MediaWiki:Gadget-AerofoilDesigner.js: Difference between revisions
From Aodhán Gallagher
No edit summary |
No edit summary |
||
| (2 intermediate revisions by the same user not shown) | |||
| Line 2: | Line 2: | ||
'use strict'; | 'use strict'; | ||
const ANGLE_MODE = 'degrees'; | const ANGLE_MODE = 'degrees'; | ||
const PARAMS = [ | const PARAMS = [ | ||
{ key: 'xt', label: 'xₜ — thickness join', min: 0.05, max: 0.95, step: 0.005, value: 0.25 }, | { key: 'xt', label: 'xₜ — thickness join', min: 0.05, max: 0.95, step: 0.005, value: 0.25 }, | ||
| Line 591: | Line 582: | ||
number.className = 'aerofoil-number'; | number.className = 'aerofoil-number'; | ||
const setValue = (raw) => { | const setValue = (raw) => { | ||
| Line 601: | Line 590: | ||
number.value = value; | number.value = value; | ||
values[p.key] = value; | values[p.key] = value; | ||
}; | }; | ||
| Line 609: | Line 598: | ||
setValue(p.value); | setValue(p.value); | ||
inputs[p.key] = range; | inputs[p.key] = range; | ||
row.appendChild(label); | row.appendChild(label); | ||
row.appendChild(range); | row.appendChild(range); | ||
row.appendChild(number); | row.appendChild(number); | ||
controls.appendChild(row); | controls.appendChild(row); | ||
}); | }); | ||
Latest revision as of 10:27, 23 September 2026
(function () {
'use strict';
const ANGLE_MODE = 'degrees';
const PARAMS = [
{ key: 'xt', label: 'xₜ — thickness join', min: 0.05, max: 0.95, step: 0.005, value: 0.25 },
{ key: 'yt', label: 'yₜ — thickness join', min: 0.00, max: 0.15, step: 0.001, value: 0.03 },
{ key: 'xc', label: 'x꜀ — camber join', min: 0.05, max: 0.95, step: 0.005, value: 0.40 },
{ key: 'yc', label: 'y꜀ — camber join', min: -0.10, max: 0.15, step: 0.001, value: 0.02 },
{ key: 'rle', label: 'rₗₑ — leading-edge radius', min: 0.001, max: 0.20, step: 0.001, value: 0.03 },
{ key: 'dzte', label: 'Δzₜₑ — TE thickness offset', min: -0.10, max: 0.10, step: 0.001, value: 0.00 },
{ key: 'zte', label: 'zₜₑ — TE camber', min: -0.10, max: 0.15, step: 0.001, value: 0.00 },
{ key: 'betate', label: 'βₜₑ — TE thickness angle', min: -45, max: 45, step: 0.5, value: 0 },
{ key: 'gammale', label: 'γₗₑ — LE camber angle', min: -45, max: 45, step: 0.5, value: 5 },
{ key: 'alphate', label: 'αₜₑ — TE camber angle', min: -45, max: 45, step: 0.5, value: 0 },
{ key: 'b0', label: 'b₀', min: 0.00, max: 0.30, step: 0.005, value: 0.05 },
{ key: 'b2', label: 'b₂', min: 0.00, max: 0.60, step: 0.005, value: 0.20 },
{ key: 'b8', label: 'b₈', min: 0.00, max: 0.10, step: 0.001, value: 0.00 },
{ key: 'b15', label: 'b₁₅', min: 0.00, max: 1.00, step: 0.005, value: 0.85 },
{ key: 'b17', label: 'b₁₇', min: 0.00, max: 1.00, step: 0.005, value: 0.85 }
];
function toRadians(angle) {
return ANGLE_MODE === 'degrees' ? angle * Math.PI / 180 : angle;
}
function cot(angle) {
return 1 / Math.tan(angle);
}
function bezier(points, u) {
// General Bernstein-form Bezier evaluation. This exactly covers the
// cubic (4-point) and quartic (5-point) curves used by the MATLAB code.
const n = points.length - 1;
let value = 0;
for (let i = 0; i <= n; i++) {
let binomial = 1;
for (let j = 1; j <= i; j++) {
binomial *= (n - j + 1) / j;
}
value += binomial * Math.pow(1 - u, n - i) * Math.pow(u, i) * points[i];
}
return value;
}
function sampleBezier(points, count) {
const values = new Array(count);
for (let i = 0; i < count; i++) {
const u = count === 1 ? 0 : i / (count - 1);
values[i] = bezier(points, u);
}
return values;
}
function linspace(start, stop, count) {
const values = new Array(count);
if (count === 1) {
values[0] = start;
return values;
}
const step = (stop - start) / (count - 1);
for (let i = 0; i < count; i++) {
values[i] = start + i * step;
}
return values;
}
function buildGeometry(v) {
const beta = toRadians(v.betate);
const gamma = toRadians(v.gammale);
const alpha = toRadians(v.alphate);
// ---- BEZIER CONTROL POINTS ----
const xLeadEdgeThick = [0, 0, -3 * v.b8 * v.b8 / (2 * v.rle), v.xt];
const yLeadEdgeThick = [0, v.b8, v.yt, v.yt];
const xTrailEdgeThick = [
v.xt,
(7 * v.xt + 9 * v.b8 * v.b8 / (2 * v.rle)) / 4,
3 * v.xt + 15 * v.b8 * v.b8 / (4 * v.rle),
v.b15,
1
];
const yTrailEdgeThick = [
v.yt,
v.yt,
(v.yt + v.b8) / 2,
v.dzte + (1 - v.b15) * Math.tan(beta),
v.dzte
];
const xLeadEdgeCamber = [0, v.b0, v.b2, v.xc];
const yLeadEdgeCamber = [0, v.b0 * Math.tan(gamma), v.yc, v.yc];
const xTrailEdgeCamber = [
v.xc,
(3 * v.xc - v.yc * cot(gamma)) / 2,
(-8 * v.yc * cot(gamma) + 13 * v.xc) / 6,
v.b17,
1
];
const yTrailEdgeCamber = [
v.yc,
v.yc,
5 * v.yc / 6,
v.zte - (1 - v.b17) * Math.tan(alpha),
v.zte
];
// ---- BEZIER CURVES ----
const n = 100;
const u = linspace(0, 1, n);
const thickLeadCurve = {
x: sampleBezier(xLeadEdgeThick, n),
y: sampleBezier(yLeadEdgeThick, n)
};
const thickTrailCurve = {
x: sampleBezier(xTrailEdgeThick, n),
y: sampleBezier(yTrailEdgeThick, n)
};
const camberLeadCurve = {
x: sampleBezier(xLeadEdgeCamber, n),
y: sampleBezier(yLeadEdgeCamber, n)
};
const camberTrailCurve = {
x: sampleBezier(xTrailEdgeCamber, n),
y: sampleBezier(yTrailEdgeCamber, n)
};
const thickCurve = {
x: thickLeadCurve.x.concat(thickTrailCurve.x),
y: thickLeadCurve.y.concat(thickTrailCurve.y)
};
const camberCurve = {
x: camberLeadCurve.x.concat(camberTrailCurve.x),
y: camberLeadCurve.y.concat(camberTrailCurve.y)
};
// ---- GEOMETRY VALIDITY CHECK ----
let negThick = false;
let thickBounds = false;
let camberBounds = false;
let overHung = false;
for (let i = 0; i < thickCurve.x.length; i++) {
if (thickCurve.y[i] < 0) negThick = true;
if (thickCurve.x[i] < 0 || thickCurve.x[i] > 1) thickBounds = true;
if (camberCurve.x[i] < 0 || camberCurve.x[i] > 1) camberBounds = true;
if (i > 0 && (thickCurve.x[i] < thickCurve.x[i - 1] || camberCurve.x[i] < camberCurve.x[i - 1])) {
overHung = true;
}
}
const validGeom = !(negThick || thickBounds || camberBounds || overHung);
// ---- INTERPOLATION ----
// MATLAB uses interp1(..., 'linear') after checking for overhang.
// For a valid monotonic curve, sorting by x gives the same result and
// also lets us keep rendering something useful while the user explores
// invalid parameter combinations.
const x = linspace(0, 1, n * 2);
const interpThickCurve = interpolateSorted(thickCurve.x, thickCurve.y, x);
const interpCamberCurve = interpolateSorted(camberCurve.x, camberCurve.y, x);
const upper = new Array(x.length);
const lower = new Array(x.length);
for (let i = 0; i < x.length; i++) {
upper[i] = interpCamberCurve[i] + interpThickCurve[i];
lower[i] = interpCamberCurve[i] - interpThickCurve[i];
}
return {
x,
upper,
lower,
camber: interpCamberCurve,
thickness: interpThickCurve,
thickCurve,
camberCurve,
thickLeadCurve,
thickTrailCurve,
camberLeadCurve,
camberTrailCurve,
controlPoints: {
thicknessLead: zipPoints(xLeadEdgeThick, yLeadEdgeThick),
thicknessTrail: zipPoints(xTrailEdgeThick, yTrailEdgeThick),
camberLead: zipPoints(xLeadEdgeCamber, yLeadEdgeCamber),
camberTrail: zipPoints(xTrailEdgeCamber, yTrailEdgeCamber)
},
validGeom,
checks: { negThick, thickBounds, camberBounds, overHung }
};
}
function zipPoints(xs, ys) {
return xs.map((x, i) => ({ x, y: ys[i] }));
}
function interpolateSorted(xValues, yValues, xQuery) {
const pairs = [];
for (let i = 0; i < xValues.length; i++) {
if (Number.isFinite(xValues[i]) && Number.isFinite(yValues[i])) {
pairs.push([xValues[i], yValues[i]]);
}
}
pairs.sort((a, b) => a[0] - b[0]);
// Remove duplicate x values, keeping the first value, as a pragmatic
// browser-side equivalent for plotting.
const xs = [];
const ys = [];
for (const pair of pairs) {
if (!xs.length || Math.abs(pair[0] - xs[xs.length - 1]) > 1e-12) {
xs.push(pair[0]);
ys.push(pair[1]);
}
}
return xQuery.map((q) => {
if (!xs.length || q < xs[0] || q > xs[xs.length - 1]) return NaN;
if (xs.length === 1) return ys[0];
let lo = 0;
let hi = xs.length - 1;
while (hi - lo > 1) {
const mid = Math.floor((lo + hi) / 2);
if (xs[mid] <= q) lo = mid;
else hi = mid;
}
const dx = xs[hi] - xs[lo];
const t = dx === 0 ? 0 : (q - xs[lo]) / dx;
return ys[lo] + t * (ys[hi] - ys[lo]);
});
}
function fmt(value) {
if (!Number.isFinite(value)) return '—';
if (Math.abs(value) >= 1) return value.toFixed(3);
if (Math.abs(value) >= 0.1) return value.toFixed(4);
return value.toFixed(5);
}
function createSvgElement(tag, attrs) {
const ns = 'http://www.w3.org/2000/svg';
const el = document.createElementNS(ns, tag);
Object.entries(attrs).forEach(([key, value]) => el.setAttribute(key, String(value)));
return el;
}
function renderPlot(svg, geom) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const validPoints = [];
for (let i = 0; i < geom.x.length; i++) {
if (Number.isFinite(geom.upper[i])) validPoints.push([geom.x[i], geom.upper[i]]);
if (Number.isFinite(geom.lower[i])) validPoints.push([geom.x[i], geom.lower[i]]);
if (Number.isFinite(geom.camber[i])) validPoints.push([geom.x[i], geom.camber[i]]);
}
if (!validPoints.length) {
const text = createSvgElement('text', { x: 50, y: 50, 'text-anchor': 'middle', class: 'aerofoil-empty' });
text.textContent = 'Geometry cannot be plotted for these parameters';
svg.appendChild(text);
return;
}
let minY = Math.min(...validPoints.map((p) => p[1]));
let maxY = Math.max(...validPoints.map((p) => p[1]));
if (!Number.isFinite(minY) || !Number.isFinite(maxY)) {
minY = -0.1;
maxY = 0.1;
}
const span = Math.max(maxY - minY, 0.04);
const margin = span * 0.12;
minY -= margin;
maxY += margin;
const W = 900;
const H = 520;
const padLeft = 70;
const padRight = 25;
const padTop = 25;
const padBottom = 55;
const plotW = W - padLeft - padRight;
const plotH = H - padTop - padBottom;
const sx = (x) => padLeft + x * plotW;
const sy = (y) => padTop + (maxY - y) / (maxY - minY) * plotH;
// Grid and axes.
[0, 0.25, 0.5, 0.75, 1].forEach((xVal) => {
const x = sx(xVal);
svg.appendChild(createSvgElement('line', {
x1: x, y1: padTop, x2: x, y2: padTop + plotH, class: 'aerofoil-grid'
}));
const label = createSvgElement('text', { x, y: H - 22, 'text-anchor': 'middle', class: 'aerofoil-tick' });
label.textContent = xVal.toFixed(2);
svg.appendChild(label);
});
const yTicks = 5;
for (let i = 0; i <= yTicks; i++) {
const yVal = minY + (maxY - minY) * i / yTicks;
const y = sy(yVal);
svg.appendChild(createSvgElement('line', {
x1: padLeft, y1: y, x2: padLeft + plotW, y2: y, class: 'aerofoil-grid'
}));
const label = createSvgElement('text', { x: padLeft - 9, y: y + 4, 'text-anchor': 'end', class: 'aerofoil-tick' });
label.textContent = fmt(yVal);
svg.appendChild(label);
}
svg.appendChild(createSvgElement('line', {
x1: padLeft, y1: sy(0), x2: padLeft + plotW, y2: sy(0), class: 'aerofoil-axis'
}));
svg.appendChild(createSvgElement('line', {
x1: padLeft, y1: padTop, x2: padLeft, y2: padTop + plotH, class: 'aerofoil-axis'
}));
// X axis label.
const xLabel = createSvgElement('text', { x: padLeft + plotW / 2, y: H - 4, 'text-anchor': 'middle', class: 'aerofoil-axis-label' });
xLabel.textContent = 'x/c';
svg.appendChild(xLabel);
// Y axis label.
const yLabel = createSvgElement('text', {
x: 16, y: padTop + plotH / 2, 'text-anchor': 'middle', class: 'aerofoil-axis-label',
transform: `rotate(-90 16 ${padTop + plotH / 2})`
});
yLabel.textContent = 'y/c';
svg.appendChild(yLabel);
function pathFromSeries(xs, ys) {
let d = '';
let started = false;
for (let i = 0; i < xs.length; i++) {
if (!Number.isFinite(xs[i]) || !Number.isFinite(ys[i])) {
started = false;
continue;
}
const command = started ? 'L' : 'M';
d += `${command}${sx(xs[i]).toFixed(2)},${sy(ys[i]).toFixed(2)} `;
started = true;
}
return d.trim();
}
const upperPath = pathFromSeries(geom.x, geom.upper);
const lowerPath = pathFromSeries([...geom.x].reverse(), [...geom.lower].reverse());
const profile = createSvgElement('path', { d: `${upperPath} ${lowerPath}`, class: 'aerofoil-profile' });
svg.appendChild(profile);
const upper = createSvgElement('path', { d: upperPath, class: 'aerofoil-upper' });
const lower = createSvgElement('path', { d: pathFromSeries(geom.x, geom.lower), class: 'aerofoil-lower' });
const camber = createSvgElement('path', { d: pathFromSeries(geom.x, geom.camber), class: 'aerofoil-camber' });
svg.appendChild(upper);
svg.appendChild(lower);
svg.appendChild(camber);
}
function renderConstructionPlot(svg, geom) {
while (svg.firstChild) svg.removeChild(svg.firstChild);
const allCurves = [
geom.thickLeadCurve,
geom.thickTrailCurve,
geom.camberLeadCurve,
geom.camberTrailCurve
];
const allPoints = [
...geom.controlPoints.thicknessLead,
...geom.controlPoints.thicknessTrail,
...geom.controlPoints.camberLead,
...geom.controlPoints.camberTrail
];
const finite = (v) => Number.isFinite(v);
const xs = [];
const ys = [];
allCurves.forEach((curve) => {
curve.x.forEach((x) => { if (finite(x)) xs.push(x); });
curve.y.forEach((y) => { if (finite(y)) ys.push(y); });
});
allPoints.forEach((p) => {
if (finite(p.x)) xs.push(p.x);
if (finite(p.y)) ys.push(p.y);
});
if (!xs.length || !ys.length) return;
let minX = Math.min(...xs);
let maxX = Math.max(...xs);
let minY = Math.min(...ys);
let maxY = Math.max(...ys);
const spanX = Math.max(maxX - minX, 0.2);
const spanY = Math.max(maxY - minY, 0.04);
minX -= 0.08 * spanX;
maxX += 0.08 * spanX;
minY -= 0.12 * spanY;
maxY += 0.12 * spanY;
const W = 900;
const H = 500;
const padLeft = 70;
const padRight = 25;
const padTop = 35;
const padBottom = 55;
const plotW = W - padLeft - padRight;
const plotH = H - padTop - padBottom;
const sx = (x) => padLeft + (x - minX) / (maxX - minX) * plotW;
const sy = (y) => padTop + (maxY - y) / (maxY - minY) * plotH;
// Grid and x/c tick labels.
const xTickCount = 5;
for (let i = 0; i <= xTickCount; i++) {
const xVal = minX + (maxX - minX) * i / xTickCount;
const px = sx(xVal);
svg.appendChild(createSvgElement('line', {
x1: px, y1: padTop, x2: px, y2: padTop + plotH, class: 'aerofoil-grid'
}));
const label = createSvgElement('text', {
x: px, y: H - 22, 'text-anchor': 'middle', class: 'aerofoil-tick'
});
label.textContent = fmt(xVal);
svg.appendChild(label);
}
const yTickCount = 5;
for (let i = 0; i <= yTickCount; i++) {
const yVal = minY + (maxY - minY) * i / yTickCount;
const py = sy(yVal);
svg.appendChild(createSvgElement('line', {
x1: padLeft, y1: py, x2: padLeft + plotW, y2: py, class: 'aerofoil-grid'
}));
const label = createSvgElement('text', {
x: padLeft - 9, y: py + 4, 'text-anchor': 'end', class: 'aerofoil-tick'
});
label.textContent = fmt(yVal);
svg.appendChild(label);
}
if (minX <= 0 && maxX >= 0) {
const px = sx(0);
svg.appendChild(createSvgElement('line', {
x1: px, y1: padTop, x2: px, y2: padTop + plotH, class: 'aerofoil-axis'
}));
}
if (minY <= 0 && maxY >= 0) {
const py = sy(0);
svg.appendChild(createSvgElement('line', {
x1: padLeft, y1: py, x2: padLeft + plotW, y2: py, class: 'aerofoil-axis'
}));
}
const xLabel = createSvgElement('text', {
x: padLeft + plotW / 2, y: H - 4, 'text-anchor': 'middle', class: 'aerofoil-axis-label'
});
xLabel.textContent = 'x/c';
svg.appendChild(xLabel);
const yLabel = createSvgElement('text', {
x: 16, y: padTop + plotH / 2, 'text-anchor': 'middle', class: 'aerofoil-axis-label',
transform: `rotate(-90 16 ${padTop + plotH / 2})`
});
yLabel.textContent = 'y/c';
svg.appendChild(yLabel);
function pathFromCurve(curve) {
let d = '';
let started = false;
for (let i = 0; i < curve.x.length; i++) {
if (!finite(curve.x[i]) || !finite(curve.y[i])) {
started = false;
continue;
}
d += `${started ? 'L' : 'M'}${sx(curve.x[i]).toFixed(2)},${sy(curve.y[i]).toFixed(2)} `;
started = true;
}
return d.trim();
}
// Curves.
svg.appendChild(createSvgElement('path', {
d: pathFromCurve(geom.thickLeadCurve), class: 'aerofoil-thickness-curve'
}));
svg.appendChild(createSvgElement('path', {
d: pathFromCurve(geom.thickTrailCurve), class: 'aerofoil-thickness-curve'
}));
svg.appendChild(createSvgElement('path', {
d: pathFromCurve(geom.camberLeadCurve), class: 'aerofoil-camber-curve'
}));
svg.appendChild(createSvgElement('path', {
d: pathFromCurve(geom.camberTrailCurve), class: 'aerofoil-camber-curve'
}));
// Control polygons and labelled points.
function drawControlSet(points, prefix, lineClass, pointClass) {
const polygonPoints = points
.filter((p) => finite(p.x) && finite(p.y))
.map((p) => `${sx(p.x).toFixed(2)},${sy(p.y).toFixed(2)}`)
.join(' ');
if (polygonPoints) {
svg.appendChild(createSvgElement('polyline', {
points: polygonPoints,
class: lineClass,
fill: 'none'
}));
}
points.forEach((p, i) => {
if (!finite(p.x) || !finite(p.y)) return;
const px = sx(p.x);
const py = sy(p.y);
svg.appendChild(createSvgElement('circle', {
cx: px, cy: py, r: 4, class: pointClass
}));
const label = createSvgElement('text', {
x: px + 7,
y: py - 7,
class: 'aerofoil-control-point-label'
});
label.textContent = `${prefix}${i}`;
svg.appendChild(label);
});
}
drawControlSet(geom.controlPoints.thicknessLead, 'T', 'aerofoil-thickness-control', 'aerofoil-thickness-point');
drawControlSet(geom.controlPoints.thicknessTrail, 'T', 'aerofoil-thickness-control', 'aerofoil-thickness-point');
drawControlSet(geom.controlPoints.camberLead, 'C', 'aerofoil-camber-control', 'aerofoil-camber-point');
drawControlSet(geom.controlPoints.camberTrail, 'C', 'aerofoil-camber-control', 'aerofoil-camber-point');
// Legend.
const legend = createSvgElement('g', { transform: `translate(${W - 220}, 18)` });
const tLegend = createSvgElement('text', { x: 0, y: 0, class: 'aerofoil-legend-text' });
tLegend.textContent = 'Thickness curve / controls';
legend.appendChild(tLegend);
const cLegend = createSvgElement('text', { x: 0, y: 20, class: 'aerofoil-legend-text' });
cLegend.textContent = 'Camber curve / controls';
legend.appendChild(cLegend);
svg.appendChild(legend);
}
function setup(root) {
if (root.dataset.aerofoilInitialised === '1') return;
root.dataset.aerofoilInitialised = '1';
const controls = document.createElement('div');
controls.className = 'aerofoil-controls';
const values = {};
const inputs = {};
const valueLabels = {};
PARAMS.forEach((p) => {
const row = document.createElement('div');
row.className = 'aerofoil-control';
const label = document.createElement('label');
label.className = 'aerofoil-control-label';
label.htmlFor = `aerofoil-${p.key}`;
label.textContent = p.label;
const range = document.createElement('input');
range.type = 'range';
range.id = `aerofoil-${p.key}`;
range.min = p.min;
range.max = p.max;
range.step = p.step;
range.value = p.value;
const number = document.createElement('input');
number.type = 'number';
number.min = p.min;
number.max = p.max;
number.step = p.step;
number.value = p.value;
number.className = 'aerofoil-number';
const setValue = (raw) => {
let value = Number(raw);
if (!Number.isFinite(value)) value = p.value;
value = Math.min(p.max, Math.max(p.min, value));
range.value = value;
number.value = value;
values[p.key] = value;
};
range.addEventListener('input', () => setValue(range.value));
number.addEventListener('input', () => setValue(number.value));
setValue(p.value);
inputs[p.key] = range;
row.appendChild(label);
row.appendChild(range);
row.appendChild(number);
controls.appendChild(row);
});
const actions = document.createElement('div');
actions.className = 'aerofoil-actions';
const reset = document.createElement('button');
reset.type = 'button';
reset.className = 'mw-ui-button';
reset.textContent = 'Reset';
reset.addEventListener('click', () => {
PARAMS.forEach((p) => {
inputs[p.key].value = p.value;
inputs[p.key].dispatchEvent(new Event('input', { bubbles: true }));
});
});
actions.appendChild(reset);
const plotWrap = document.createElement('div');
plotWrap.className = 'aerofoil-plot-wrap';
const svg = createSvgElement('svg', {
viewBox: '0 0 900 520',
role: 'img',
'aria-label': 'Interactive aerofoil plot'
});
plotWrap.appendChild(svg);
const constructionTitle = document.createElement('div');
constructionTitle.className = 'aerofoil-subplot-title';
constructionTitle.textContent = 'Bezier thickness and camber construction';
const constructionWrap = document.createElement('div');
constructionWrap.className = 'aerofoil-plot-wrap aerofoil-construction-wrap';
const constructionSvg = createSvgElement('svg', {
viewBox: '0 0 900 500',
role: 'img',
'aria-label': 'Bezier thickness and camber curves with control points'
});
constructionWrap.appendChild(constructionSvg);
const status = document.createElement('div');
status.className = 'aerofoil-status';
const checks = document.createElement('div');
checks.className = 'aerofoil-checks';
const unitNote = document.createElement('div');
unitNote.className = 'aerofoil-note';
unitNote.textContent = `Angles are entered in ${ANGLE_MODE}; calculations follow the MATLAB tan/cot convention.`;
root.appendChild(controls);
root.appendChild(actions);
root.appendChild(plotWrap);
root.appendChild(constructionTitle);
root.appendChild(constructionWrap);
root.appendChild(status);
root.appendChild(checks);
root.appendChild(unitNote);
const update = () => {
try {
const geom = buildGeometry(values);
renderPlot(svg, geom);
renderConstructionPlot(constructionSvg, geom);
status.className = `aerofoil-status ${geom.validGeom ? 'is-valid' : 'is-invalid'}`;
status.textContent = geom.validGeom ? 'Geometry valid' : 'Geometry invalid — see checks below.';
checks.innerHTML = '';
const messages = [
['Negative thickness', geom.checks.negThick],
['Thickness x outside [0, 1]', geom.checks.thickBounds],
['Camber x outside [0, 1]', geom.checks.camberBounds],
['Overhang / decreasing x', geom.checks.overHung]
];
messages.forEach(([name, failed]) => {
const item = document.createElement('span');
item.className = failed ? 'fail' : 'pass';
item.textContent = `${failed ? '✗' : '✓'} ${name}`;
checks.appendChild(item);
});
} catch (error) {
status.className = 'aerofoil-status is-invalid';
status.textContent = `Geometry error: ${error.message}`;
console.error('Aerofoil Designer:', error);
}
};
Object.keys(inputs).forEach((key) => {
inputs[key].addEventListener('input', update);
});
update();
}
function decimalPlaces(step) {
const text = String(step);
if (!text.includes('.')) return 0;
return text.split('.')[1].length;
}
function init() {
document.querySelectorAll('#aerofoil-tool').forEach(setup);
}
if (window.mw && mw.hook) {
mw.hook('wikipage.content').add(init);
init();
} else {
document.addEventListener('DOMContentLoaded', init);
}
}());