skill
Luminous Intensity Distribution Tool
Public UnreviewedInteractive 2D LED array illuminance simulator — heatmap, cross-section, reflectors, power/efficiency, and J/cm² energy density calculator
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783
<!DOCTYPE html>
<html lang="en">
<head><script>(function(){var n=0;function show(p,m){var el=document.createElement('div');el.style.cssText='position:fixed;left:0;right:0;background:#e04040;color:#fff;padding:6px 12px;z-index:'+(99999-n)+';font:13px/1.4 monospace;white-space:pre-wrap;border-bottom:1px solid #b02020;top:'+n*30+'px';el.textContent=p+m;document.body.appendChild(el);n++}window.onerror=function(msg,src,line){show('ERROR '+(src||'').split('/').pop()+':'+line+' \u2014 ',msg)};window.addEventListener('unhandledrejection',function(e){show('REJECT \u2014 ',e.reason&&e.reason.message||e.reason||'unknown')})})()</script>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Luminous Intensity Distribution</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #fff;
display: flex;
flex-direction: column;
align-items: center;
min-height: 100vh;
font-family: Arial, Helvetica, sans-serif;
padding: 12px;
}
h2 { margin-bottom: 4px; font-size: 18px; }
.chart-container { position: relative; width: 100%; max-width: 700px; }
#polar-container { aspect-ratio: 1 / 0.62; }
#cross-container { aspect-ratio: 1 / 0.5; }
#heatmap-container { aspect-ratio: 1 / 0.7; margin-top: 8px; }
canvas { width: 100%; height: 100%; cursor: crosshair; }
.tooltip {
position: absolute; display: none; background: rgba(0,0,0,0.82);
color: #fff; padding: 6px 10px; border-radius: 5px; font-size: 13px;
pointer-events: none; white-space: nowrap; z-index: 10;
}
#readout, #readout2, #readoutHM {
margin-top: 6px; font-size: 13px; color: #333; min-height: 18px;
}
.controls-table { margin-top: 12px; border-collapse: collapse; font-size: 13px; }
.controls-table th { font-weight: 600; padding: 2px 6px; text-align: center; color: #666; font-size: 11px; }
.controls-table td { padding: 3px 4px; vertical-align: middle; }
.controls-table .param-label { font-weight: bold; font-size: 14px; text-align: right; padding-right: 6px; }
.controls-table input[type=range] { width: 130px; vertical-align: middle; }
.controls-table input[type=number] {
width: 58px; font-size: 13px; font-weight: bold; text-align: center;
border: 1px solid #ccc; border-radius: 3px; padding: 2px 4px; background: #fafafa;
}
.controls-table input[type=number]:focus { outline: none; border-color: #2e7d32; background: #fff; }
.controls-table .cfg-input {
width: 48px; font-size: 11px; text-align: center;
border: 1px solid #ddd; border-radius: 2px; padding: 1px 2px; color: #666;
}
.controls-table .cfg-input:focus { outline: none; border-color: #999; }
.section-title { font-size: 16px; font-weight: bold; margin-top: 18px; margin-bottom: 2px; }
.legend { display: flex; align-items: center; gap: 14px; margin-top: 6px; font-size: 12px; color: #555; }
.legend-item { display: flex; align-items: center; gap: 4px; }
.legend-swatch { width: 20px; height: 3px; border-radius: 1px; }
.color-legend { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: #666; }
.reflector-box {
display: flex; align-items: center; gap: 10px; margin-top: 8px;
padding: 6px 10px; background: #f8f6fa; border: 1px solid #d0c0e0;
border-radius: 5px; font-size: 13px;
}
.reflector-box label { font-weight: bold; color: #555; }
.reflector-box input[type=number] {
width: 54px; font-size: 13px; font-weight: bold; text-align: center;
border: 1px solid #ccc; border-radius: 3px; padding: 2px 4px;
}
.reflector-box input[type=number]:focus { outline: none; border-color: #7b1fa2; }
.preset-btn {
padding: 3px 7px; font-size: 11px; border: 1px solid #ccc;
border-radius: 3px; background: #fff; cursor: pointer; color: #444;
}
.preset-btn:hover { background: #f0e8f5; border-color: #7b1fa2; }
.preset-btn.active { background: #7b1fa2; color: #fff; border-color: #7b1fa2; }
.power-section {
margin-top: 10px; padding: 8px 12px; background: #f6f9ff;
border: 1px solid #c0d0e8; border-radius: 5px; font-size: 13px;
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.power-section label { font-weight: bold; color: #555; }
.power-section input[type=number] {
width: 70px; font-size: 13px; font-weight: bold; text-align: center;
border: 1px solid #ccc; border-radius: 3px; padding: 2px 4px;
}
.power-section .power-readout {
padding: 3px 8px; background: #e8f0fe; border: 1px solid #b0c4de;
border-radius: 3px; font-weight: bold; color: #1565c0; font-size: 12px;
}
.power-section .sep { color: #ccc; font-size: 16px; }
.power-calc {
margin-top: 6px; padding: 6px 12px; background: #fff8f0;
border: 1px solid #e0d0b8; border-radius: 5px; font-size: 13px;
display: flex; flex-wrap: wrap; gap: 10px; align-items: center;
}
.power-calc label { font-weight: bold; color: #555; }
.power-calc input[type=number] {
width: 80px; font-size: 13px; font-weight: bold; text-align: center;
border: 1px solid #ccc; border-radius: 3px; padding: 2px 4px;
}
.power-calc .calc-result {
padding: 3px 8px; background: #fef3e8; border: 1px solid #e0c8a0;
border-radius: 3px; font-weight: bold; color: #c45e00; font-size: 12px;
}
</style>
</head>
<body>
<h2>Luminous Intensity Distribution</h2>
<div id="readout"> </div>
<div id="polar-container" class="chart-container">
<canvas id="polarChart"></canvas>
<div id="tooltip1" class="tooltip"></div>
</div>
<div class="section-title">Illuminance on Target Panel</div>
<table class="controls-table">
<thead><tr><th></th><th>Value</th><th>Slider</th><th>Min</th><th>Max</th><th>Step</th></tr></thead>
<tbody>
<tr>
<td class="param-label">h</td>
<td><input type="number" id="hNum" value="100" step="0.5"></td>
<td><input type="range" id="hSlider" min="1" max="200" step="0.5" value="100"></td>
<td><input type="number" class="cfg-input" id="hMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="hMax" value="200" step="any"></td>
<td><input type="number" class="cfg-input" id="hStep" value="0.5" step="any"></td>
</tr>
<tr>
<td class="param-label">d</td>
<td><input type="number" id="dNum" value="30" step="0.5"></td>
<td><input type="range" id="dSlider" min="1" max="100" step="0.5" value="30"></td>
<td><input type="number" class="cfg-input" id="dMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="dMax" value="100" step="any"></td>
<td><input type="number" class="cfg-input" id="dStep" value="0.5" step="any"></td>
</tr>
<tr>
<td class="param-label">cols</td>
<td><input type="number" id="colsNum" value="12" step="1"></td>
<td><input type="range" id="colsSlider" min="1" max="100" step="1" value="12"></td>
<td><input type="number" class="cfg-input" id="colsMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="colsMax" value="100" step="any"></td>
<td><input type="number" class="cfg-input" id="colsStep" value="1" step="any"></td>
</tr>
<tr>
<td class="param-label">rows</td>
<td><input type="number" id="rowsNum" value="14" step="1"></td>
<td><input type="range" id="rowsSlider" min="1" max="100" step="1" value="14"></td>
<td><input type="number" class="cfg-input" id="rowsMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="rowsMax" value="100" step="any"></td>
<td><input type="number" class="cfg-input" id="rowsStep" value="1" step="any"></td>
</tr>
<tr>
<td class="param-label">l (panel)</td>
<td><input type="number" id="lNum" value="300" step="0.5"></td>
<td><input type="range" id="lSlider" min="1" max="500" step="0.5" value="300"></td>
<td><input type="number" class="cfg-input" id="lMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="lMax" value="500" step="any"></td>
<td><input type="number" class="cfg-input" id="lStep" value="0.5" step="any"></td>
</tr>
<tr>
<td class="param-label">w (panel)</td>
<td><input type="number" id="wNum" value="340" step="0.5"></td>
<td><input type="range" id="wSlider" min="1" max="500" step="0.5" value="340"></td>
<td><input type="number" class="cfg-input" id="wMin" value="1" step="any"></td>
<td><input type="number" class="cfg-input" id="wMax" value="500" step="any"></td>
<td><input type="number" class="cfg-input" id="wStep" value="0.5" step="any"></td>
</tr>
</tbody>
</table>
<div class="reflector-box">
<label>Reflectors:</label>
<span style="font-size:12px;color:#666;">R =</span>
<input type="number" id="reflNum" value="88" min="0" max="100" step="1">
<span style="font-size:12px;color:#666;">%</span>
<button class="preset-btn" data-r="100">Mirror</button>
<button class="preset-btn active" data-r="88">Aluminum</button>
<button class="preset-btn" data-r="75">White card</button>
<button class="preset-btn" data-r="5">Black card</button>
<button class="preset-btn" data-r="0">Void</button>
</div>
<div class="power-section">
<label>Power per LED:</label>
<input type="number" id="ledPower" value="500" min="0" step="10"> <span style="font-size:12px;color:#666;">mW</span>
<span class="sep">|</span>
<span class="power-readout" id="totalPowerOut">Total: 0 W</span>
<span class="power-readout" id="panelAbsorbed">Efficiency: 0%</span>
<span class="power-readout" id="planeReceived">Target receives: 0 W</span>
</div>
<div class="power-calc">
<label>Energy calc:</label>
<span style="font-size:12px;color:#666;">Time:</span>
<input type="number" id="calcTime" value="60" min="0" step="1">
<span style="font-size:12px;color:#666;">sec</span>
<span class="calc-result" id="calcJoules">= 0 J/cm²</span>
<span class="sep" style="color:#ccc;font-size:16px;">|</span>
<span style="font-size:12px;color:#666;">Target:</span>
<input type="number" id="calcTarget" value="5" min="0" step="0.1">
<span style="font-size:12px;color:#666;">J/cm²</span>
<span class="calc-result" id="calcTimeNeeded">= 0 sec</span>
</div>
<div class="legend">
<div class="legend-item"><div class="legend-swatch" style="background:#1e64dc;height:4px;"></div> Cross-section (y=0)</div>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#555;cursor:pointer;"><input type="checkbox" id="showLeds" checked> Show LEDs</label>
</div>
<div class="color-legend">
<span>0%</span>
<canvas id="colorLegendBar" width="120" height="12" style="width:120px;height:12px;border:1px solid #ccc;border-radius:2px;"></canvas>
<span>100%</span>
<span style="margin-left:4px;">(panel intensity)</span>
<span id="deltaReadout" style="margin-left:12px;padding:2px 8px;background:#f0faf0;border:1px solid #b0d8b0;border-radius:3px;font-weight:bold;color:#2e7d32;"></span>
</div>
<div class="section-title">Top View (looking down at panel)</div>
<div id="readoutHM"> </div>
<div id="heatmap-container" class="chart-container">
<canvas id="heatmapChart"></canvas>
<div id="tooltipHM" class="tooltip"></div>
</div>
<div class="section-title">Cross-Section (y = 0)</div>
<div id="readout2"> </div>
<div id="cross-container" class="chart-container">
<canvas id="crossChart"></canvas>
<div id="tooltip2" class="tooltip"></div>
</div>
<script>
const dpr = window.devicePixelRatio || 1;
function intensity(angleDeg) { return Math.max(0, Math.cos(angleDeg * Math.PI / 180)); }
function illuminanceAt2D(dx, dy, h) {
const r2 = dx * dx + dy * dy + h * h;
const cosT = h / Math.sqrt(r2);
return cosT * cosT * cosT / (h * h);
}
// Green color: 0 = white, 1 = green (#2e7d32)
function intensityColor(frac) {
const f = Math.max(0, Math.min(1, frac));
const r = Math.round(255 + (46 - 255) * f);
const g = Math.round(255 + (125 - 255) * f);
const b = Math.round(255 + (50 - 255) * f);
return `rgb(${r},${g},${b})`;
}
(function drawColorLegend() {
const c = document.getElementById('colorLegendBar');
const cx = c.getContext('2d');
for (let i = 0; i < 120; i++) { cx.fillStyle = intensityColor(i / 119); cx.fillRect(i, 0, 1, 12); }
})();
// ─── State ───
let hDist = 100, dSpacing = 30, nCols = 12, nRows = 14;
let panelL = 300, panelW = 340, reflectivity = 0.88;
// Source grid positions (centered at origin)
function getSourceGrid() {
const sources = [];
for (let r = 0; r < nRows; r++) {
for (let c = 0; c < nCols; c++) {
sources.push({
x: (c - (nCols - 1) / 2) * dSpacing,
y: (r - (nRows - 1) / 2) * dSpacing
});
}
}
return sources;
}
function getReflectorBounds() {
return {
xL: -nCols * dSpacing / 2, xR: nCols * dSpacing / 2,
yL: -nRows * dSpacing / 2, yR: nRows * dSpacing / 2
};
}
function totalIlluminance2D(px, py, h, sources) {
const refl = getReflectorBounds();
const outside = reflectivity > 0 && (px < refl.xL || px > refl.xR || py < refl.yL || py > refl.yR);
if (outside) {
let sum = 0;
for (const s of sources) sum += (1 - reflectivity) * illuminanceAt2D(px - s.x, py - s.y, h);
return sum;
}
let sum = 0;
for (const s of sources) {
sum += illuminanceAt2D(px - s.x, py - s.y, h);
if (reflectivity > 0) {
sum += reflectivity * illuminanceAt2D(px - (2 * refl.xL - s.x), py - s.y, h);
sum += reflectivity * illuminanceAt2D(px - (2 * refl.xR - s.x), py - s.y, h);
sum += reflectivity * illuminanceAt2D(px - s.x, py - (2 * refl.yL - s.y), h);
sum += reflectivity * illuminanceAt2D(px - s.x, py - (2 * refl.yR - s.y), h);
}
}
return sum;
}
// Cross-section along y=0
function totalIlluminanceX(x, h, sources) { return totalIlluminance2D(x, 0, h, sources); }
// ─── POLAR CHART ───
const polarCanvas = document.getElementById('polarChart');
const polarCtx = polarCanvas.getContext('2d');
const polarContainer = document.getElementById('polar-container');
const tooltip1 = document.getElementById('tooltip1');
const readout = document.getElementById('readout');
let pW, pH, pcx, pcy, pR, polarHighlight = null;
function polarResize() {
const rect = polarContainer.getBoundingClientRect();
pW = rect.width; pH = rect.height;
polarCanvas.width = pW * dpr; polarCanvas.height = pH * dpr;
polarCanvas.style.width = pW + 'px'; polarCanvas.style.height = pH + 'px';
polarCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
pcx = pW / 2; pcy = pH - 28; pR = Math.min(pW / 2 - 40, pH - 55);
drawPolar();
}
function polarToXY(deg, r) {
const rad = (deg - 90) * Math.PI / 180;
return { x: pcx + r * Math.cos(rad), y: pcy + r * Math.sin(rad) };
}
function drawPolar() {
const ctx = polarCtx; ctx.clearRect(0, 0, pW, pH);
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 0.6;
for (const f of [0.25,0.5,0.75,1]) {
ctx.beginPath(); ctx.setLineDash([4,4]); ctx.arc(pcx, pcy, pR*f, Math.PI, 2*Math.PI); ctx.stroke();
ctx.setLineDash([]); ctx.fillStyle = '#999'; ctx.font = '11px Arial'; ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
ctx.fillText(Math.round(f*100)+'%', pcx - pR*f - 4, pcy);
}
ctx.setLineDash([]);
const angles = [-90,-60,-30,0,30,60,90];
ctx.strokeStyle = '#bbb'; ctx.lineWidth = 0.6; ctx.setLineDash([4,4]);
for (const deg of angles) { const p = polarToXY(deg, pR); ctx.beginPath(); ctx.moveTo(pcx, pcy); ctx.lineTo(p.x, p.y); ctx.stroke(); }
ctx.setLineDash([]); ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(pcx-pR-25, pcy); ctx.lineTo(pcx+pR+25, pcy); ctx.stroke();
ctx.fillStyle = '#000'; ctx.font = '13px Arial';
for (const deg of angles) {
const p = polarToXY(deg, pR+18);
ctx.textAlign = deg < 0 ? 'right' : deg > 0 ? 'left' : 'center';
ctx.textBaseline = deg === 0 ? 'bottom' : 'middle'; ctx.fillText(deg+'\u00B0', p.x, p.y);
}
ctx.beginPath();
for (let deg = -90; deg <= 90; deg += 0.5) { const p = polarToXY(deg, pR*intensity(deg)); deg===-90 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y); }
ctx.closePath(); ctx.fillStyle = 'rgba(46,125,50,0.08)'; ctx.fill();
ctx.strokeStyle = '#1a1a1a'; ctx.lineWidth = 2.5; ctx.beginPath();
for (let deg = -90; deg <= 90; deg += 0.5) { const p = polarToXY(deg, pR*intensity(deg)); deg===-90 ? ctx.moveTo(p.x,p.y) : ctx.lineTo(p.x,p.y); }
ctx.stroke();
for (let deg = -90; deg <= 90; deg += 10) {
const p = polarToXY(deg, pR*intensity(deg));
ctx.beginPath(); ctx.arc(p.x, p.y, 3, 0, Math.PI*2); ctx.fillStyle = '#2e7d32'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 1; ctx.stroke();
}
if (polarHighlight) {
const h = polarHighlight;
ctx.strokeStyle = 'rgba(46,125,50,0.3)'; ctx.lineWidth = 1; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(pcx, pcy); ctx.lineTo(h.px, h.py); ctx.stroke(); ctx.setLineDash([]);
ctx.beginPath(); ctx.arc(h.px, h.py, 6, 0, Math.PI*2); ctx.fillStyle = '#2e7d32'; ctx.fill(); ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.stroke();
}
}
function findNearestPolar(mx, my) {
let best = null, bestDist = Infinity;
for (let deg = -90; deg <= 90; deg += 1) {
const p = polarToXY(deg, pR*intensity(deg)); const d = Math.hypot(p.x-mx, p.y-my);
if (d < bestDist) { bestDist = d; best = { deg, val: intensity(deg), px: p.x, py: p.y }; }
}
return bestDist < 40 ? best : null;
}
polarCanvas.addEventListener('mousemove', (e) => {
const rect = polarCanvas.getBoundingClientRect();
const pt = findNearestPolar(e.clientX-rect.left, e.clientY-rect.top);
if (pt) {
polarHighlight = pt; tooltip1.style.display = 'block';
let tx = pt.px+14, ty = pt.py-34; if (tx+160>pW) tx = pt.px-160; if (ty<0) ty = pt.py+14;
tooltip1.style.left = tx+'px'; tooltip1.style.top = ty+'px';
tooltip1.innerHTML = `<b>${pt.deg}\u00B0</b> — ${(pt.val*100).toFixed(1)}%`;
readout.textContent = `Angle: ${pt.deg}\u00B0 | Intensity: ${(pt.val*100).toFixed(1)}%`;
} else { polarHighlight = null; tooltip1.style.display = 'none'; readout.innerHTML = ' '; }
drawPolar();
});
polarCanvas.addEventListener('mouseleave', () => { polarHighlight = null; tooltip1.style.display = 'none'; readout.innerHTML = ' '; drawPolar(); });
// ─── HEATMAP (Top View) ───
const hmCanvas = document.getElementById('heatmapChart');
const hmCtx = hmCanvas.getContext('2d');
const hmContainer = document.getElementById('heatmap-container');
const tooltipHM = document.getElementById('tooltipHM');
const readoutHM = document.getElementById('readoutHM');
let hmW, hmH, hmMargin, hmPlotW, hmPlotH, hmOx, hmOy;
function hmResize() {
const rect = hmContainer.getBoundingClientRect();
hmW = rect.width; hmH = rect.height;
hmCanvas.width = hmW * dpr; hmCanvas.height = hmH * dpr;
hmCanvas.style.width = hmW + 'px'; hmCanvas.style.height = hmH + 'px';
hmCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
hmMargin = { top: 24, right: 20, bottom: 36, left: 44 };
hmPlotW = hmW - hmMargin.left - hmMargin.right;
hmPlotH = hmH - hmMargin.top - hmMargin.bottom;
hmOx = hmMargin.left; hmOy = hmMargin.top;
drawHeatmap();
}
function drawHeatmap() {
const ctx = hmCtx; ctx.clearRect(0, 0, hmW, hmH);
const h = hDist, sources = getSourceGrid();
const plH = panelL / 2, plW = panelW / 2;
const refl = getReflectorBounds();
const xRange = Math.max(plH, Math.abs(refl.xL), Math.abs(refl.xR)) * 1.1;
const yRange = Math.max(plW, Math.abs(refl.yL), Math.abs(refl.yR)) * 1.1;
// Use equal scale on both axes: compute pixels-per-unit from whichever axis is tighter
const pxPerUnitX = hmPlotW / (2 * xRange);
const pxPerUnitY = hmPlotH / (2 * yRange);
const pxPerUnit = Math.min(pxPerUnitX, pxPerUnitY);
// Center both axes
const xCen = hmOx + hmPlotW / 2;
const yCen = hmOy + hmPlotH / 2;
const xMax = hmPlotW / (2 * pxPerUnit);
const yMax = hmPlotH / (2 * pxPerUnit);
function xToP(x) { return xCen + x * pxPerUnit; }
function yToP(y) { return yCen - y * pxPerUnit; }
// Compute heatmap over the panel region
const res = 100;
let peak = 0;
const data = [];
for (let iy = 0; iy < res; iy++) {
data[iy] = [];
for (let ix = 0; ix < res; ix++) {
const x = -plH + panelL * ix / (res - 1);
const y = -plW + panelW * iy / (res - 1);
const E = totalIlluminance2D(x, y, h, sources);
data[iy][ix] = E;
if (E > peak) peak = E;
}
}
// Draw heatmap pixels
const pxW = (xToP(plH) - xToP(-plH)) / res;
const pxH = (yToP(-plW) - yToP(plW)) / res;
for (let iy = 0; iy < res; iy++) {
for (let ix = 0; ix < res; ix++) {
const frac = peak > 0 ? data[iy][ix] / peak : 0;
ctx.fillStyle = intensityColor(frac);
const x = -plH + panelL * ix / (res - 1);
const y = -plW + panelW * iy / (res - 1);
ctx.fillRect(xToP(x) - pxW/2, yToP(y) - pxH/2, pxW + 1, pxH + 1);
}
}
// Panel outline
ctx.strokeStyle = '#2e7d32'; ctx.lineWidth = 2;
ctx.strokeRect(xToP(-plH), yToP(plW), xToP(plH) - xToP(-plH), yToP(-plW) - yToP(plW));
// Panel dimension labels
ctx.fillStyle = '#2e7d32'; ctx.font = 'bold 11px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
ctx.fillText('l = ' + panelL.toFixed(1), (xToP(-plH) + xToP(plH)) / 2, yToP(-plW) + 4);
ctx.save(); ctx.translate(xToP(plH) + 12, (yToP(plW) + yToP(-plW)) / 2);
ctx.rotate(-Math.PI / 2); ctx.fillText('w = ' + panelW.toFixed(1), 0, 0); ctx.restore();
// Reflector lines
if (reflectivity > 0) {
ctx.strokeStyle = '#888'; ctx.lineWidth = 1.5; ctx.setLineDash([]);
const rl = xToP(refl.xL), rr = xToP(refl.xR), rt = yToP(refl.yR), rb = yToP(refl.yL);
ctx.strokeRect(rl, rt, rr - rl, rb - rt);
ctx.fillStyle = '#888'; ctx.font = '10px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'bottom';
ctx.fillText('R=' + Math.round(reflectivity * 100) + '%', (rl + rr) / 2, rt - 2);
}
// LED positions
if (document.getElementById('showLeds').checked) {
for (const s of sources) {
const px = xToP(s.x), py = yToP(s.y);
ctx.beginPath(); ctx.arc(px, py, 3, 0, Math.PI * 2);
ctx.fillStyle = '#f5a623'; ctx.fill(); ctx.strokeStyle = '#d48b00'; ctx.lineWidth = 0.8; ctx.stroke();
}
}
// Axes
ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(hmOx, hmOy + hmPlotH); ctx.lineTo(hmOx + hmPlotW, hmOy + hmPlotH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(hmOx, hmOy); ctx.lineTo(hmOx, hmOy + hmPlotH); ctx.stroke();
ctx.fillStyle = '#000'; ctx.font = '11px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
const xStep = niceStep(2 * xMax, 6);
for (let xv = Math.ceil(-xMax / xStep) * xStep; xv <= xMax; xv += xStep) ctx.fillText(xv.toFixed(0), xToP(xv), hmOy + hmPlotH + 4);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle';
const yStep = niceStep(2 * yMax, 5);
for (let yv = Math.ceil(-yMax / yStep) * yStep; yv <= yMax; yv += yStep) ctx.fillText(yv.toFixed(0), hmOx - 4, yToP(yv));
ctx.textAlign = 'center'; ctx.fillText('x', hmOx + hmPlotW / 2, hmOy + hmPlotH + 22);
ctx.save(); ctx.translate(12, hmOy + hmPlotH / 2); ctx.rotate(-Math.PI / 2); ctx.fillText('y', 0, 0); ctx.restore();
// Delta readout (over panel only)
let minE = Infinity, maxE = 0;
for (let iy = 0; iy < res; iy++) for (let ix = 0; ix < res; ix++) {
if (data[iy][ix] < minE) minE = data[iy][ix];
if (data[iy][ix] > maxE) maxE = data[iy][ix];
}
if (minE === Infinity) minE = 0;
const minP = peak > 0 ? minE / peak * 100 : 0, maxP = peak > 0 ? maxE / peak * 100 : 0;
document.getElementById('deltaReadout').textContent = '\u0394 ' + (maxP - minP).toFixed(1) + '% (' + minP.toFixed(1) + '% \u2013 ' + maxP.toFixed(1) + '%)';
// Store for hover
hmCanvas._state = { xMax, yMax, plH, plW, peak, sources, h, xToP, yToP, xCen, yCen, pxPerUnit };
}
hmCanvas.addEventListener('mousemove', (e) => {
const rect = hmCanvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const st = hmCanvas._state; if (!st) return;
const x = (mx - st.xCen) / st.pxPerUnit;
const y = -(my - st.yCen) / st.pxPerUnit;
if (Math.abs(x) <= st.plH && Math.abs(y) <= st.plW) {
const E = totalIlluminance2D(x, y, st.h, st.sources);
const pct = st.peak > 0 ? E / st.peak * 100 : 0;
tooltipHM.style.display = 'block';
let tx = mx + 14, ty = my - 30; if (tx + 180 > hmW) tx = mx - 180; if (ty < 0) ty = my + 14;
tooltipHM.style.left = tx + 'px'; tooltipHM.style.top = ty + 'px';
tooltipHM.innerHTML = `<b>(${x.toFixed(1)}, ${y.toFixed(1)})</b> — ${pct.toFixed(1)}%`;
readoutHM.textContent = `x=${x.toFixed(1)} y=${y.toFixed(1)} | Illuminance: ${pct.toFixed(1)}%`;
} else { tooltipHM.style.display = 'none'; readoutHM.innerHTML = ' '; }
});
hmCanvas.addEventListener('mouseleave', () => { tooltipHM.style.display = 'none'; readoutHM.innerHTML = ' '; });
// ─── CROSS-SECTION (y=0) ───
const crCanvas = document.getElementById('crossChart');
const crCtx = crCanvas.getContext('2d');
const crContainer = document.getElementById('cross-container');
const tooltip2 = document.getElementById('tooltip2');
const readout2 = document.getElementById('readout2');
let crW, crH, crMargin, crPlotW, crPlotH, crOx, crOy;
function crResize() {
const rect = crContainer.getBoundingClientRect();
crW = rect.width; crH = rect.height;
crCanvas.width = crW * dpr; crCanvas.height = crH * dpr;
crCanvas.style.width = crW + 'px'; crCanvas.style.height = crH + 'px';
crCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
crMargin = { top: 30, right: 20, bottom: 36, left: 50 };
crPlotW = crW - crMargin.left - crMargin.right;
crPlotH = crH - crMargin.top - crMargin.bottom;
crOx = crMargin.left; crOy = crMargin.top;
drawCross();
}
function drawCross() {
const ctx = crCtx; ctx.clearRect(0, 0, crW, crH);
const h = hDist, sources = getSourceGrid();
const plH = panelL / 2;
const refl = getReflectorBounds();
const xMax = Math.max(plH, Math.abs(refl.xL)) * 1.1;
const res = 400;
const sumData = [];
let peak = 0;
for (let i = 0; i <= res; i++) {
const x = -xMax + 2 * xMax * i / res;
const E = totalIlluminanceX(x, h, sources);
sumData.push({ x, E }); if (E > peak) peak = E;
}
const yMax = peak > 0 ? peak * 1.05 : 1;
function xToP(x) { return crOx + crPlotW * (x + xMax) / (2 * xMax); }
function eToP(e) { return crOy + crPlotH * (1 - e / yMax); }
// Color bar at top
const barH = 14, barY = crOy - barH - 2;
const barL = xToP(-plH), barR = xToP(plH);
for (let px = Math.floor(barL); px <= Math.ceil(barR); px++) {
const x = -plH + panelL * (px - barL) / (barR - barL);
const E = totalIlluminanceX(x, h, sources);
ctx.fillStyle = intensityColor(peak > 0 ? E / yMax : 0);
ctx.fillRect(px, barY, 2, barH);
}
ctx.strokeStyle = '#2e7d32'; ctx.lineWidth = 1;
ctx.strokeRect(barL, barY, barR - barL, barH);
// Grid
ctx.strokeStyle = '#ddd'; ctx.lineWidth = 0.5;
for (let i = 0; i <= 5; i++) { const py = eToP(yMax * i / 5); ctx.beginPath(); ctx.moveTo(crOx, py); ctx.lineTo(crOx + crPlotW, py); ctx.stroke(); }
const xStep = niceStep(2 * xMax, 8);
for (let xv = Math.ceil(-xMax / xStep) * xStep; xv <= xMax; xv += xStep) { const px = xToP(xv); ctx.beginPath(); ctx.moveTo(px, crOy); ctx.lineTo(px, crOy + crPlotH); ctx.stroke(); }
// Axes
ctx.strokeStyle = '#000'; ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(crOx, crOy + crPlotH); ctx.lineTo(crOx + crPlotW, crOy + crPlotH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(crOx, crOy); ctx.lineTo(crOx, crOy + crPlotH); ctx.stroke();
ctx.fillStyle = '#000'; ctx.font = '11px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'top';
for (let xv = Math.ceil(-xMax / xStep) * xStep; xv <= xMax; xv += xStep) ctx.fillText(xv.toFixed(0), xToP(xv), crOy + crPlotH + 4);
ctx.textAlign = 'right'; ctx.textBaseline = 'middle'; ctx.font = '11px Arial';
for (let i = 0; i <= 5; i++) ctx.fillText(Math.round(i * 20) + '%', crOx - 4, eToP(yMax * i / 5));
// Reflector lines
if (reflectivity > 0) {
ctx.save(); ctx.beginPath(); ctx.rect(crOx, crOy, crPlotW, crPlotH); ctx.clip();
ctx.strokeStyle = '#888'; ctx.lineWidth = 2;
const rl = xToP(refl.xL), rr = xToP(refl.xR);
ctx.beginPath(); ctx.moveTo(rl, crOy); ctx.lineTo(rl, crOy + crPlotH); ctx.stroke();
ctx.beginPath(); ctx.moveTo(rr, crOy); ctx.lineTo(rr, crOy + crPlotH); ctx.stroke();
ctx.restore();
}
// Clip for curve
ctx.save(); ctx.beginPath(); ctx.rect(crOx, crOy, crPlotW, crPlotH); ctx.clip();
// Fill
ctx.beginPath(); ctx.moveTo(xToP(sumData[0].x), eToP(0));
for (const pt of sumData) ctx.lineTo(xToP(pt.x), eToP(pt.E));
ctx.lineTo(xToP(sumData[res].x), eToP(0)); ctx.closePath();
ctx.fillStyle = 'rgba(46,125,50,0.06)'; ctx.fill();
// Curve
ctx.strokeStyle = '#1e64dc'; ctx.lineWidth = 2.5; ctx.beginPath();
let first = true;
for (const pt of sumData) { const px = xToP(pt.x), py = eToP(pt.E); first ? (ctx.moveTo(px, py), first = false) : ctx.lineTo(px, py); }
ctx.stroke();
ctx.restore();
crCanvas._state = { xMax, yMax, peak, sources, h, xToP, eToP };
}
crCanvas.addEventListener('mousemove', (e) => {
const rect = crCanvas.getBoundingClientRect();
const mx = e.clientX - rect.left, my = e.clientY - rect.top;
const st = crCanvas._state; if (!st) return;
const x = (mx - crOx) / crPlotW * 2 * st.xMax - st.xMax;
const E = totalIlluminanceX(x, st.h, st.sources);
const pct = st.peak > 0 ? E / st.peak * 100 : 0;
tooltip2.style.display = 'block';
let tx = mx + 14, ty = my - 30; if (tx + 180 > crW) tx = mx - 180;
tooltip2.style.left = tx + 'px'; tooltip2.style.top = ty + 'px';
tooltip2.innerHTML = `<b>x = ${x.toFixed(1)}</b> — ${pct.toFixed(1)}%`;
readout2.textContent = `x = ${x.toFixed(1)} | Illuminance: ${pct.toFixed(1)}% of peak`;
});
crCanvas.addEventListener('mouseleave', () => { tooltip2.style.display = 'none'; readout2.innerHTML = ' '; });
function niceStep(range, targetTicks) {
const rough = range / targetTicks;
const mag = Math.pow(10, Math.floor(Math.log10(rough)));
const norm = rough / mag;
return (norm < 1.5 ? 1 : norm < 3.5 ? 2 : norm < 7.5 ? 5 : 10) * mag;
}
function redrawAll() { drawHeatmap(); drawCross(); updatePower(); }
// ─── Slider wiring ───
function wireParam(sId, nId, minId, maxId, stepId, getV, setV) {
const sl = document.getElementById(sId), ni = document.getElementById(nId);
const mi = document.getElementById(minId), ma = document.getElementById(maxId), st = document.getElementById(stepId);
function sync(fromSl) {
const v = parseFloat(fromSl ? sl.value : ni.value); if (isNaN(v)) return;
setV(v); if (fromSl) ni.value = v; else sl.value = Math.max(parseFloat(sl.min), Math.min(parseFloat(sl.max), v));
redrawAll();
}
sl.addEventListener('input', () => sync(true));
ni.addEventListener('input', () => sync(false)); ni.addEventListener('change', () => sync(false));
mi.addEventListener('change', () => { sl.min = mi.value; });
ma.addEventListener('change', () => { sl.max = ma.value; });
st.addEventListener('change', () => { sl.step = st.value; ni.step = st.value; });
}
wireParam('hSlider','hNum','hMin','hMax','hStep', ()=>hDist, v=>{hDist=v;});
wireParam('dSlider','dNum','dMin','dMax','dStep', ()=>dSpacing, v=>{dSpacing=v;});
wireParam('colsSlider','colsNum','colsMin','colsMax','colsStep', ()=>nCols, v=>{nCols=Math.max(1,Math.round(v));});
wireParam('rowsSlider','rowsNum','rowsMin','rowsMax','rowsStep', ()=>nRows, v=>{nRows=Math.max(1,Math.round(v));});
wireParam('lSlider','lNum','lMin','lMax','lStep', ()=>panelL, v=>{panelL=v;});
wireParam('wSlider','wNum','wMin','wMax','wStep', ()=>panelW, v=>{panelW=v;});
// ─── Reflectivity ───
const reflNumEl = document.getElementById('reflNum');
function setRefl(v) { reflectivity = v / 100; reflNumEl.value = v; updatePresetBtns(); redrawAll(); }
reflNumEl.addEventListener('input', () => setRefl(parseFloat(reflNumEl.value) || 0));
reflNumEl.addEventListener('change', () => setRefl(parseFloat(reflNumEl.value) || 0));
const presetBtns = document.querySelectorAll('.preset-btn');
function updatePresetBtns() { const r = Math.round(reflectivity * 100); presetBtns.forEach(b => b.classList.toggle('active', parseInt(b.dataset.r) === r)); }
presetBtns.forEach(b => b.addEventListener('click', () => setRefl(parseInt(b.dataset.r))));
// ─── Power ───
const ledPowerEl = document.getElementById('ledPower');
const totalPowerOutEl = document.getElementById('totalPowerOut');
const panelAbsorbedEl = document.getElementById('panelAbsorbed');
const planeReceivedEl = document.getElementById('planeReceived');
const calcTimeEl = document.getElementById('calcTime');
const calcJoulesEl = document.getElementById('calcJoules');
const calcTargetEl = document.getElementById('calcTarget');
const calcTimeNeededEl = document.getElementById('calcTimeNeeded');
// Irradiance from a single Lambertian source at (sx,sy,0) on a horizontal plane at height h
// at point (px,py,h): E = cos^3(theta) / (pi * h^2) per unit source power
// We already have illuminanceAt2D which returns cos^3/h^2, so divide by pi for power fraction.
// Irradiance from source at (sx,sy,0) on a VERTICAL wall point (wx,wy,wz):
// The source emits as cos(theta_s) where theta_s is angle from source normal (z-up).
// cos(theta_s) = wz / r
// The wall receives as cos(theta_w) where theta_w is angle from wall normal (inward).
// For a wall facing in the x-direction: cos(theta_w) = |wx - sx| / r
// For a wall facing in the y-direction: cos(theta_w) = |wy - sy| / r
// E_wall = (1/pi) * cos(theta_s) * cos(theta_w) / r^2
function wallIrradiance(sx, sy, wx, wy, wz, wallAxis) {
const dx = wx - sx, dy = wy - sy;
const r2 = dx * dx + dy * dy + wz * wz;
const r = Math.sqrt(r2);
if (r < 1e-9) return 0;
const cosSource = wz / r; // source emission angle (from z-up normal)
const cosWall = wallAxis === 'x' ? Math.abs(dx) / r : Math.abs(dy) / r;
return cosSource * cosWall / (Math.PI * r2);
}
function updatePower() {
const nTotal = nCols * nRows;
const perLed_mW = parseFloat(ledPowerEl.value) || 0;
const totalPower_W = perLed_mW * nTotal / 1000;
const perLed_W = perLed_mW / 1000;
const h = hDist, sources = getSourceGrid();
const plH = panelL / 2, plW2 = panelW / 2;
const refl = getReflectorBounds();
// Power on target: integrate direct + reflected contributions separately
// Direct: each real source emits P watts into hemisphere. Fraction on plane per source:
// integral of cos^3(theta)/(pi*h^2) dA over the target plane
// Reflected: mirror sources contribute R * (same integral for mirror position)
// but this is energy redirected from sideways emission, not new energy.
// Total received can never exceed total emitted.
const pSteps = 40;
const pdx = panelL / pSteps, pdy = panelW / pSteps;
let directSum = 0, reflectedSum = 0;
const mirrorSources = [];
if (reflectivity > 0) {
const rb = getReflectorBounds();
for (const s of sources) {
mirrorSources.push({ x: 2 * rb.xL - s.x, y: s.y });
mirrorSources.push({ x: 2 * rb.xR - s.x, y: s.y });
mirrorSources.push({ x: s.x, y: 2 * rb.yL - s.y });
mirrorSources.push({ x: s.x, y: 2 * rb.yR - s.y });
}
}
for (let iy = 0; iy <= pSteps; iy++) for (let ix = 0; ix <= pSteps; ix++) {
const x = -plH + ix * pdx, y = -plW2 + iy * pdy;
let dE = 0, rE = 0;
for (const s of sources) dE += illuminanceAt2D(x - s.x, y - s.y, h);
for (const m of mirrorSources) rE += illuminanceAt2D(x - m.x, y - m.y, h);
directSum += dE * pdx * pdy;
reflectedSum += rE * pdx * pdy;
}
// Fraction of total emitted power landing on target
// Direct fraction: what each real source contributes directly
const directFrac = directSum / (nTotal * Math.PI);
// Reflected fraction: mirror sources redirect R * their emission onto the plane
// but the energy comes from the real sources' sideways emission, not new power
const reflectedFrac = reflectivity * reflectedSum / (nTotal * Math.PI);
// Total fraction capped at 1.0 (can't receive more than emitted)
const totalFrac = Math.min(directFrac + reflectedFrac, 1.0);
const planeRec_W = totalPower_W * totalFrac;
const efficiency = totalPower_W > 0 ? (totalFrac * 100) : 0;
totalPowerOutEl.textContent = 'Total: ' + fmtP(totalPower_W);
panelAbsorbedEl.textContent = 'Efficiency: ' + efficiency.toFixed(1) + '%';
planeReceivedEl.textContent = 'Target receives: ' + fmtP(planeRec_W);
// Energy density: J/cm² = (power W / area cm²) * time s
// Assuming l and w are in mm: area_cm2 = (l * w) / 100
const area_cm2 = (panelL * panelW) / 100;
const irradiance_W_cm2 = area_cm2 > 0 ? planeRec_W / area_cm2 : 0; // W/cm²
const t = parseFloat(calcTimeEl.value) || 0;
const energyDensity = irradiance_W_cm2 * t; // J/cm²
calcJoulesEl.textContent = '= ' + fmtED(energyDensity);
const tgt = parseFloat(calcTargetEl.value) || 0; // target J/cm²
const tn = irradiance_W_cm2 > 0 ? tgt / irradiance_W_cm2 : Infinity;
calcTimeNeededEl.textContent = tn === Infinity ? '= \u221E' : '= ' + fmtT(tn);
}
function fmtP(w) { return w >= 1 ? w.toFixed(2)+' W' : (w*1000).toFixed(1)+' mW'; }
function fmtED(jcm2) { return jcm2 >= 1 ? jcm2.toFixed(2)+' J/cm\u00B2' : (jcm2*1000).toFixed(1)+' mJ/cm\u00B2'; }
function fmtT(s) { return s >= 3600 ? (s/3600).toFixed(2)+' hr' : s >= 60 ? (s/60).toFixed(1)+' min' : s.toFixed(1)+' sec'; }
document.getElementById('showLeds').addEventListener('change', redrawAll);
ledPowerEl.addEventListener('input', updatePower);
calcTimeEl.addEventListener('input', updatePower);
calcTargetEl.addEventListener('input', updatePower);
// ─── URL params ───
const params = new URLSearchParams(window.location.search);
if (params.has('h')) { hDist = parseFloat(params.get('h')); document.getElementById('hSlider').value = hDist; document.getElementById('hNum').value = hDist; }
if (params.has('d')) { dSpacing = parseFloat(params.get('d')); document.getElementById('dSlider').value = dSpacing; document.getElementById('dNum').value = dSpacing; }
if (params.has('cols')) { nCols = parseInt(params.get('cols')); document.getElementById('colsSlider').value = nCols; document.getElementById('colsNum').value = nCols; }
if (params.has('rows')) { nRows = parseInt(params.get('rows')); document.getElementById('rowsSlider').value = nRows; document.getElementById('rowsNum').value = nRows; }
if (params.has('l')) { panelL = parseFloat(params.get('l')); document.getElementById('lSlider').value = panelL; document.getElementById('lNum').value = panelL; }
if (params.has('w')) { panelW = parseFloat(params.get('w')); document.getElementById('wSlider').value = panelW; document.getElementById('wNum').value = panelW; }
if (params.has('r')) { reflectivity = parseFloat(params.get('r')) / 100; reflNumEl.value = Math.round(reflectivity * 100); updatePresetBtns(); }
// ─── Init ───
window.addEventListener('resize', () => { polarResize(); hmResize(); crResize(); updatePower(); });
polarResize(); hmResize(); crResize(); updatePower();
</script>
</body>
</html>