ieeeColors = ({
navy: "#2C3E50",
teal: "#16A085",
orange: "#E67E22",
gray: "#7F8C8D",
lightGray: "#ECF0F1",
darkGray: "#34495E",
red: "#E74C3C",
green: "#27AE60",
purple: "#9B59B6",
blue: "#3498DB"
})
// Scenario definitions
scenarios = [
{
id: "drone",
name: "Drone Orientation",
description: "Combine accelerometer and gyroscope for stable flight attitude",
sensors: ["accelerometer", "gyroscope"],
recommendedAlgorithm: "complementary",
icon: "helicopter"
},
{
id: "indoor",
name: "Indoor Navigation",
description: "Use IMU and magnetometer for indoor positioning",
sensors: ["accelerometer", "gyroscope", "magnetometer"],
recommendedAlgorithm: "madgwick",
icon: "building"
},
{
id: "vehicle",
name: "Vehicle Tracking",
description: "Fuse GPS, IMU, and odometry for accurate vehicle position",
sensors: ["gps", "accelerometer", "gyroscope", "odometry"],
recommendedAlgorithm: "kalman",
icon: "car-side"
},
{
id: "gesture",
name: "Gesture Recognition",
description: "Combine accelerometer and gyroscope for hand gesture detection",
sensors: ["accelerometer", "gyroscope"],
recommendedAlgorithm: "complementary",
icon: "hand-paper"
}
]
// Sensor definitions with characteristics
sensorDefinitions = ({
accelerometer: {
name: "Accelerometer",
shortName: "Accel",
color: ieeeColors.teal,
defaultNoise: 0.15,
defaultBias: 0.02,
defaultUpdateRate: 100,
description: "Measures linear acceleration and gravity",
strengths: "Long-term stability, gravity reference",
weaknesses: "Noisy, affected by linear motion"
},
gyroscope: {
name: "Gyroscope",
shortName: "Gyro",
color: ieeeColors.orange,
defaultNoise: 0.05,
defaultBias: 0.001,
defaultUpdateRate: 200,
description: "Measures angular velocity",
strengths: "Smooth, high bandwidth",
weaknesses: "Drifts over time (integration error)"
},
magnetometer: {
name: "Magnetometer",
shortName: "Mag",
color: ieeeColors.purple,
defaultNoise: 0.2,
defaultBias: 0.05,
defaultUpdateRate: 50,
description: "Measures magnetic field for heading",
strengths: "Absolute heading reference",
weaknesses: "Magnetic interference, slow"
},
gps: {
name: "GPS",
shortName: "GPS",
color: ieeeColors.green,
defaultNoise: 2.5,
defaultBias: 0.5,
defaultUpdateRate: 1,
description: "Global positioning via satellites",
strengths: "Absolute position, no drift",
weaknesses: "Low rate, multipath, no indoor"
},
odometry: {
name: "Odometry",
shortName: "Odom",
color: ieeeColors.blue,
defaultNoise: 0.08,
defaultBias: 0.003,
defaultUpdateRate: 50,
description: "Wheel encoder based distance",
strengths: "High rate, reliable",
weaknesses: "Cumulative error, wheel slip"
}
})
// Fusion algorithm definitions
fusionAlgorithms = [
{
id: "average",
name: "Simple Averaging",
description: "Average all sensor readings equally",
complexity: "O(n)",
pros: ["Simple to implement", "Low computation"],
cons: ["Ignores sensor quality", "Poor with different rates"],
formula: "x_fused = (1/n) * sum(x_i)"
},
{
id: "weighted",
name: "Weighted Averaging",
description: "Weight sensors by their reliability",
complexity: "O(n)",
pros: ["Accounts for sensor quality", "Still simple"],
cons: ["Fixed weights", "Doesn't adapt"],
formula: "x_fused = sum(w_i * x_i) / sum(w_i)"
},
{
id: "complementary",
name: "Complementary Filter",
description: "High-pass gyro + low-pass accelerometer",
complexity: "O(1)",
pros: ["Very efficient", "Good for orientation", "Tunable"],
cons: ["Only 2 sensors", "Manual tuning"],
formula: "angle = alpha * (angle + gyro*dt) + (1-alpha) * accel_angle"
},
{
id: "kalman",
name: "Kalman Filter",
description: "Optimal state estimation with noise models",
complexity: "O(n^3)",
pros: ["Optimal (linear)", "Handles noise", "Predictive"],
cons: ["Complex tuning", "Assumes linearity"],
formula: "x_k = A*x_{k-1} + B*u_k + K*(z_k - H*x_k)"
},
{
id: "madgwick",
name: "Madgwick Filter",
description: "Gradient descent orientation filter",
complexity: "O(1)",
pros: ["Efficient", "Good for 9-DOF IMU", "Auto-tuning"],
cons: ["Orientation only", "Magnetic sensitivity"],
formula: "q = q + q_dot * dt (gradient descent on error)"
}
]
// State management
viewof selectedScenario = Inputs.select(
scenarios.map(s => s.id),
{
label: "Scenario",
format: id => scenarios.find(s => s.id === id).name,
value: "drone"
}
)
currentScenario = scenarios.find(s => s.id === selectedScenario)
viewof selectedAlgorithm = Inputs.select(
fusionAlgorithms.map(a => a.id),
{
label: "Fusion Algorithm",
format: id => fusionAlgorithms.find(a => a.id === id).name,
value: currentScenario.recommendedAlgorithm
}
)
currentAlgorithm = fusionAlgorithms.find(a => a.id === selectedAlgorithm)
// Sensor parameters
viewof sensorParams = {
const container = htl.html`<div style="
background: ${ieeeColors.lightGray};
padding: 15px;
border-radius: 8px;
margin: 10px 0;
">
<h4 style="color: ${ieeeColors.navy}; margin-top: 0;">Sensor Configuration</h4>
<div id="sensor-controls" style="display: grid; gap: 15px;"></div>
</div>`;
const controlsDiv = container.querySelector("#sensor-controls");
const params = {};
currentScenario.sensors.forEach(sensorId => {
const def = sensorDefinitions[sensorId];
params[sensorId] = {
noise: def.defaultNoise,
bias: def.defaultBias,
updateRate: def.defaultUpdateRate,
enabled: true
};
const sensorDiv = htl.html`<div style="
background: white;
padding: 12px;
border-radius: 6px;
border-left: 4px solid ${def.color};
">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
<strong style="color: ${def.color};">${def.name}</strong>
<label style="font-size: 12px;">
<input type="checkbox" checked onchange=${e => {
params[sensorId].enabled = e.target.checked;
container.dispatchEvent(new CustomEvent("input"));
}}> Enabled
</label>
</div>
<div style="display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; font-size: 12px;">
<div>
<label>Noise: <span id="noise-${sensorId}">${def.defaultNoise.toFixed(2)}</span></label>
<input type="range" min="0" max="1" step="0.01" value="${def.defaultNoise}"
style="width: 100%;"
oninput=${e => {
params[sensorId].noise = +e.target.value;
e.target.parentElement.querySelector('span').textContent = (+e.target.value).toFixed(2);
container.dispatchEvent(new CustomEvent("input"));
}}>
</div>
<div>
<label>Bias: <span id="bias-${sensorId}">${def.defaultBias.toFixed(3)}</span></label>
<input type="range" min="0" max="0.1" step="0.001" value="${def.defaultBias}"
style="width: 100%;"
oninput=${e => {
params[sensorId].bias = +e.target.value;
e.target.parentElement.querySelector('span').textContent = (+e.target.value).toFixed(3);
container.dispatchEvent(new CustomEvent("input"));
}}>
</div>
<div>
<label>Rate (Hz): <span id="rate-${sensorId}">${def.defaultUpdateRate}</span></label>
<input type="range" min="1" max="200" step="1" value="${def.defaultUpdateRate}"
style="width: 100%;"
oninput=${e => {
params[sensorId].updateRate = +e.target.value;
e.target.parentElement.querySelector('span').textContent = e.target.value;
container.dispatchEvent(new CustomEvent("input"));
}}>
</div>
</div>
</div>`;
controlsDiv.appendChild(sensorDiv);
});
container.value = params;
return container;
}
// Algorithm parameters
viewof algorithmParams = {
const container = htl.html`<div style="
background: ${ieeeColors.lightGray};
padding: 15px;
border-radius: 8px;
margin: 10px 0;
">
<h4 style="color: ${ieeeColors.navy}; margin-top: 0;">Algorithm Parameters</h4>
<div id="algo-params"></div>
</div>`;
const paramsDiv = container.querySelector("#algo-params");
const params = {};
if (selectedAlgorithm === "complementary") {
params.alpha = 0.98;
paramsDiv.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label>Alpha (gyro weight): <span id="alpha-val">0.98</span></label>
<input type="range" min="0.5" max="0.999" step="0.001" value="0.98" style="width: 100%;">
<p style="font-size: 11px; color: ${ieeeColors.gray}; margin: 4px 0;">
Higher = trust gyro more (smoother, may drift)<br>
Lower = trust accel more (noisier, no drift)
</p>
</div>
<div style="background: white; padding: 10px; border-radius: 6px;">
<strong>Current Setting:</strong><br>
<span style="font-size: 12px;">Gyro: ${Math.round(0.98 * 100)}% | Accel: ${Math.round((1-0.98) * 100)}%</span>
</div>
</div>
`;
paramsDiv.querySelector('input').addEventListener('input', e => {
params.alpha = +e.target.value;
paramsDiv.querySelector('#alpha-val').textContent = (+e.target.value).toFixed(3);
paramsDiv.querySelector('div:last-child span').textContent =
`Gyro: ${Math.round(params.alpha * 100)}% | Accel: ${Math.round((1-params.alpha) * 100)}%`;
container.dispatchEvent(new CustomEvent("input"));
});
} else if (selectedAlgorithm === "kalman") {
params.processNoise = 0.01;
params.measurementNoise = 0.1;
paramsDiv.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label>Process Noise (Q): <span id="q-val">0.01</span></label>
<input type="range" min="0.001" max="0.1" step="0.001" value="0.01" id="q-input" style="width: 100%;">
<p style="font-size: 11px; color: ${ieeeColors.gray}; margin: 4px 0;">
How much the system state changes unexpectedly
</p>
</div>
<div>
<label>Measurement Noise (R): <span id="r-val">0.1</span></label>
<input type="range" min="0.01" max="1" step="0.01" value="0.1" id="r-input" style="width: 100%;">
<p style="font-size: 11px; color: ${ieeeColors.gray}; margin: 4px 0;">
How noisy sensor measurements are
</p>
</div>
</div>
`;
paramsDiv.querySelector('#q-input').addEventListener('input', e => {
params.processNoise = +e.target.value;
paramsDiv.querySelector('#q-val').textContent = (+e.target.value).toFixed(3);
container.dispatchEvent(new CustomEvent("input"));
});
paramsDiv.querySelector('#r-input').addEventListener('input', e => {
params.measurementNoise = +e.target.value;
paramsDiv.querySelector('#r-val').textContent = (+e.target.value).toFixed(2);
container.dispatchEvent(new CustomEvent("input"));
});
} else if (selectedAlgorithm === "madgwick") {
params.beta = 0.1;
paramsDiv.innerHTML = `
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px;">
<div>
<label>Beta (gain): <span id="beta-val">0.1</span></label>
<input type="range" min="0.01" max="0.5" step="0.01" value="0.1" style="width: 100%;">
<p style="font-size: 11px; color: ${ieeeColors.gray}; margin: 4px 0;">
Higher = faster convergence but more noise
</p>
</div>
<div style="background: white; padding: 10px; border-radius: 6px;">
<strong>Recommended:</strong> 0.033 - 0.1<br>
<span style="font-size: 12px;">Lower for stable motion, higher for rapid changes</span>
</div>
</div>
`;
paramsDiv.querySelector('input').addEventListener('input', e => {
params.beta = +e.target.value;
paramsDiv.querySelector('#beta-val').textContent = (+e.target.value).toFixed(2);
container.dispatchEvent(new CustomEvent("input"));
});
} else if (selectedAlgorithm === "weighted") {
paramsDiv.innerHTML = `
<div style="background: white; padding: 10px; border-radius: 6px;">
<p style="margin: 0; font-size: 12px;">
Weights are automatically calculated based on sensor noise levels.<br>
Lower noise = higher weight.
</p>
</div>
`;
} else {
paramsDiv.innerHTML = `
<div style="background: white; padding: 10px; border-radius: 6px;">
<p style="margin: 0; font-size: 12px;">
Simple averaging uses equal weights for all sensors.
</p>
</div>
`;
}
container.value = params;
return container;
}
// Simulation controls
viewof simulationControls = {
const container = htl.html`<div style="
display: flex;
gap: 15px;
align-items: center;
margin: 15px 0;
flex-wrap: wrap;
">
<button id="start-btn" style="
background: ${ieeeColors.teal};
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
font-weight: bold;
">Start Simulation</button>
<button id="reset-btn" style="
background: ${ieeeColors.gray};
color: white;
border: none;
padding: 10px 20px;
border-radius: 6px;
cursor: pointer;
">Reset</button>
<label style="display: flex; align-items: center; gap: 5px;">
<input type="checkbox" id="motion-check" checked>
<span>Simulated Motion</span>
</label>
<div style="flex: 1;"></div>
<div style="font-size: 12px; color: ${ieeeColors.gray};">
Time: <span id="time-display">0.0</span>s
</div>
</div>`;
container.value = { running: false, time: 0, motion: true };
const startBtn = container.querySelector('#start-btn');
const resetBtn = container.querySelector('#reset-btn');
const motionCheck = container.querySelector('#motion-check');
const timeDisplay = container.querySelector('#time-display');
startBtn.onclick = () => {
container.value.running = !container.value.running;
startBtn.textContent = container.value.running ? 'Pause' : 'Start Simulation';
startBtn.style.background = container.value.running ? ieeeColors.orange : ieeeColors.teal;
container.dispatchEvent(new CustomEvent("input"));
};
resetBtn.onclick = () => {
container.value.time = 0;
container.value.running = false;
startBtn.textContent = 'Start Simulation';
startBtn.style.background = ieeeColors.teal;
timeDisplay.textContent = '0.0';
container.dispatchEvent(new CustomEvent("input"));
};
motionCheck.onchange = () => {
container.value.motion = motionCheck.checked;
container.dispatchEvent(new CustomEvent("input"));
};
return container;
}
// Simulation engine
simulationState = {
const state = {
time: 0,
trueValue: 0,
trueVelocity: 0,
sensorReadings: {},
fusedValue: 0,
history: {
time: [],
true: [],
fused: [],
sensors: {}
},
kalmanState: { x: 0, p: 1 },
complementaryAngle: 0,
madgwickQ: [1, 0, 0, 0],
gyroIntegrated: 0
};
currentScenario.sensors.forEach(s => {
state.history.sensors[s] = [];
});
return state;
}
// Noise generator
gaussianRandom = () => {
let u = 0, v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2.0 * Math.log(u)) * Math.cos(2.0 * Math.PI * v);
}
// Generate sensor reading
generateSensorReading = (sensorId, trueValue, time, params) => {
const sensorConfig = params[sensorId];
if (!sensorConfig || !sensorConfig.enabled) return null;
const noise = gaussianRandom() * sensorConfig.noise;
const drift = sensorConfig.bias * time * 0.1;
// Different sensors behave differently
if (sensorId === "gyroscope") {
// Gyroscope measures rate of change, accumulates drift
return trueValue + noise + drift;
} else if (sensorId === "accelerometer") {
// Accelerometer is noisy but no drift
return trueValue + noise * 2;
} else if (sensorId === "magnetometer") {
// Magnetometer has occasional spikes (interference)
const spike = Math.random() < 0.02 ? gaussianRandom() * 0.5 : 0;
return trueValue + noise + spike;
} else if (sensorId === "gps") {
// GPS has large noise but centered
return trueValue + noise * 5;
} else if (sensorId === "odometry") {
// Odometry accumulates small errors
return trueValue + noise * 0.5 + drift * 0.5;
}
return trueValue + noise;
}
// Fusion algorithms implementation
applyFusion = (readings, algorithm, params, algoParams, state, dt) => {
const validReadings = Object.entries(readings)
.filter(([k, v]) => v !== null && params[k]?.enabled)
.map(([k, v]) => ({ sensor: k, value: v, noise: params[k].noise }));
if (validReadings.length === 0) return state.fusedValue;
if (algorithm === "average") {
return validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length;
}
if (algorithm === "weighted") {
const weights = validReadings.map(r => 1 / (r.noise * r.noise + 0.001));
const totalWeight = weights.reduce((a, b) => a + b, 0);
return validReadings.reduce((sum, r, i) => sum + r.value * weights[i], 0) / totalWeight;
}
if (algorithm === "complementary") {
const alpha = algoParams.alpha || 0.98;
const gyroReading = readings.gyroscope;
const accelReading = readings.accelerometer;
if (gyroReading !== null && accelReading !== null) {
// Integrate gyro and blend with accelerometer
state.complementaryAngle = alpha * (state.complementaryAngle + gyroReading * dt) +
(1 - alpha) * accelReading;
return state.complementaryAngle;
}
return validReadings[0].value;
}
if (algorithm === "kalman") {
const Q = algoParams.processNoise || 0.01;
const R = algoParams.measurementNoise || 0.1;
// Prediction
const xPred = state.kalmanState.x;
const pPred = state.kalmanState.p + Q;
// Update with measurement
const z = validReadings.reduce((sum, r) => sum + r.value, 0) / validReadings.length;
const K = pPred / (pPred + R);
state.kalmanState.x = xPred + K * (z - xPred);
state.kalmanState.p = (1 - K) * pPred;
return state.kalmanState.x;
}
if (algorithm === "madgwick") {
// Simplified Madgwick for 1D demo
const beta = algoParams.beta || 0.1;
const gyroReading = readings.gyroscope || 0;
const accelReading = readings.accelerometer || 0;
// Gradient descent step
const error = state.fusedValue - accelReading;
const correction = beta * error;
return state.fusedValue + (gyroReading - correction) * dt;
}
return validReadings[0].value;
}
// Main simulation loop using a generator
simulation = {
const dt = 0.02; // 50 Hz simulation
const maxHistory = 500;
while (true) {
yield Promises.delay(dt * 1000);
if (!simulationControls.running) continue;
simulationState.time += dt;
// Generate true value (simulated motion)
if (simulationControls.motion) {
// Complex motion pattern
simulationState.trueValue =
Math.sin(simulationState.time * 0.5) * 30 +
Math.sin(simulationState.time * 1.5) * 10 +
Math.sin(simulationState.time * 0.2) * 20;
simulationState.trueVelocity =
Math.cos(simulationState.time * 0.5) * 15 +
Math.cos(simulationState.time * 1.5) * 15 +
Math.cos(simulationState.time * 0.2) * 4;
}
// Generate sensor readings
currentScenario.sensors.forEach(sensorId => {
const reading = generateSensorReading(
sensorId,
simulationState.trueValue,
simulationState.time,
sensorParams
);
simulationState.sensorReadings[sensorId] = reading;
});
// Apply fusion
simulationState.fusedValue = applyFusion(
simulationState.sensorReadings,
selectedAlgorithm,
sensorParams,
algorithmParams,
simulationState,
dt
);
// Update history
simulationState.history.time.push(simulationState.time);
simulationState.history.true.push(simulationState.trueValue);
simulationState.history.fused.push(simulationState.fusedValue);
currentScenario.sensors.forEach(sensorId => {
if (!simulationState.history.sensors[sensorId]) {
simulationState.history.sensors[sensorId] = [];
}
simulationState.history.sensors[sensorId].push(
simulationState.sensorReadings[sensorId]
);
});
// Trim history
if (simulationState.history.time.length > maxHistory) {
simulationState.history.time.shift();
simulationState.history.true.shift();
simulationState.history.fused.shift();
Object.keys(simulationState.history.sensors).forEach(k => {
simulationState.history.sensors[k].shift();
});
}
// Update time display
document.querySelector('#time-display').textContent =
simulationState.time.toFixed(1);
}
}
// Time-series visualization
timeSeriesPlot = {
const width = 800;
const height = 350;
const margin = { top: 30, right: 120, bottom: 40, left: 60 };
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [0, 0, width, height])
.style("max-width", "100%")
.style("height", "auto")
.style("background", "white")
.style("border-radius", "8px")
.style("box-shadow", "0 2px 8px rgba(0,0,0,0.1)");
// Title
svg.append("text")
.attr("x", width / 2)
.attr("y", 20)
.attr("text-anchor", "middle")
.attr("fill", ieeeColors.navy)
.attr("font-weight", "bold")
.text("Time-Series Comparison");
const g = svg.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
const plotWidth = width - margin.left - margin.right;
const plotHeight = height - margin.top - margin.bottom;
// Scales
const xScale = d3.scaleLinear().range([0, plotWidth]);
const yScale = d3.scaleLinear().range([plotHeight, 0]);
// Axes
const xAxis = g.append("g")
.attr("transform", `translate(0,${plotHeight})`);
const yAxis = g.append("g");
// Line generators
const line = d3.line()
.x((d, i) => xScale(simulationState.history.time[i] || 0))
.y(d => yScale(d))
.curve(d3.curveMonotoneX);
// Paths for each data series
const truePath = g.append("path")
.attr("fill", "none")
.attr("stroke", ieeeColors.navy)
.attr("stroke-width", 2)
.attr("stroke-dasharray", "5,5");
const fusedPath = g.append("path")
.attr("fill", "none")
.attr("stroke", ieeeColors.teal)
.attr("stroke-width", 3);
const sensorPaths = {};
currentScenario.sensors.forEach((sensorId, i) => {
sensorPaths[sensorId] = g.append("path")
.attr("fill", "none")
.attr("stroke", sensorDefinitions[sensorId].color)
.attr("stroke-width", 1)
.attr("opacity", 0.5);
});
// Legend
const legend = svg.append("g")
.attr("transform", `translate(${width - margin.right + 10}, ${margin.top + 10})`);
const legendItems = [
{ label: "True Value", color: ieeeColors.navy, dash: "5,5" },
{ label: "Fused", color: ieeeColors.teal, dash: null },
...currentScenario.sensors.map(s => ({
label: sensorDefinitions[s].shortName,
color: sensorDefinitions[s].color,
dash: null,
opacity: 0.5
}))
];
legendItems.forEach((item, i) => {
const lg = legend.append("g")
.attr("transform", `translate(0, ${i * 18})`);
lg.append("line")
.attr("x1", 0)
.attr("x2", 20)
.attr("y1", 6)
.attr("y2", 6)
.attr("stroke", item.color)
.attr("stroke-width", 2)
.attr("stroke-dasharray", item.dash)
.attr("opacity", item.opacity || 1);
lg.append("text")
.attr("x", 25)
.attr("y", 10)
.attr("font-size", 11)
.attr("fill", ieeeColors.darkGray)
.text(item.label);
});
// Update function
function update() {
if (simulationState.history.time.length < 2) return;
const times = simulationState.history.time;
const xDomain = [Math.max(0, times[times.length - 1] - 10), times[times.length - 1]];
const allValues = [
...simulationState.history.true,
...simulationState.history.fused,
...Object.values(simulationState.history.sensors).flat().filter(v => v !== null)
];
const yDomain = d3.extent(allValues);
const yPadding = (yDomain[1] - yDomain[0]) * 0.1 || 10;
xScale.domain(xDomain);
yScale.domain([yDomain[0] - yPadding, yDomain[1] + yPadding]);
xAxis.call(d3.axisBottom(xScale).ticks(5).tickFormat(d => d.toFixed(1) + "s"));
yAxis.call(d3.axisLeft(yScale).ticks(5));
truePath.attr("d", line(simulationState.history.true));
fusedPath.attr("d", line(simulationState.history.fused));
Object.entries(sensorPaths).forEach(([sensorId, path]) => {
const data = simulationState.history.sensors[sensorId] || [];
path.attr("d", line(data.map(v => v === null ? yScale.domain()[0] : v)));
});
}
// Animation loop
const interval = d3.interval(update, 50);
return svg.node();
}
// Error/variance display
errorDisplay = {
const container = htl.html`<div style="
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 15px;
margin: 15px 0;
"></div>`;
function calculateStats() {
const history = simulationState.history;
if (history.time.length < 10) return null;
const recentTrue = history.true.slice(-100);
const recentFused = history.fused.slice(-100);
// Calculate errors
const errors = recentTrue.map((t, i) => Math.abs(t - recentFused[i]));
const mae = errors.reduce((a, b) => a + b, 0) / errors.length;
const mse = errors.map(e => e * e).reduce((a, b) => a + b, 0) / errors.length;
const rmse = Math.sqrt(mse);
// Calculate variance
const mean = recentFused.reduce((a, b) => a + b, 0) / recentFused.length;
const variance = recentFused.map(v => (v - mean) ** 2)
.reduce((a, b) => a + b, 0) / recentFused.length;
// Sensor errors
const sensorErrors = {};
Object.entries(history.sensors).forEach(([sensorId, data]) => {
const recent = data.slice(-100).filter(v => v !== null);
if (recent.length > 0) {
const errs = recentTrue.slice(0, recent.length)
.map((t, i) => Math.abs(t - recent[i]));
sensorErrors[sensorId] = errs.reduce((a, b) => a + b, 0) / errs.length;
}
});
return { mae, rmse, variance, sensorErrors };
}
function update() {
const stats = calculateStats();
if (!stats) {
container.innerHTML = `<div style="
grid-column: 1 / -1;
text-align: center;
color: ${ieeeColors.gray};
padding: 20px;
">Start simulation to see statistics</div>`;
return;
}
container.innerHTML = `
<div style="
background: white;
padding: 15px;
border-radius: 8px;
border-left: 4px solid ${ieeeColors.teal};
">
<div style="font-size: 11px; color: ${ieeeColors.gray}; text-transform: uppercase;">Fused Output</div>
<div style="font-size: 24px; font-weight: bold; color: ${ieeeColors.navy};">
MAE: ${stats.mae.toFixed(2)}
</div>
<div style="font-size: 12px; color: ${ieeeColors.gray};">
RMSE: ${stats.rmse.toFixed(2)} | Var: ${stats.variance.toFixed(2)}
</div>
</div>
${Object.entries(stats.sensorErrors).map(([sensorId, error]) => `
<div style="
background: white;
padding: 15px;
border-radius: 8px;
border-left: 4px solid ${sensorDefinitions[sensorId]?.color || ieeeColors.gray};
">
<div style="font-size: 11px; color: ${ieeeColors.gray}; text-transform: uppercase;">
${sensorDefinitions[sensorId]?.shortName || sensorId}
</div>
<div style="font-size: 24px; font-weight: bold; color: ${ieeeColors.darkGray};">
MAE: ${error.toFixed(2)}
</div>
<div style="font-size: 12px; color: ${stats.mae < error ? ieeeColors.green : ieeeColors.red};">
${stats.mae < error ? 'Fusion ' + ((1 - stats.mae/error) * 100).toFixed(0) + '% better' : 'Worse than fusion'}
</div>
</div>
`).join('')}
`;
}
setInterval(update, 200);
return container;
}
// 3D Orientation visualization (simplified 2D representation)
orientationDisplay = {
const width = 300;
const height = 300;
const svg = d3.create("svg")
.attr("width", width)
.attr("height", height)
.attr("viewBox", [-width/2, -height/2, width, height])
.style("background", "white")
.style("border-radius", "8px")
.style("box-shadow", "0 2px 8px rgba(0,0,0,0.1)");
// Background circle
svg.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 120)
.attr("fill", ieeeColors.lightGray)
.attr("stroke", ieeeColors.gray)
.attr("stroke-width", 2);
// Cardinal directions
["N", "E", "S", "W"].forEach((dir, i) => {
const angle = i * Math.PI / 2 - Math.PI / 2;
svg.append("text")
.attr("x", Math.cos(angle) * 135)
.attr("y", Math.sin(angle) * 135)
.attr("text-anchor", "middle")
.attr("dominant-baseline", "middle")
.attr("fill", ieeeColors.darkGray)
.attr("font-size", 14)
.attr("font-weight", "bold")
.text(dir);
});
// Tick marks
for (let i = 0; i < 36; i++) {
const angle = i * Math.PI / 18;
const r1 = i % 9 === 0 ? 105 : 115;
const r2 = 120;
svg.append("line")
.attr("x1", Math.cos(angle) * r1)
.attr("y1", Math.sin(angle) * r1)
.attr("x2", Math.cos(angle) * r2)
.attr("y2", Math.sin(angle) * r2)
.attr("stroke", ieeeColors.gray)
.attr("stroke-width", i % 9 === 0 ? 2 : 1);
}
// Title
svg.append("text")
.attr("x", 0)
.attr("y", -140)
.attr("text-anchor", "middle")
.attr("fill", ieeeColors.navy)
.attr("font-weight", "bold")
.text("Orientation Display");
// Fused orientation arrow
const fusedArrow = svg.append("g").attr("class", "fused-arrow");
fusedArrow.append("polygon")
.attr("points", "0,-100 -8,0 0,-10 8,0")
.attr("fill", ieeeColors.teal);
fusedArrow.append("circle")
.attr("cx", 0)
.attr("cy", 0)
.attr("r", 12)
.attr("fill", ieeeColors.teal);
// True orientation marker
const trueMarker = svg.append("circle")
.attr("r", 8)
.attr("fill", "none")
.attr("stroke", ieeeColors.navy)
.attr("stroke-width", 3)
.attr("stroke-dasharray", "4,4");
// Sensor markers
const sensorMarkers = {};
currentScenario.sensors.forEach(sensorId => {
sensorMarkers[sensorId] = svg.append("circle")
.attr("r", 5)
.attr("fill", sensorDefinitions[sensorId].color)
.attr("opacity", 0.6);
});
// Current value display
const valueDisplay = svg.append("text")
.attr("x", 0)
.attr("y", 30)
.attr("text-anchor", "middle")
.attr("fill", ieeeColors.navy)
.attr("font-size", 16);
function update() {
const angle = (simulationState.fusedValue * Math.PI / 180) - Math.PI / 2;
const trueAngle = (simulationState.trueValue * Math.PI / 180) - Math.PI / 2;
fusedArrow.attr("transform", `rotate(${simulationState.fusedValue})`);
trueMarker
.attr("cx", Math.cos(trueAngle) * 100)
.attr("cy", Math.sin(trueAngle) * 100);
Object.entries(sensorMarkers).forEach(([sensorId, marker]) => {
const reading = simulationState.sensorReadings[sensorId];
if (reading !== null && reading !== undefined) {
const sensorAngle = (reading * Math.PI / 180) - Math.PI / 2;
marker
.attr("cx", Math.cos(sensorAngle) * 90)
.attr("cy", Math.sin(sensorAngle) * 90)
.attr("opacity", 0.6);
} else {
marker.attr("opacity", 0);
}
});
valueDisplay.text(`${simulationState.fusedValue.toFixed(1)}deg`);
}
const interval = d3.interval(update, 50);
return svg.node();
}
// Main display layout
mainDisplay = {
return htl.html`
<div style="display: grid; grid-template-columns: 1fr 320px; gap: 20px; align-items: start;">
<div>
${timeSeriesPlot}
${errorDisplay}
</div>
<div>
${orientationDisplay}
<div style="
background: white;
padding: 15px;
border-radius: 8px;
margin-top: 15px;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
">
<h4 style="margin-top: 0; color: ${ieeeColors.navy};">Legend</h4>
<div style="font-size: 12px;">
<div style="display: flex; align-items: center; gap: 8px; margin: 5px 0;">
<div style="width: 20px; height: 20px; background: ${ieeeColors.teal}; border-radius: 50%;"></div>
<span>Fused Output (arrow)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px; margin: 5px 0;">
<div style="width: 20px; height: 20px; border: 3px dashed ${ieeeColors.navy}; border-radius: 50%;"></div>
<span>True Value</span>
</div>
${currentScenario.sensors.map(s => `
<div style="display: flex; align-items: center; gap: 8px; margin: 5px 0;">
<div style="width: 12px; height: 12px; background: ${sensorDefinitions[s].color}; border-radius: 50%; opacity: 0.6;"></div>
<span>${sensorDefinitions[s].shortName}</span>
</div>
`).join('')}
</div>
</div>
</div>
</div>
`;
}