---
title: "Uptime Calculator"
id: "10479"
type: "page"
slug: "uptime-calculator"
published_at: "2026-04-23T10:47:52+00:00"
modified_at: "2026-05-13T08:51:39+00:00"
url: "https://capow.energy/uptime-calculator/"
markdown_url: "https://capow.energy/uptime-calculator.md"
excerpt: "CaPow — Uptime Calculator Uptime Calculator Calculate by how much can CaPow boost your operational efficiency 📸 Screenshot 📊 Export to Excel 💾 Save Results m ft Energy equation: AGV overall consumption × overall route time < CaPow Power Delivery..."
---

CaPow — Uptime Calculator**Energy equation:** AGV overall consumption × overall route time < CaPow Power Delivery × time spent over CaPow pads

Route & Robot Parameters

Route Configuration

Let's build the longest "worst case scenario" route.  
 If this yields 100% uptime, your entire fleet will never stop for charging.

+ Add Route SectionTotal Route Lengthm

Average Speed (Weighted)m/s

⏱

Stopping Points

Stopping points are ideal for a power boost.

+ Add Stopping Point

⚡

Power

Consumption vs. delivery.

AGV Average ConsumptionW

— Select robot — Geek+ P40 (50W) Ocado Chuck (60W) MiR250 (100W) Omron LD90 (100W) Geek+ P800 (100W) MiR500/600/1350 (130W) Omron MD650 (140W) Geek+ RSx Roboshuttle (200W) Other

CaPow OutputW

🔋

Modular Charging Pads

Let's juice your robot while it's in motion!

+ Add Charging Pad

🧮

Calculated Pad Values

Total Pad Lengthm

Time Over Padss

Route Visualization

🗺️

Route Map

Auto-updates as you configure your route

Route Section

Charging Pad

Stop Point

🤖

 AGV Direction

Energy Balance Results

Power Balance Summary

— Calculating

AGV Total Consumption

—

Watt-seconds (Ws)

Static Charging (Stops)

—

Watt-seconds (Ws)

Modular Charging (Pads)

—

Watt-seconds (Ws)

Total Charging Output

—

Watt-seconds (Ws)

Net Power Balance

—

Watt-seconds (Ws)

Energy Coverage

—coverage

Calculation Breakdown

ParameterValueUnit

Course Duration—s

AGV Overall Consumption—Ws

Charging at Stop Points—Ws

Total Pad Length—m

Time Over Charging Pads—s

Modular Charging Output—Ws

Net Power Balance—Ws

Improved work:charge ratio—×

⚙️

Awaiting Calculation

Adjust the parameters above to see the energy balance result.

—

net power balance (Ws)

—

improved work:charge ratio

 ⚡ Energy Summary

Total Distance—

Avg Speed—

Total Stops—

Stop Time—

Course Duration—

Energy Balance—

Coverage Percentage—

'; return; } const distUnit = currentUnit; const speedUnit = currentUnit + '/s'; container.innerHTML = sections.map(sec => { const displayDist = currentUnit === 'm' ? sec.distance : sec.distance * M_TO_FT; const displaySpeed = currentUnit === 'm' ? sec.speed : sec.speed * M_TO_FT; return ` Section Distance

Section Speed

×

 `; }).join(''); updateRouteTotals(); } function addSection() { const defaultDist = currentUnit === 'm' ? 100 : 328; const defaultSpeed = currentUnit === 'm' ? 0.8 : 2.6; sections.push({ id: sectionIdCounter++, name: 'Enter section name', distance: defaultDist * (currentUnit === 'm' ? 1 : FT_TO_M), speed: defaultSpeed * (currentUnit === 'm' ? 1 : FT_TO_M) }); stopVizPositions = {}; // recompute stop defaults when route changes renderSections(); calculate(); } function removeSection(id) { sections = sections.filter(s => s.id !== id); stopVizPositions = {}; // recompute stop defaults when route changes renderSections(); calculate(); } function updateSectionName(id, name) { const sec = sections.find(s => s.id === id); if (sec) { sec.name = name.trim() || 'Section'; renderPads(); // Update pad dropdowns to show new section name } } function updateSectionDistance(id, val) { const sec = sections.find(s => s.id === id); if (sec) { let dist = parseFloat(val) || 0; if (currentUnit === 'ft') dist *= FT_TO_M; sec.distance = dist; updateRouteTotals(); calculate(); } } function updateSectionSpeed(id, val) { const sec = sections.find(s => s.id === id); if (sec) { let speed = parseFloat(val) || 0; if (currentUnit === 'ft') speed *= FT_TO_M; sec.speed = speed; updateRouteTotals(); calculate(); } } function updateRouteTotals() { const totalDist = sections.reduce((sum, s) => sum + s.distance, 0); const weightedSpeedSum = sections.reduce((sum, s) => sum + (s.distance * s.speed), 0); const avgSpeed = totalDist > 0 ? weightedSpeedSum / totalDist : 0; const displayDist = currentUnit === 'm' ? totalDist : totalDist * M_TO_FT; const displaySpeed = currentUnit === 'm' ? avgSpeed : avgSpeed * M_TO_FT; document.getElementById('totalRouteDisplay').value = displayDist.toFixed(currentUnit==='ft'?2:0); document.getElementById('avgSpeedDisplay').value = displaySpeed.toFixed(2); } // Stops function renderStops() { const container = document.getElementById('stopsList'); if (!stops.length) { container.innerHTML = 'No stopping points.

'; return; } container.innerHTML = stops.map(stop => ` Duration (seconds)

×

 `).join(''); } function addStop() { stops.push({ id: stopIdCounter++, name: 'Enter stopping point name', duration: 60 }); stopVizPositions = {}; // recompute all defaults with new stop count renderStops(); calculate(); } function removeStop(id) { stops = stops.filter(s => s.id !== id); delete stopVizPositions[id]; renderStops(); calculate(); } function updateStopName(id, name) { const stop = stops.find(s => s.id === id); if (stop) stop.name = name.trim() || 'Stop'; } function updateStopDuration(id, val) { const stop = stops.find(s => s.id === id); if (stop) { stop.duration = parseFloat(val) || 0; calculate(); } } function getTotalStopTime() { return stops.reduce((sum, s) => sum + s.duration, 0); } // Charging Pads Functions function renderPads() { const container = document.getElementById('padsList'); if (!pads.length) { container.innerHTML = 'No charging pads configured.

'; return; } const distUnit = currentUnit; container.innerHTML = pads.map(pad => { const displayLength = currentUnit === 'm' ? pad.length : pad.length * M_TO_FT; const section = sections.find(s => s.id === pad.sectionId); const sectionSpeed = section ? (currentUnit === 'm' ? section.speed : section.speed * M_TO_FT) : 0; const speedUnit = currentUnit + '/s'; return ` Pad Length (${distUnit})

Quantity

Route Section

 ${sections.map(s => { const sSpeed = currentUnit === 'm' ? s.speed : s.speed * M_TO_FT; return `${s.name}`; }).join('')}

Selected: ${section ? section.name : 'None'} at ${sectionSpeed.toFixed(1)} ${speedUnit} • ${pad.quantity || 1}× pads = ${section ? ((pad.length / section.speed) * (pad.quantity || 1)).toFixed(1) : '—'}s total

×

 `; }).join(''); updatePadTotals(); } function addPad() { const defaultLength = currentUnit === 'm' ? 10 : 32.8; const firstSectionId = sections.length > 0 ? sections[0].id : 1; pads.push({ id: padIdCounter++, length: defaultLength * (currentUnit === 'm' ? 1 : FT_TO_M), quantity: 1, sectionId: firstSectionId }); renderPads(); calculate(); } function removePad(id) { pads = pads.filter(p => p.id !== id); renderPads(); calculate(); } function updatePadLength(id, val) { const pad = pads.find(p => p.id === id); if (pad) { let length = parseFloat(val) || 0; if (currentUnit === 'ft') length *= FT_TO_M; pad.length = length; updatePadTotals(); calculate(); } } function updatePadSection(id, sectionId) { const pad = pads.find(p => p.id === id); if (pad) { pad.sectionId = parseInt(sectionId); renderPads(); calculate(); } } function updatePadQuantity(id, val) { const pad = pads.find(p => p.id === id); if (pad) { pad.quantity = Math.max(1, parseInt(val) || 1); updatePadTotals(); calculate(); } } function updatePadTotals() { const totalLength = pads.reduce((sum, p) => sum + (p.length * (p.quantity || 1)), 0); const totalTime = pads.reduce((sum, p) => { const section = sections.find(s => s.id === p.sectionId); return sum + (section ? (p.length / section.speed) * (p.quantity || 1) : 0); }, 0); const displayLength = currentUnit === 'm' ? totalLength : totalLength * M_TO_FT; // Update the computed values section (only one now) document.getElementById('totalPadLength2').value = displayLength.toFixed(currentUnit==='ft'?2:1); document.getElementById('timeOverPads2').value = totalTime.toFixed(1); } // Robot Preset function applyRobotPreset() { const sel = document.getElementById('robotPreset'); const tag = document.getElementById('robotTag'); const tagText = document.getElementById('robotTagText'); const customField = document.getElementById('customRobotField'); if (!sel.value) { tag.style.opacity = '0'; customField.style.display = 'none'; return; } if (sel.value === 'other') { customField.style.display = 'block'; tagText.textContent = 'Custom robot'; tag.style.opacity = '1'; // Don't change consumption value for "other" calculate(); return; } customField.style.display = 'none'; document.getElementById('agvConsumption').value = sel.value; tagText.textContent = sel.options[sel.selectedIndex].text + ' selected'; tag.style.opacity = '1'; calculate(); } function onManualConsumption() { const sel = document.getElementById('robotPreset'); if (sel.value !== 'other') { sel.value = ''; } if (sel.value !== 'other') { document.getElementById('robotTag').style.opacity = '0'; } calculate(); } // ─── ROUTE VISUALIZER ──────────────────────────────────────────────────────── function renderRouteVisualization() { const svg = document.getElementById('routeVizSvg'); const canvas = document.getElementById('routeVizCanvas'); if (!svg || !sections.length) return; const totalDist = sections.reduce((s, sec) => s + sec.distance, 0); if (totalDist === 0) return; const numSec = sections.length; const distUnit = currentUnit; const M2FT = 3.28084; // ── Layout decision ────────────────────────────────────────────────────── // 1–3 sections → horizontal straight line // 4–6 sections → L-shape (two rows connected by elbow) // 7+ → rectangular loop const layout = numSec <= 3 ? 'linear' : numSec <= 6 ? 'lshape' : 'loop'; const TRACK_H = 14; const TRACK_RADIUS = 7; const PAD_COLOR = '#059669'; const PAD_OPACITY = 0.55; const STOP_R = 8; const SECTION_COLORS = ['#7F7C7C','#9C9999','#B5B3B3','#6A6868','#888585','#AFAFAF','#5E5C5C']; if (layout === 'linear') { const W = Math.max(canvas.clientWidth - 48, 400); const CY = 100; const SVG_H = 195; svg.setAttribute('viewBox', `0 0 ${W} ${SVG_H}`); svg.setAttribute('height', SVG_H); let html = ''; let curX = 0; const sectionRects = sections.map((sec, i) => { const frac = sec.distance / totalDist; const sw = frac * W; const rx = curX; curX += sw; return { rx, sw, sec, i }; }); sectionRects.forEach(({ rx, sw, sec, i }) => { const col = SECTION_COLORS[i % SECTION_COLORS.length]; html += ``; const lx = rx + sw/2; const displayDist = (currentUnit==='m' ? sec.distance : sec.distance * M2FT).toFixed(currentUnit==='ft'?1:0); if (sw > 40) { html += `${escSvg(sec.name)}`; html += `${displayDist}${distUnit}`; } if (i > 0) html += ``; }); // Right end cap const last = sectionRects[sectionRects.length-1]; html += ``; html += `🤖`; html += ``; svg.innerHTML = html; renderPadMarkers(svg, sectionRects.map(r => ({...r, cy: CY})), TRACK_H); renderStopMarkers(svg, 'linear', { W, CY }); } else if (layout === 'lshape') { const half = Math.ceil(numSec/2); const row1secs = sections.slice(0, half); const row2secs = sections.slice(half); const row1dist = row1secs.reduce((s,sec)=>s+sec.distance,0); const row2dist = row2secs.reduce((s,sec)=>s+sec.distance,0); const W = Math.max(canvas.clientWidth - 48, 500); const ROW_GAP = 110; const CY1 = 100; const CY2 = CY1 + ROW_GAP; const SVG_H = CY2 + 110; const CORNER_GAP = 36; svg.setAttribute('viewBox', `0 0 ${W} ${SVG_H}`); svg.setAttribute('height', SVG_H); let html = ''; let curX = 0; const row1Rects = row1secs.map((sec, i) => { const frac = row1dist>0 ? sec.distance/row1dist : 1/row1secs.length; const sw = frac*(W-CORNER_GAP); const rx = curX; curX += sw; return {rx, sw, sec, i}; }); row1Rects.forEach(({rx,sw,sec,i}) => { const col = SECTION_COLORS[i%SECTION_COLORS.length]; html += ``; const lx = rx+sw/2; const dd = (currentUnit==='m' ? sec.distance : sec.distance*M2FT).toFixed(currentUnit==='ft'?1:0); if (sw>40){ html += `${dd}${distUnit}`; } if (i>0) html += ``; }); const elbowX = row1Rects[row1Rects.length-1].rx + row1Rects[row1Rects.length-1].sw; html += ``; curX = 0; const row2Rects = row2secs.map((sec,i) => { const frac = row2dist>0 ? sec.distance/row2dist : 1/row2secs.length; const sw = frac*(W-CORNER_GAP); const rx = curX; curX += sw; return {rx, sw, sec, i, gi: half+i}; }); row2Rects.forEach(({rx,sw,sec,i,gi}) => { const col = SECTION_COLORS[gi%SECTION_COLORS.length]; html += ``; const lx = rx+sw/2; const dd = (currentUnit==='m' ? sec.distance : sec.distance*M2FT).toFixed(currentUnit==='ft'?1:0); if (sw>40){ html += `${escSvg(sec.name)}`; html += `${dd}${distUnit}`; } if (i>0) html += ``; }); // Stops html += `🤖`; svg.innerHTML = html; const allRects2 = [...row1Rects.map(r=>({...r,cy:CY1})), ...row2Rects.map(r=>({...r,cy:CY2}))]; renderPadMarkers(svg, allRects2, TRACK_H); renderStopMarkers(svg, 'lshape', { W, CY1, CY2, CORNER_GAP, row1dist, row2dist }); } else { // LOOP const W = Math.max(canvas.clientWidth-48, 500); const LPAD = 40; const INNER_W = W-LPAD*2; const INNER_H = 80; const TOP_Y = 85; const BOT_Y = TOP_Y+INNER_H; const SVG_H = BOT_Y+100; const half = Math.ceil(numSec/2); const topSecs = sections.slice(0, half); const botSecs = sections.slice(half).reverse(); const topDist = topSecs.reduce((s,sec)=>s+sec.distance,0); const botDist = botSecs.reduce((s,sec)=>s+sec.distance,0); svg.setAttribute('viewBox', `0 0 ${W} ${SVG_H}`); svg.setAttribute('height', SVG_H); let html = ''; html += ``; let curX = LPAD; topSecs.forEach((sec,i) => { const frac = topDist>0?sec.distance/topDist:1/topSecs.length; const sw = frac*INNER_W; const col = SECTION_COLORS[i%SECTION_COLORS.length]; html += ``; const lx = curX+sw/2; const dd=(currentUnit==='m'?sec.distance:sec.distance*M2FT).toFixed(currentUnit==='ft'?1:0); if (sw>40){ html += `${escSvg(sec.name)}`; html += `${dd}${distUnit}`; } if (i>0) html += ``; curX += sw; }); curX = LPAD; botSecs.forEach((sec,i) => { const gi = half+i; const frac = botDist>0?sec.distance/botDist:1/botSecs.length; const sw = frac*INNER_W; const col = SECTION_COLORS[gi%SECTION_COLORS.length]; html += ``; const lx = curX+sw/2; const dd=(currentUnit==='m'?sec.distance:sec.distance*M2FT).toFixed(currentUnit==='ft'?1:0); if (sw>40){ html += `${escSvg(sec.name)}`; html += `${dd}${distUnit}`; } if (i>0) html += ``; curX += sw; }); html += ``; html += ``; // Stops at corners html+=`🤖`; html+=``; html+=``; svg.innerHTML = html; const buildLoopRects = (secs, dist, cy) => { let x = LPAD; return secs.map(sec => { const frac = dist > 0 ? sec.distance / dist : 1 / secs.length; const sw = frac * INNER_W; const rx = x; x += sw; return { sec, rx, sw, cy }; }); }; const loopRects = [...buildLoopRects(topSecs, topDist, TOP_Y), ...buildLoopRects(botSecs, botDist, BOT_Y)]; renderPadMarkers(svg, loopRects, TRACK_H); renderStopMarkers(svg, 'loop', { W, LPAD, INNER_W, TOP_Y, BOT_Y }); } } function escSvg(str) { return String(str).replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── Draggable pad logic ─────────────────────────────────────────────────────── function ensurePadVizOffsets() { // Clean up stale entries first Object.keys(padVizOffsets).forEach(id => { if (!pads.find(p => p.id === parseInt(id))) delete padVizOffsets[id]; }); // Group pads by section and assign non-overlapping default offsets const bySec = {}; pads.forEach(pad => { if (!bySec[pad.sectionId]) bySec[pad.sectionId] = []; bySec[pad.sectionId].push(pad); }); Object.values(bySec).forEach(secPads => { // Only assign defaults for pads that don't have a position yet const needsDefault = secPads.filter(p => padVizOffsets[p.id] === undefined); if (needsDefault.length === 0) return; // Find the section and its pixel width to compute pad pixel widths const sec = sections.find(s => s.id === secPads[0].sectionId); if (!sec || sec.distance === 0) { needsDefault.forEach((p, i) => { padVizOffsets[p.id] = (i + 1) / (needsDefault.length + 1); }); return; } // Space new pads evenly among the gaps not occupied by already-placed pads needsDefault.forEach((pad, i) => { padVizOffsets[pad.id] = (i + 1) / (secPads.length + 1); }); }); } function getPadPixelBounds(pad, sRect) { // Returns { left, right } pixel bounds of this pad given its current offset const sec = sections.find(s => s.id === pad.sectionId); if (!sec || sec.distance === 0) return { left: sRect.rx, right: sRect.rx }; const padFrac = Math.min((pad.length * (pad.quantity || 1)) / sec.distance, 1); const pw = Math.max(sRect.sw * padFrac, 6); const offset = padVizOffsets[pad.id] !== undefined ? padVizOffsets[pad.id] : 0.5; const maxTravel = sRect.sw - pw; const left = sRect.rx + offset * maxTravel; return { left, right: left + pw, pw }; } function getPadPixelX(pad, sRect) { return getPadPixelBounds(pad, sRect).left; } function renderPadMarkers(svg, sectionRects, TRACK_H) { ensurePadVizOffsets(); const existing = svg.querySelector('#padMarkersGroup'); if (existing) existing.remove(); const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.setAttribute('id', 'padMarkersGroup'); const PAD_COLOR = '#059669'; const PAD_OPACITY = 0.7; pads.forEach(pad => { const sec = sections.find(s => s.id === pad.sectionId); if (!sec || sec.distance === 0) return; const sRect = sectionRects.find(r => r.sec.id === pad.sectionId); if (!sRect) return; const padFrac = Math.min((pad.length * (pad.quantity || 1)) / sec.distance, 1); const pw = Math.max(sRect.sw * padFrac, 6); // min 6px so it's always grabbable const cy = sRect.cy; const pg = document.createElementNS('http://www.w3.org/2000/svg', 'g'); pg.setAttribute('data-pad-id', pad.id); pg.setAttribute('cursor', 'ew-resize'); // Pad rect const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); rect.setAttribute('y', cy - TRACK_H / 2); rect.setAttribute('width', pw); rect.setAttribute('height', TRACK_H); rect.setAttribute('rx', 4); rect.setAttribute('fill', PAD_COLOR); rect.setAttribute('opacity', PAD_OPACITY); rect.setAttribute('class', 'pad-rect'); pg.appendChild(rect); // ⚡ icon if (pw > 14) { const icon = document.createElementNS('http://www.w3.org/2000/svg', 'text'); icon.setAttribute('text-anchor', 'middle'); icon.setAttribute('y', cy + 4); icon.setAttribute('font-size', '9'); icon.setAttribute('font-family', 'Lato,sans-serif'); icon.setAttribute('font-weight', '700'); icon.setAttribute('fill', 'white'); icon.setAttribute('pointer-events', 'none'); icon.setAttribute('class', 'pad-icon'); icon.textContent = '⚡'; pg.appendChild(icon); } // Drag hint const hint = document.createElementNS('http://www.w3.org/2000/svg', 'text'); hint.setAttribute('text-anchor', 'middle'); hint.setAttribute('y', cy - TRACK_H / 2 - 20); hint.setAttribute('font-size', '9'); hint.setAttribute('font-family', 'Lato,sans-serif'); hint.setAttribute('fill', '#7F7C7C'); hint.setAttribute('pointer-events', 'none'); hint.setAttribute('class', 'pad-drag-hint'); hint.setAttribute('opacity', '0'); hint.textContent = '⟵ drag ⟶'; pg.appendChild(hint); // Pad length label — moves with the pad const padLenM = pad.length; const padLenDisplay = (currentUnit === 'm' ? padLenM : padLenM * 3.28084).toFixed(1); const qty = pad.quantity || 1; const padLabelText = qty > 1 ? `⚡ ${padLenDisplay}${currentUnit} ×${qty}` : `⚡ ${padLenDisplay}${currentUnit}`; const padLbl = document.createElementNS('http://www.w3.org/2000/svg', 'text'); padLbl.setAttribute('text-anchor', 'middle'); padLbl.setAttribute('y', cy + TRACK_H / 2 + 13); padLbl.setAttribute('font-size', '9'); padLbl.setAttribute('font-family', 'Lato,sans-serif'); padLbl.setAttribute('font-weight', '600'); padLbl.setAttribute('fill', PAD_COLOR); padLbl.setAttribute('pointer-events', 'none'); padLbl.setAttribute('class', 'pad-lbl'); padLbl.textContent = padLabelText; pg.appendChild(padLbl); // Set initial x position const px = getPadPixelX(pad, sRect); updatePadGroupX(pg, px, pw); attachPadDrag(pg, pad.id, sRect, pw, TRACK_H); g.appendChild(pg); }); svg.appendChild(g); } function updatePadGroupX(pg, px, pw) { const rect = pg.querySelector('.pad-rect'); const icon = pg.querySelector('.pad-icon'); const hint = pg.querySelector('.pad-drag-hint'); const lbl = pg.querySelector('.pad-lbl'); if (rect) rect.setAttribute('x', px); if (icon) icon.setAttribute('x', px + pw / 2); if (hint) hint.setAttribute('x', px + pw / 2); if (lbl) lbl.setAttribute('x', px + pw / 2); } function attachPadDrag(pg, padId, sRect, pw, TRACK_H) { let dragging = false; let dragStartSvgX = 0; let dragStartOffset = 0; const hint = pg.querySelector('.pad-drag-hint'); function getSvgX(e) { const svg = pg.closest('svg'); const rect = svg.getBoundingClientRect(); const touch = e.touches && e.touches.length ? e.touches[0] : (e.changedTouches && e.changedTouches.length ? e.changedTouches[0] : null); const clientX = touch ? touch.clientX : e.clientX; const vb = svg.getAttribute('viewBox').split(' '); return (clientX - rect.left) * (parseFloat(vb[2]) / rect.width); } function onStart(e) { e.preventDefault(); e.stopPropagation(); dragging = true; pg.setAttribute('cursor', 'grabbing'); if (hint) hint.setAttribute('opacity', '0'); dragStartSvgX = getSvgX(e); dragStartOffset = padVizOffsets[padId] !== undefined ? padVizOffsets[padId] : 0.5; const grp = pg.closest('svg').querySelector('#padMarkersGroup'); if (grp) grp.appendChild(pg); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onEnd); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onEnd); } function onMove(e) { if (!dragging) return; e.preventDefault(); const dx = getSvgX(e) - dragStartSvgX; const maxTravel = sRect.sw - pw; if (maxTravel <= 0) return; // Raw desired offset let newOffset = Math.max(0, Math.min(1, dragStartOffset + dx / maxTravel)); // Clamp against sibling pads in the same section const thisPad = pads.find(p => p.id === padId); const siblings = pads.filter(p => p.sectionId === thisPad.sectionId && p.id !== padId); siblings.forEach(sib => { const sibBounds = getPadPixelBounds(sib, sRect); const myLeft = sRect.rx + newOffset * maxTravel; const myRight = myLeft + pw; // Gap of 2px between pads const GAP = 2; if (myRight > sibBounds.left - GAP && myLeft < sibBounds.right + GAP) { // Collision — push to whichever side we came from if (dragStartOffset * maxTravel + sRect.rx < sibBounds.left) { // We were to the left — clamp right edge to sibling's left const clampedLeft = sibBounds.left - GAP - pw; newOffset = Math.max(0, (clampedLeft - sRect.rx) / maxTravel); } else { // We were to the right — clamp left edge to sibling's right const clampedLeft = sibBounds.right + GAP; newOffset = Math.min(1, (clampedLeft - sRect.rx) / maxTravel); } } }); padVizOffsets[padId] = newOffset; const pad = pads.find(p => p.id === padId); const newPx = getPadPixelX(pad, sRect); updatePadGroupX(pg, newPx, pw); } function onEnd() { if (!dragging) return; dragging = false; pg.setAttribute('cursor', 'ew-resize'); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onEnd); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onEnd); } pg.addEventListener('mousedown', onStart); pg.addEventListener('touchstart', onStart, { passive: false }); pg.addEventListener('mouseenter', () => { if (!dragging && hint) hint.setAttribute('opacity', '1'); }); pg.addEventListener('mouseleave', () => { if (!dragging && hint) hint.setAttribute('opacity', '0'); }); } // ───────────────────────────────────────────────────────────────────────────── // Each stop has a viz-only position stored as a 0–1 fraction of the total route. // Returns the {x, cy} pixel position for a given fraction in the current layout. function getStopDefaultFraction(stopIndex) { // Place stops at section boundaries (1/n, 2/n, etc.) so they don't overlap section name labels const n = sections.length; if (n <= 1) return 0.85; // single section: push to near the end // Each stop sits at the boundary between sections const boundaryFrac = (stopIndex + 1) / n; return Math.min(boundaryFrac, 0.95); } function ensureStopVizPositions() { stops.forEach((stop, i) => { if (stopVizPositions[stop.id] === undefined) { stopVizPositions[stop.id] = getStopDefaultFraction(i); } }); // Remove stale entries Object.keys(stopVizPositions).forEach(id => { if (!stops.find(s => s.id === parseInt(id))) delete stopVizPositions[id]; }); } function fractionToPoint(frac, layout, params) { // Returns {x, y} pixel coords for a 0–1 fraction along the route path const f = Math.max(0, Math.min(1, frac)); if (layout === 'linear') { return { x: f * params.W, y: params.CY }; } else if (layout === 'lshape') { // Row1 is first half, row2 is second half const split = params.row1dist / (params.row1dist + params.row2dist); if (f <= split) { const localF = f / split; return { x: localF * (params.W - params.CORNER_GAP), y: params.CY1 }; } else { const localF = (f - split) / (1 - split); return { x: localF * (params.W - params.CORNER_GAP), y: params.CY2 }; } } else { // Loop: top goes 0→0.5 left→right, bottom goes 0.5→1 right→left if (f <= 0.5) { const localF = f / 0.5; return { x: params.LPAD + localF * params.INNER_W, y: params.TOP_Y }; } else { const localF = (f - 0.5) / 0.5; return { x: params.LPAD + (1 - localF) * params.INNER_W, y: params.BOT_Y }; } } } function pointToFraction(px, py, layout, params) { if (layout === 'linear') { return Math.max(0, Math.min(1, px / params.W)); } else if (layout === 'lshape') { const split = params.row1dist / (params.row1dist + params.row2dist); // Which row is closer? const distToRow1 = Math.abs(py - params.CY1); const distToRow2 = Math.abs(py - params.CY2); const trackW = params.W - params.CORNER_GAP; if (distToRow1 <= distToRow2) { const localF = Math.max(0, Math.min(1, px / trackW)); return localF * split; } else { const localF = Math.max(0, Math.min(1, px / trackW)); return split + localF * (1 - split); } } else { // Loop: snap to top or bottom row based on which is closer const distTop = Math.abs(py - params.TOP_Y); const distBot = Math.abs(py - params.BOT_Y); const localF = Math.max(0, Math.min(1, (px - params.LPAD) / params.INNER_W)); if (distTop <= distBot) { return localF * 0.5; } else { return 0.5 + (1 - localF) * 0.5; } } } function renderStopMarkers(svg, layout, params) { ensureStopVizPositions(); // Remove existing stop marker group const existing = svg.querySelector('#stopMarkersGroup'); if (existing) existing.remove(); const g = document.createElementNS('http://www.w3.org/2000/svg', 'g'); g.setAttribute('id', 'stopMarkersGroup'); const STOP_R = 9; stops.forEach((stop, i) => { const frac = stopVizPositions[stop.id] !== undefined ? stopVizPositions[stop.id] : getStopDefaultFraction(i); const pt = fractionToPoint(frac, layout, params); const lbl = stop.name.length > 12 ? stop.name.slice(0, 11) + '…' : stop.name; const sg = document.createElementNS('http://www.w3.org/2000/svg', 'g'); sg.setAttribute('data-stop-id', stop.id); sg.setAttribute('cursor', 'grab'); sg.setAttribute('transform', `translate(${pt.x},${pt.y})`); // Shadow for drag affordance const shadow = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); shadow.setAttribute('r', STOP_R + 4); shadow.setAttribute('fill', 'rgba(243,75,84,0.12)'); sg.appendChild(shadow); // Main dot const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('r', STOP_R); circle.setAttribute('fill', '#F34B54'); circle.setAttribute('stroke', '#c0001a'); circle.setAttribute('stroke-width', '2'); sg.appendChild(circle); // Icon const icon = document.createElementNS('http://www.w3.org/2000/svg', 'text'); icon.setAttribute('text-anchor', 'middle'); icon.setAttribute('dominant-baseline', 'central'); icon.setAttribute('font-size', '8'); icon.setAttribute('font-family', 'Lato,sans-serif'); icon.setAttribute('fill', 'white'); icon.setAttribute('font-weight', '700'); icon.setAttribute('pointer-events', 'none'); icon.textContent = '⏱'; sg.appendChild(icon); // Label (above or below depending on track row) const labelOffset = (layout === 'lshape' && pt.y === params.CY2) || (layout === 'loop' && pt.y === params.BOT_Y) ? STOP_R + 14 : -(STOP_R + 6); const durationOffset = labelOffset > 0 ? labelOffset + 10 : labelOffset - 10; const nameText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); nameText.setAttribute('text-anchor', 'middle'); nameText.setAttribute('y', labelOffset); nameText.setAttribute('font-family', 'Lato,sans-serif'); nameText.setAttribute('font-size', '10'); nameText.setAttribute('font-weight', '600'); nameText.setAttribute('fill', '#F34B54'); nameText.setAttribute('pointer-events', 'none'); nameText.textContent = lbl; sg.appendChild(nameText); const durText = document.createElementNS('http://www.w3.org/2000/svg', 'text'); durText.setAttribute('text-anchor', 'middle'); durText.setAttribute('y', durationOffset); durText.setAttribute('font-family', 'Lato,sans-serif'); durText.setAttribute('font-size', '9'); durText.setAttribute('fill', '#6B7280'); durText.setAttribute('pointer-events', 'none'); durText.textContent = stop.duration + 's'; sg.appendChild(durText); // Drag hint (shows on hover, always below dot to avoid clipping section labels) const tooltip = document.createElementNS('http://www.w3.org/2000/svg', 'text'); tooltip.setAttribute('text-anchor', 'middle'); tooltip.setAttribute('y', STOP_R + 22); tooltip.setAttribute('font-family', 'Lato,sans-serif'); tooltip.setAttribute('font-size', '9'); tooltip.setAttribute('fill', '#7F7C7C'); tooltip.setAttribute('pointer-events', 'none'); tooltip.setAttribute('class', 'drag-hint'); tooltip.setAttribute('opacity', '0'); tooltip.textContent = '⟵ drag ⟶'; sg.appendChild(tooltip); attachStopDrag(sg, stop.id, layout, params, svg); g.appendChild(sg); }); svg.appendChild(g); } function attachStopDrag(sg, stopId, layout, params, svg) { let dragging = false; const hint = sg.querySelector('.drag-hint'); function getSvgPoint(e) { const rect = svg.getBoundingClientRect(); const touch = e.touches && e.touches.length ? e.touches[0] : (e.changedTouches && e.changedTouches.length ? e.changedTouches[0] : null); const clientX = touch ? touch.clientX : e.clientX; const clientY = touch ? touch.clientY : e.clientY; const vb = svg.getAttribute('viewBox').split(' '); const scaleX = parseFloat(vb[2]) / rect.width; const scaleY = parseFloat(vb[3]) / rect.height; return { x: (clientX - rect.left) * scaleX, y: (clientY - rect.top) * scaleY }; } function onStart(e) { e.preventDefault(); e.stopPropagation(); dragging = true; sg.setAttribute('cursor', 'grabbing'); if (hint) hint.setAttribute('opacity', '0'); // Bring to front within the markers group const grp = svg.querySelector('#stopMarkersGroup'); if (grp) grp.appendChild(sg); window.addEventListener('mousemove', onMove); window.addEventListener('mouseup', onEnd); window.addEventListener('touchmove', onMove, { passive: false }); window.addEventListener('touchend', onEnd); } function onMove(e) { if (!dragging) return; e.preventDefault(); const pt = getSvgPoint(e); const frac = pointToFraction(pt.x, pt.y, layout, params); stopVizPositions[stopId] = frac; const newPt = fractionToPoint(frac, layout, params); sg.setAttribute('transform', `translate(${newPt.x},${newPt.y})`); // Update label vertical flip when crossing between top/bottom rows const STOP_R = 9; const isBottomRow = (layout === 'lshape' && newPt.y === params.CY2) || (layout === 'loop' && newPt.y === params.BOT_Y); const labelOffset = isBottomRow ? STOP_R + 14 : -(STOP_R + 6); const durOffset = isBottomRow ? labelOffset + 11 : labelOffset - 11; // Child order: [0]=shadow, [1]=circle, [2]=icon-text, [3]=name-text, [4]=dur-text, [5]=hint-text const children = sg.children; if (children[3]) children[3].setAttribute('y', labelOffset); if (children[4]) children[4].setAttribute('y', durOffset); } function onEnd() { if (!dragging) return; dragging = false; sg.setAttribute('cursor', 'grab'); window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onEnd); window.removeEventListener('touchmove', onMove); window.removeEventListener('touchend', onEnd); } sg.addEventListener('mousedown', onStart); sg.addEventListener('touchstart', onStart, { passive: false }); // Show/hide hint on hover sg.addEventListener('mouseenter', () => { if (!dragging && hint) hint.setAttribute('opacity', '1'); }); sg.addEventListener('mouseleave', () => { if (!dragging && hint) hint.setAttribute('opacity', '0'); }); } // ───────────────────────────────────────────────────────────────────────────── // Helpers function roundTo1(el) { const val = parseFloat(el.value); if (!isNaN(val)) el.value = val.toFixed(1); } function n(id) { return parseFloat(document.getElementById(id).value) || 0; } function set(id, val, decimals=0) { const el = document.getElementById(id); if (el) el.textContent = isNaN(val) ? '—' : (decimals > 0 ? val.toFixed(decimals) : Math.round(val).toLocaleString()); } // Calculate function calculate() { const totalRouteDist = sections.reduce((sum, s) => sum + s.distance, 0); const weightedSpeedSum = sections.reduce((sum, s) => sum + (s.distance * s.speed), 0); const avgSpeed = totalRouteDist > 0 ? weightedSpeedSum / totalRouteDist : 0; const agvW = n('agvConsumption'); const capowW = n('capowOutput'); const totalStops = getTotalStopTime(); const courseDur = (avgSpeed > 0 ? totalRouteDist / avgSpeed : 0) + totalStops; const agvConsump = courseDur * agvW; const stopCharge = capowW * totalStops; // Calculate dynamic charging from all pads const totalPadLenM = pads.reduce((sum, p) => sum + (p.length * (p.quantity || 1)), 0); const timeOverPads = pads.reduce((sum, p) => { const section = sections.find(s => s.id === p.sectionId); return sum + (section ? (p.length / section.speed) * (p.quantity || 1) : 0); }, 0); const dynCharge = timeOverPads * capowW; const totalCharge = stopCharge + dynCharge; const balance = totalCharge - agvConsump; const ratio = totalCharge / agvConsump; set('resConsumption', agvConsump); set('resStaticCharging', stopCharge); set('resDynamicCharging', dynCharge); set('resCharging', totalCharge); set('resBalance', balance); set('bCourseDur', courseDur, 0); set('bAgvConsump', agvConsump); set('bStopCharge', stopCharge); set('bDynCharge', dynCharge); // Update pad breakdown values const displayPadLength = currentUnit === 'm' ? totalPadLenM : totalPadLenM * M_TO_FT; document.getElementById('bPadLength').textContent = displayPadLength.toFixed(currentUnit==='ft'?2:1); document.getElementById('bPadTime').textContent = timeOverPads.toFixed(1); const balEl = document.getElementById('bBalance'); const ratEl = document.getElementById('bRatio'); balEl.textContent = Math.round(balance).toLocaleString(); // Calculate improved work:charge ratio: 1/(1-ratio) const improvedRatio = ratio < 1 ? (1 / (1 - ratio)) : ratio; const positive = balance >= 0 && ratio >= 1; ratEl.innerHTML = positive ? '∞' : (isNaN(improvedRatio) ? '—' : improvedRatio.toFixed(2) + '×'); balEl.className = 'row-value ' + (positive ? 'green' : 'orange'); ratEl.className = 'row-value'; // No color for improved ratio // Update percentage coverage meter const coveragePercent = ratio * 100; const pct = Math.min(coveragePercent, 100); const fill = document.getElementById('meterFill'); fill.style.width = isNaN(pct) ? '0%' : pct + '%'; fill.textContent = isNaN(coveragePercent) ? '—' : coveragePercent.toFixed(1) + '%'; if (positive) { fill.className = 'meter-fill positive'; fill.style.background = 'linear-gradient(90deg, #00a86b, var(--green))'; fill.style.boxShadow = '0 0 12px rgba(0, 168, 107, 0.8)'; } else { fill.className = 'meter-fill negative'; fill.style.background = 'linear-gradient(90deg, #E04048, #F34B54)'; fill.style.boxShadow = '0 0 12px rgba(243, 75, 84, 0.8)'; } document.getElementById('coveragePercentage').textContent = isNaN(coveragePercent) ? '—' : coveragePercent.toFixed(1) + '%'; document.getElementById('coveragePercentage').style.color = positive ? 'var(--green)' : '#F34B54'; document.getElementById('resBalance').className = 'result-value ' + (positive ? 'green' : 'orange'); document.getElementById('resBalance').style.color = ''; // Remove inline style, use class const badge = document.getElementById('balanceBadge'); badge.textContent = positive ? '✓ UPTIME ACHIEVABLE' : '✗ ENERGY DEFICIT'; badge.className = 'balance-badge ' + (positive ? 'positive' : 'negative'); const verdict = document.getElementById('verdict'); const vIcon = document.getElementById('verdictIcon'); const vTitle = document.getElementById('verdictTitle'); const vSub = document.getElementById('verdictSub'); const vRatio = document.getElementById('ratioDisplay'); const vBalance = document.getElementById('balanceDisplay'); // Calculate improved work:charge ratio: 1/(1-ratio) const improvedRatioDisplay = ratio < 1 ? (1 / (1 - ratio)) : ratio; vRatio.innerHTML = positive ? '∞' : (isNaN(improvedRatioDisplay) ? '—' : improvedRatioDisplay.toFixed(2) + '×'); vRatio.className = 'ratio-big neutral'; // No color // Display Net Power Balance vBalance.textContent = isNaN(balance) ? '—' : Math.round(balance).toLocaleString(); vBalance.className = 'ratio-big neutral'; // No color if (positive) { verdict.className = 'verdict positive'; vIcon.textContent = '✅'; vTitle.textContent = '100% Uptime Achievable'; vTitle.className = 'verdict-title positive'; vSub.innerHTML = `- ✅ Energy equation is positive, and 100% uptime is achieved
- 🟢 Energy surplus: **${Math.round(balance).toLocaleString()} Ws**
- 🚀 Continuous operation without charging downtime

`; } else { verdict.className = 'verdict negative'; vIcon.textContent = ''; vTitle.textContent = 'We need a bit more Juice'; vTitle.className = 'verdict-title negative'; const pctCovered = (ratio * 100).toFixed(1); const workHours = (6 * improvedRatioDisplay).toFixed(1); vSub.innerHTML = `- ⚡ Power delivery covers **${pctCovered}%** of energy needs
- 🚀 Uptime is boosted by **${improvedRatioDisplay.toFixed(2)}×**
- 🔋 Instead of 1 hour of charging per 6 hours of work, you'll now get **${workHours} hours** of work per 1 hour of charging
- 💡 Consider adding more charging pads or increasing/adding stopping times to reach 100% coverage

`; } updatePreview(); renderRouteVisualization(); } // Update Preview Panel function updatePreview() { const distUnit = currentUnit; const speedUnit = currentUnit + '/s'; // Update summary stats const totalRouteDist = sections.reduce((sum, s) => sum + s.distance, 0); const weightedSpeedSum = sections.reduce((sum, s) => sum + (s.distance * s.speed), 0); const avgSpeed = totalRouteDist > 0 ? weightedSpeedSum / totalRouteDist : 0; const totalStops = getTotalStopTime(); const displayDist = currentUnit === 'm' ? totalRouteDist : totalRouteDist * M_TO_FT; const displaySpeed = currentUnit === 'm' ? avgSpeed : avgSpeed * M_TO_FT; document.getElementById('prevTotalDist').textContent = displayDist.toFixed(currentUnit==='ft'?1:0) + ' ' + distUnit; document.getElementById('prevAvgSpeed').textContent = displaySpeed.toFixed(2) + ' ' + speedUnit; document.getElementById('prevTotalStops').textContent = stops.length; document.getElementById('prevStopTime').textContent = totalStops.toFixed(0) + ' s'; // Get calculated values const courseDur = (avgSpeed > 0 ? totalRouteDist / avgSpeed : 0) + totalStops; document.getElementById('prevCourseDur').textContent = courseDur.toFixed(0) + ' s'; // Get balance from main calculation const agvW = n('agvConsumption'); const capowW = n('capowOutput'); const agvConsump = courseDur * agvW; const stopCharge = capowW * totalStops; const totalPadLenM = pads.reduce((sum, p) => sum + (p.length * (p.quantity || 1)), 0); const timeOverPads = pads.reduce((sum, p) => { const section = sections.find(s => s.id === p.sectionId); return sum + (section ? (p.length / section.speed) * (p.quantity || 1) : 0); }, 0); const dynCharge = timeOverPads * capowW; const totalCharge = stopCharge + dynCharge; const balance = totalCharge - agvConsump; const ratio = totalCharge / agvConsump; const balanceEl = document.getElementById('prevBalance'); balanceEl.textContent = Math.round(balance).toLocaleString() + ' Ws'; balanceEl.className = 'preview-stat-value ' + (balance >= 0 ? 'green' : 'orange'); const ratioEl = document.getElementById('prevRatio'); const coveragePct = ratio * 100; ratioEl.textContent = (isNaN(coveragePct) ? '—' : coveragePct.toFixed(1) + '%'); ratioEl.className = 'preview-stat-value ' + (ratio >= 1 ? 'green' : 'orange'); } // Export to Excel function exportToExcel() { const distUnit = currentUnit; const speedUnit = currentUnit + '/s'; // Gather all data const totalRouteDist = sections.reduce((sum, s) => sum + s.distance, 0); const weightedSpeedSum = sections.reduce((sum, s) => sum + (s.distance * s.speed), 0); const avgSpeed = totalRouteDist > 0 ? weightedSpeedSum / totalRouteDist : 0; const agvW = n('agvConsumption'); const capowW = n('capowOutput'); const totalStops = getTotalStopTime(); const courseDur = (avgSpeed > 0 ? totalRouteDist / avgSpeed : 0) + totalStops; const agvConsump = courseDur * agvW; const stopCharge = capowW * totalStops; const totalPadLenM = pads.reduce((sum, p) => sum + (p.length * (p.quantity || 1)), 0); const timeOverPads = pads.reduce((sum, p) => { const section = sections.find(s => s.id === p.sectionId); return sum + (section ? (p.length / section.speed) * (p.quantity || 1) : 0); }, 0); const dynCharge = timeOverPads * capowW; const totalCharge = stopCharge + dynCharge; const balance = totalCharge - agvConsump; const ratio = totalCharge / agvConsump; const coveragePercent = ratio * 100; const displayDist = currentUnit === 'm' ? totalRouteDist : totalRouteDist * M_TO_FT; const displaySpeed = currentUnit === 'm' ? avgSpeed : avgSpeed * M_TO_FT; const displayPadLength = currentUnit === 'm' ? totalPadLenM : totalPadLenM * M_TO_FT; // Create workbook const wb = XLSX.utils.book_new(); // Sheet 1: Route Sections const sectionsData = [ ['Route Sections'], ['Section Name', `Distance (${distUnit})`, `Speed (${speedUnit})`], ...sections.map(s => [ s.name, (currentUnit === 'm' ? s.distance : s.distance * M_TO_FT).toFixed(currentUnit==='ft'?2:0), (currentUnit === 'm' ? s.speed : s.speed * M_TO_FT).toFixed(1) ]), [], ['Total Route Length', displayDist.toFixed(currentUnit==='ft'?2:0), distUnit], ['Average Speed (Weighted)', displaySpeed.toFixed(2), speedUnit] ]; const ws1 = XLSX.utils.aoa_to_sheet(sectionsData); XLSX.utils.book_append_sheet(wb, ws1, 'Route Sections'); // Sheet 2: Stopping Points const stopsData = [ ['Stopping Points'], ['Stop Name', 'Duration (s)'], ...stops.map(s => [s.name, s.duration]), [], ['Total Stop Time', totalStops, 's'] ]; const ws2 = XLSX.utils.aoa_to_sheet(stopsData); XLSX.utils.book_append_sheet(wb, ws2, 'Stopping Points'); // Sheet 3: Power Configuration const powerData = [ ['Power Configuration'], [], ['AGV Average Consumption', agvW, 'W'], ['CaPow Output', capowW, 'W'] ]; const ws3 = XLSX.utils.aoa_to_sheet(powerData); XLSX.utils.book_append_sheet(wb, ws3, 'Power'); // Sheet 4: Charging Pads const padsData = [ ['Dynamic Charging Pads'], ['Pad #', `Length (${distUnit})`, 'Quantity', 'Route Section', `Section Speed (${speedUnit})`, 'Total Time (s)'], ...pads.map((p, i) => { const section = sections.find(s => s.id === p.sectionId); const sectionSpeed = section ? (currentUnit === 'm' ? section.speed : section.speed * M_TO_FT) : 0; const padTime = section ? (p.length / section.speed) * (p.quantity || 1) : 0; return [ i + 1, (currentUnit === 'm' ? p.length : p.length * M_TO_FT).toFixed(currentUnit==='ft'?2:1), p.quantity || 1, section ? section.name : 'Unknown', sectionSpeed.toFixed(1), padTime.toFixed(1) ]; }), [], ['Total Pad Length', displayPadLength.toFixed(currentUnit==='ft'?2:1), distUnit], ['Time Over Pads', timeOverPads.toFixed(1), 's'] ]; const ws4 = XLSX.utils.aoa_to_sheet(padsData); XLSX.utils.book_append_sheet(wb, ws4, 'Charging Pads'); // Sheet 5: Results const resultsData = [ ['Energy Balance Results'], [], ['Parameter', 'Value', 'Unit'], ['Course Duration', courseDur.toFixed(0), 's'], ['AGV Total Consumption', Math.round(agvConsump), 'Ws'], ['Static Charging (Stops)', Math.round(stopCharge), 'Ws'], ['Modular Charging (Pads)', Math.round(dynCharge), 'Ws'], ['Total Charging Output', Math.round(totalCharge), 'Ws'], ['Net Power Balance', Math.round(balance), 'Ws'], ['Improved work:charge ratio', (ratio < 1 ? (1 / (1 - ratio)) : ratio).toFixed(2), '×'], ['Energy Coverage', coveragePercent.toFixed(1), '%'], [], ['Status', balance >= 0 ? '✓ 100% Uptime Achievable' : '✗ Energy Deficit'] ]; const ws5 = XLSX.utils.aoa_to_sheet(resultsData); XLSX.utils.book_append_sheet(wb, ws5, 'Results'); // Download with project name and date const projectName = document.getElementById('projectName').value.trim() || 'Untitled Project'; const dateStr = new Date().toISOString().split('T')[0]; const filename = `${projectName}_${dateStr}.xlsx`; XLSX.writeFile(wb, filename); } // Take Screenshot async function takeScreenshot() { const projectName = document.getElementById('projectName').value.trim() || 'Untitled Project'; const dateStr = new Date().toISOString().split('T')[0]; try { if (typeof html2canvas === 'undefined') { alert('Screenshot library is still loading. Please try again in a moment.'); return; } // Wait for all fonts to be fully loaded await document.fonts.ready; // Scroll to top before capturing const prevScrollY = window.scrollY; window.scrollTo(0, 0); // Small delay to let layout settle after scroll await new Promise(r => setTimeout(r, 300)); const canvas = await html2canvas(document.documentElement, { backgroundColor: '#F9FAFB', scale: 2, logging: false, useCORS: true, allowTaint: true, foreignObjectRendering: false, scrollY: 0, scrollX: 0, windowWidth: document.documentElement.scrollWidth, windowHeight: document.documentElement.scrollHeight, width: document.documentElement.scrollWidth, height: document.documentElement.scrollHeight, onclone: (clonedDoc) => { // Ensure sticky panel is not clipped in clone const previewPanel = clonedDoc.querySelector('.preview-panel'); if (previewPanel) { previewPanel.style.position = 'relative'; previewPanel.style.top = 'auto'; previewPanel.style.maxHeight = 'none'; previewPanel.style.overflow = 'visible'; } } }); // Restore scroll position window.scrollTo(0, prevScrollY); const dataUrl = canvas.toDataURL('image/png'); const link = document.createElement('a'); link.download = `${projectName}_${dateStr}.png`; link.href = dataUrl; link.click(); } catch (error) { console.error('Screenshot failed:', error); alert('Screenshot failed: ' + error.message + '. Please try again.'); } } // Export to Excel - Single Sheet function exportToExcel() { const distUnit = currentUnit; const speedUnit = currentUnit + '/s'; // Gather all data const totalRouteDist = sections.reduce((sum, s) => sum + s.distance, 0); const weightedSpeedSum = sections.reduce((sum, s) => sum + (s.distance * s.speed), 0); const avgSpeed = totalRouteDist > 0 ? weightedSpeedSum / totalRouteDist : 0; const agvW = n('agvConsumption'); const capowW = n('capowOutput'); const totalStops = getTotalStopTime(); const courseDur = (avgSpeed > 0 ? totalRouteDist / avgSpeed : 0) + totalStops; const agvConsump = courseDur * agvW; const stopCharge = capowW * totalStops; const totalPadLenM = pads.reduce((sum, p) => sum + (p.length * (p.quantity || 1)), 0); const timeOverPads = pads.reduce((sum, p) => { const section = sections.find(s => s.id === p.sectionId); return sum + (section ? (p.length / section.speed) * (p.quantity || 1) : 0); }, 0); const dynCharge = timeOverPads * capowW; const totalCharge = stopCharge + dynCharge; const balance = totalCharge - agvConsump; const ratio = totalCharge / agvConsump; const coveragePercent = ratio * 100; const displayDist = currentUnit === 'm' ? totalRouteDist : totalRouteDist * M_TO_FT; const displaySpeed = currentUnit === 'm' ? avgSpeed : avgSpeed * M_TO_FT; const displayPadLength = currentUnit === 'm' ? totalPadLenM : totalPadLenM * M_TO_FT; const projectName = document.getElementById('projectName').value.trim() || 'Untitled Project'; const dateStr = new Date().toISOString().split('T')[0]; // Create single comprehensive sheet const data = [ ['CaPow Uptime Calculator'], ['Project Name:', projectName], ['Date:', dateStr], ['Units:', currentUnit === 'm' ? 'Metric (m, m/s)' : 'Imperial (ft, ft/s)'], [], ['═══════════════════════════════════════════════════════════════'], ['ROUTE CONFIGURATION'], ['═══════════════════════════════════════════════════════════════'], [], ['Route Sections'], ['Section Name', `Distance (${distUnit})`, `Speed (${speedUnit})`], ...sections.map(s => [ s.name, (currentUnit === 'm' ? s.distance : s.distance * M_TO_FT).toFixed(currentUnit==='ft'?2:0), (currentUnit === 'm' ? s.speed : s.speed * M_TO_FT).toFixed(1) ]), [], ['Total Route Length:', displayDist.toFixed(currentUnit==='ft'?2:0), distUnit], ['Average Speed (Weighted):', displaySpeed.toFixed(2), speedUnit], [], [], ['═══════════════════════════════════════════════════════════════'], ['STOPPING POINTS'], ['═══════════════════════════════════════════════════════════════'], [], ['Stop Name', 'Duration (s)'], ...stops.map(s => [s.name, s.duration]), [], ['Total Stop Time:', totalStops, 's'], [], [], ['═══════════════════════════════════════════════════════════════'], ['POWER CONFIGURATION'], ['═══════════════════════════════════════════════════════════════'], [], ['AGV Average Consumption:', agvW, 'W'], ['CaPow Output Power:', capowW, 'W'], [], [], ['═══════════════════════════════════════════════════════════════'], ['MODULAR CHARGING PADS'], ['═══════════════════════════════════════════════════════════════'], [], ['Pad #', `Length (${distUnit})`, 'Quantity', 'Route Section', `Speed (${speedUnit})`, 'Time on Pad (s)'], ...pads.map((p, i) => { const section = sections.find(s => s.id === p.sectionId); const sectionSpeed = section ? (currentUnit === 'm' ? section.speed : section.speed * M_TO_FT) : 0; const padTime = section ? (p.length / section.speed) * (p.quantity || 1) : 0; return [ i + 1, (currentUnit === 'm' ? p.length : p.length * M_TO_FT).toFixed(currentUnit==='ft'?2:1), p.quantity || 1, section ? section.name : 'Unknown', sectionSpeed.toFixed(1), padTime.toFixed(1) ]; }), [], ['Total Pad Length:', displayPadLength.toFixed(currentUnit==='ft'?2:1), distUnit], ['Time Over Pads:', timeOverPads.toFixed(1), 's'], [], [], ['═══════════════════════════════════════════════════════════════'], ['ENERGY BALANCE RESULTS'], ['═══════════════════════════════════════════════════════════════'], [], ['Parameter', 'Value', 'Unit'], ['Course Duration', courseDur.toFixed(0), 's'], ['AGV Total Consumption', Math.round(agvConsump).toLocaleString(), 'Ws'], [], ['Static Charging (Stops Only)', Math.round(stopCharge).toLocaleString(), 'Ws'], ['Modular Charging (Pads)', Math.round(dynCharge).toLocaleString(), 'Ws'], ['Total Charging Output', Math.round(totalCharge).toLocaleString(), 'Ws'], [], ['Net Power Balance', Math.round(balance).toLocaleString(), 'Ws'], ['Improved work:charge ratio', (ratio < 1 ? (1 / (1 - ratio)) : ratio).toFixed(2), '×'], ['Energy Coverage', coveragePercent.toFixed(1), '%'], [], ['═══════════════════════════════════════════════════════════════'], ['FINAL VERDICT'], ['═══════════════════════════════════════════════════════════════'], [], ['Status:', balance >= 0 ? '✓ 100% UPTIME ACHIEVABLE' : '✗ ENERGY DEFICIT'], balance >= 0 ? ['Surplus Energy:', Math.round(balance).toLocaleString(), 'Ws'] : ['Energy Deficit:', Math.abs(Math.round(balance)).toLocaleString(), 'Ws'], ['Coverage:', coveragePercent.toFixed(1) + '%'] ]; // Create workbook const wb = XLSX.utils.book_new(); const ws = XLSX.utils.aoa_to_sheet(data); // Set column widths ws['!cols'] = [ { wch: 30 }, // Column A - wider for labels { wch: 15 }, // Column B { wch: 12 } // Column C ]; // Add the sheet XLSX.utils.book_append_sheet(wb, ws, 'Energy Balance Report'); // Download const filename = `${projectName}_${dateStr}.xlsx`; XLSX.writeFile(wb, filename); } // Save Results function saveResults() { const projectName = document.getElementById('projectName').value.trim(); const title = (!projectName || projectName === 'Enter Project Name') ? '' : ' ' + projectName; const dateStr = new Date().toISOString().split('T')[0]; const filename = `CaPow Uptime Calculator${title} ${dateStr}.html`; const html = document.documentElement.outerHTML; const blob = new Blob([html], { type: 'text/html' }); const link = document.createElement('a'); link.href = URL.createObjectURL(blob); link.download = filename; link.click(); URL.revokeObjectURL(link.href); } // Init renderSections(); renderStops(); renderPads(); calculate(); // Render viz after layout settles setTimeout(renderRouteVisualization, 100); // Re-render on window resize window.addEventListener('resize', renderRouteVisualization);

## To view the content, please fill out the form below or enter a password.

EnterWrong password, try again.
