Merge pull request #260 from DARC-e-V/refactor/digitaleuebertragung

Refactor/digitaleuebertragung
This commit is contained in:
Matthias Jung
2026-08-19 13:40:02 +02:00
committed by GitHub
37 changed files with 2266 additions and 463 deletions
+1 -6
View File
@@ -1,11 +1,6 @@
<html>
<script src="https://www.50ohm.de/assets/jquery/3.7.1/jquery.min.js"></script>
<script src="https://www.50ohm.de/assets/chartjs/4.4.1/chart.min.js"></script>
Das Ganze lässt sich leicht mathematisch beschreiben. Für die Summe eines Cosinus-Trägers mit einem anderen, um $\qty{90}{\degree}$ phasenverschobenen Cosinus-Träger gilt folgender Zusammenhang:<br><br>
<center>$ I(t)\cdot \cos\left(\omega t\right) + Q(t)\cdot \cos\left(\omega t + \qty{90}{\degree}\right)=A \cdot \cos\left(\omega t+\phi\right) $</center><br><br>
Es entsteht also ein neues Cosinussignal mit einer Amplitude von $A=\sqrt{I(t)^2 + Q(t)^2}$ und einer Phasenverschiebung von $ \phi = \operatorname{atan2}\left(Q(t),I(t)\right)$<br><br>
<table>
<tr>
<td>
@@ -30,7 +25,7 @@
</td>
</tr>
</table>
<br><br>
<br>
<canvas id="IQ" style="width:100%; min-height:100px; max-width:600px"></canvas>
$A=$<div id="ares" style="display:inline;">0</div> und $\phi=$<div id="phires" style="display:inline;">0</div>
+302
View File
@@ -0,0 +1,302 @@
<style>
.iq169-applet {
--iq169-i: #00aedf;
--iq169-q: #40c08c;
--iq169-sum: #ed7d71;
width: 100%;
max-width: 100%;
aspect-ratio: 2.25 / 1;
box-sizing: border-box;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: .45rem;
padding: .55rem;
overflow: visible;
font-size: clamp(10px, 1.15vw, 15px);
}
.iq169-applet .iq169-controls {
display: grid;
grid-template-columns: auto minmax(0, 1fr) 4.4em auto minmax(0, 1fr) 4.4em;
gap: .35em .55em;
align-items: center;
min-width: 0;
}
.iq169-applet label {
margin: 0;
font-weight: 650;
}
.iq169-applet input[type="range"] {
width: 100%;
min-width: 0;
margin: 0;
}
.iq169-applet output {
display: block;
min-width: 4.4em;
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.iq169-applet .iq169-content {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr);
gap: .5rem;
min-width: 0;
min-height: 0;
}
.iq169-applet .iq169-waves {
display: grid;
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 1rem;
min-width: 0;
min-height: 0;
}
.iq169-applet figure {
display: grid;
grid-template-rows: 1.45em minmax(0, 1fr);
min-width: 0;
min-height: 0;
margin: 0;
}
.iq169-applet figcaption {
position: relative;
z-index: 1;
margin: 0;
font-size: .84em;
font-weight: 650;
display: flex;
align-items: center;
justify-content: center;
line-height: 1.1;
background: var(--bs-body-bg, #fff);
}
.iq169-applet .iq169-constellation-panel {
grid-template-rows: 1.45em minmax(0, 1fr) auto;
}
.iq169-applet .iq169-canvas-wrap {
position: relative;
min-width: 0;
min-height: 0;
box-sizing: border-box;
overflow: hidden;
border: 1px solid rgba(120, 130, 140, .35);
border-radius: .2rem;
background: var(--bs-body-bg, #fff);
}
.iq169-applet canvas {
position: absolute;
inset: 0;
display: block;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
box-sizing: border-box;
}
.iq169-applet .iq169-result {
display: flex;
justify-content: center;
gap: .8em;
margin-top: .15rem;
font-size: .8em;
font-variant-numeric: tabular-nums;
}
</style>
<div class="iq169-applet" aria-label="I/Q-Modulation im Format 16 zu 9">
<div class="iq169-controls">
<label style="color:var(--iq169-i)"><var>I</var></label>
<input data-iq169-i type="range" min="-100" max="100" step="1" value="50" aria-label="I-Anteil">
<output data-iq169-i-output>50 %</output>
<label style="color:var(--iq169-q)"><var>Q</var></label>
<input data-iq169-q type="range" min="-100" max="100" step="1" value="50" aria-label="Q-Anteil">
<output data-iq169-q-output>50 %</output>
</div>
<div class="iq169-content">
<div class="iq169-waves">
<figure>
<figcaption>I- und Q-Anteil</figcaption>
<div class="iq169-canvas-wrap">
<canvas data-iq169-parts aria-label="I- und Q-Anteil"></canvas>
</div>
</figure>
<figure>
<figcaption>Summiertes I/Q-Signal</figcaption>
<div class="iq169-canvas-wrap">
<canvas data-iq169-sum aria-label="Summiertes I/Q-Signal"></canvas>
</div>
</figure>
</div>
<figure class="iq169-constellation-panel">
<figcaption>Konstellationsdiagramm</figcaption>
<div class="iq169-canvas-wrap">
<canvas data-iq169-constellation aria-label="Konstellationsdiagramm"></canvas>
</div>
<div class="iq169-result">
<span><var>A</var> = <strong data-iq169-a>71 %</strong></span>
<span><var>&phi;</var> = <strong data-iq169-phi>45°</strong></span>
</div>
</figure>
</div>
<script>
(function () {
const applet = document.currentScript.closest('.iq169-applet');
const iInput = applet.querySelector('[data-iq169-i]');
const qInput = applet.querySelector('[data-iq169-q]');
const partsCanvas = applet.querySelector('[data-iq169-parts]');
const sumCanvas = applet.querySelector('[data-iq169-sum]');
const constellationCanvas = applet.querySelector('[data-iq169-constellation]');
function color(name, fallback) {
return getComputedStyle(applet).getPropertyValue(name).trim() || fallback;
}
function setup(canvas) {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.max(1, Math.round(rect.width * dpr));
const pixelHeight = Math.max(1, Math.round(rect.height * dpr));
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, width: rect.width, height: rect.height };
}
function line(ctx, x0, y0, x1, y1) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
function drawWaveCanvas(canvas, series, yLimit) {
const drawing = setup(canvas);
const ctx = drawing.ctx;
const width = drawing.width;
const height = drawing.height;
const left = 7;
const right = 5;
const middle = height / 2;
const plotWidth = width - left - right;
const scale = height * .4 / yLimit;
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(100,110,120,.42)';
ctx.lineWidth = 1;
line(ctx, left, middle, width - right, middle);
for (let grid = 1; grid < 4; grid++) {
const x = left + plotWidth * grid / 4;
ctx.strokeStyle = 'rgba(120,130,140,.16)';
line(ctx, x, 2, x, height - 2);
}
series.forEach(function (item) {
ctx.strokeStyle = item.color;
ctx.lineWidth = item.width || 2;
ctx.lineJoin = 'round';
ctx.beginPath();
for (let pixel = 0; pixel <= Math.ceil(plotWidth); pixel++) {
const theta = 4 * Math.PI * pixel / plotWidth;
const x = left + pixel;
const y = middle - item.value(theta) * scale;
pixel === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
});
}
function drawConstellation(i, q) {
const drawing = setup(constellationCanvas);
const ctx = drawing.ctx;
const width = drawing.width;
const height = drawing.height;
const cx = width / 2;
const cy = height / 2;
const unit = Math.min(width, height) * .4 / 110;
const px = cx + i * unit;
const py = cy - q * unit;
const textColor = getComputedStyle(applet).color || '#263238';
const sumColor = color('--iq169-sum', '#ed7d71');
ctx.clearRect(0, 0, width, height);
ctx.strokeStyle = 'rgba(100,110,120,.55)';
ctx.lineWidth = 1;
line(ctx, 5, cy, width - 5, cy);
line(ctx, cx, 5, cx, height - 5);
ctx.strokeStyle = 'rgba(120,130,140,.25)';
ctx.beginPath();
ctx.arc(cx, cy, 100 * unit, 0, 2 * Math.PI);
ctx.stroke();
const axisFontSize = Math.max(9, width * .04);
ctx.fillStyle = textColor;
ctx.font = axisFontSize + 'px system-ui, sans-serif';
ctx.textBaseline = 'top';
ctx.fillText('Q', cx + 6, 6);
ctx.textBaseline = 'alphabetic';
ctx.fillText('I', width - axisFontSize, cy - 7);
ctx.setLineDash([4, 3]);
ctx.strokeStyle = 'rgba(120,130,140,.65)';
line(ctx, px, cy, px, py);
line(ctx, cx, py, px, py);
ctx.setLineDash([]);
ctx.strokeStyle = sumColor;
ctx.lineWidth = 2.5;
line(ctx, cx, cy, px, py);
ctx.fillStyle = sumColor;
ctx.beginPath();
ctx.arc(px, py, Math.max(4, width * .018), 0, 2 * Math.PI);
ctx.fill();
}
function draw() {
const i = Number(iInput.value);
const q = Number(qInput.value);
const iColor = color('--iq169-i', '#00aedf');
const qColor = color('--iq169-q', '#40c08c');
const sumColor = color('--iq169-sum', '#ed7d71');
drawWaveCanvas(partsCanvas, [
{ color: iColor, value: function (theta) { return i * Math.cos(theta); } },
{ color: qColor, value: function (theta) { return q * Math.cos(theta + Math.PI / 2); } }
], 100);
drawWaveCanvas(sumCanvas, [
{ color: sumColor, width: 2.5, value: function (theta) {
return i * Math.cos(theta) + q * Math.cos(theta + Math.PI / 2);
} }
], Math.SQRT2 * 100);
drawConstellation(i, q);
applet.querySelector('[data-iq169-i-output]').textContent = i + ' %';
applet.querySelector('[data-iq169-q-output]').textContent = q + ' %';
applet.querySelector('[data-iq169-a]').textContent = Math.round(Math.hypot(i, q)) + ' %';
applet.querySelector('[data-iq169-phi]').textContent = Math.round(Math.atan2(q, i) * 180 / Math.PI) + '°';
}
iInput.addEventListener('input', draw);
qInput.addEventListener('input', draw);
new ResizeObserver(draw).observe(applet);
draw();
})();
</script>
</div>
+339
View File
@@ -0,0 +1,339 @@
<style>
.iq-pointer-applet {
--iq-i-color: #168aad;
--iq-q-color: #2a9d6f;
--iq-vector-color: #d85a30;
margin: 1rem 0;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.iq-pointer-applet .iq-pointer-controls {
display: grid;
grid-template-columns: minmax(8rem, auto) minmax(10rem, 1fr) 4.5rem;
gap: .65rem .8rem;
align-items: center;
margin-bottom: 1rem;
}
.iq-pointer-applet .iq-pointer-controls label {
margin: 0;
font-weight: 600;
}
.iq-pointer-applet .iq-pointer-controls output {
text-align: right;
font-variant-numeric: tabular-nums;
}
.iq-pointer-applet input[type="range"] { width: 100%; }
.iq-pointer-applet .iq-pointer-toggle {
grid-column: 1 / -1;
justify-self: start;
min-width: 14rem;
}
.iq-pointer-applet figure { margin: 0; }
.iq-pointer-applet figcaption {
margin-bottom: .4rem;
font-size: .9rem;
font-weight: 600;
}
.iq-pointer-applet canvas {
display: block;
width: 100%;
aspect-ratio: 1.32 / 1;
border: 1px solid var(--bs-border-color, #d8dde2);
border-radius: .35rem;
background: var(--bs-body-bg, #fff);
}
.iq-pointer-applet .iq-pointer-values {
display: flex;
flex-wrap: wrap;
gap: .4rem 1.25rem;
margin-top: .9rem;
padding: .65rem .8rem;
border-radius: .35rem;
background: var(--bs-secondary-bg, #f3f5f7);
font-variant-numeric: tabular-nums;
}
.iq-pointer-applet .iq-pointer-note {
margin: .8rem 0 0;
font-size: .9rem;
}
@media (min-width: 800px) {
.course .iq-pointer-applet {
width: 59.3%;
}
}
@media (max-width: 650px) {
.iq-pointer-applet .iq-pointer-controls {
grid-template-columns: 1fr 4rem;
}
.iq-pointer-applet .iq-pointer-controls label,
.iq-pointer-applet .iq-pointer-toggle { grid-column: 1 / -1; }
.iq-pointer-applet canvas { aspect-ratio: 1 / 1.08; }
}
</style>
<section class="iq-pointer-applet" aria-label="Zeiger mit Sinus- und Cosinusprojektion">
<div class="iq-pointer-controls">
<label>Amplitude <var>A</var></label>
<input data-iq-amplitude type="range" min="0" max="1" step="0.01" value="0.75"
aria-label="Amplitude A">
<output data-iq-amplitude-output>0,75</output>
<label>Phase <var>&phi;</var></label>
<input data-iq-phase type="range" min="-180" max="180" step="1" value="0"
aria-label="Phase Phi">
<output data-iq-phase-output>0&deg;</output>
<button data-iq-toggle type="button" class="btn btn-outline-primary btn-sm iq-pointer-toggle"
aria-pressed="false">Zeiger drehen lassen</button>
</div>
<figure>
<figcaption data-iq-caption>Anfangszustand des Symbols bei <var>t</var> = 0</figcaption>
<canvas data-iq-diagram aria-label="Zeiger sowie daraus entstehende Sinus- und Cosinusschwingung"></canvas>
</figure>
<div class="iq-pointer-values" aria-live="polite">
<span><var>I</var> = <strong data-iq-i-value>0,75</strong></span>
<span><var>Q</var> = <strong data-iq-q-value>0,00</strong></span>
<span><var>A</var> = <strong data-iq-a-value>0,75</strong></span>
<span><var>&phi;</var> = <strong data-iq-phi-value>0&deg;</strong></span>
</div>
<p class="iq-pointer-note" data-iq-note>
Bei <var>t</var> = 0 ist die waagerechte Projektion der I-Anteil und die senkrechte Projektion der Q-Anteil.
</p>
<script>
(function () {
const applet = document.currentScript.closest('.iq-pointer-applet');
const amplitudeInput = applet.querySelector('[data-iq-amplitude]');
const phaseInput = applet.querySelector('[data-iq-phase]');
const toggleButton = applet.querySelector('[data-iq-toggle]');
const caption = applet.querySelector('[data-iq-caption]');
const note = applet.querySelector('[data-iq-note]');
const canvas = applet.querySelector('[data-iq-diagram]');
let running = false;
let timePhase = 0;
let previousTime = performance.now();
function color(name, fallback) {
return getComputedStyle(applet).getPropertyValue(name).trim() || fallback;
}
function formatNumber(value) {
const normalized = Math.abs(value) < .005 ? 0 : value;
return normalized.toLocaleString('de-DE', {
minimumFractionDigits: 2,
maximumFractionDigits: 2
});
}
function setupCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.round(rect.width * dpr);
const pixelHeight = Math.round(rect.height * dpr);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx, width: rect.width, height: rect.height };
}
function line(ctx, x0, y0, x1, y1) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
function arrow(ctx, x0, y0, x1, y1, strokeColor, width) {
const angle = Math.atan2(y1 - y0, x1 - x0);
const head = 9;
ctx.strokeStyle = strokeColor;
ctx.fillStyle = strokeColor;
ctx.lineWidth = width;
ctx.lineCap = 'round';
line(ctx, x0, y0, x1, y1);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - head * Math.cos(angle - Math.PI / 6), y1 - head * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x1 - head * Math.cos(angle + Math.PI / 6), y1 - head * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function draw(amplitude, phase) {
const { ctx, width, height } = setupCanvas();
const compact = width < 540;
const cx = width * (compact ? .27 : .275);
const cy = height * (compact ? .245 : .29);
const radius = Math.min(width * .19, height * (compact ? .145 : .205));
const rightStart = cx + radius + (compact ? 16 : 28);
const rightEnd = width - 18;
const bottomStart = cy + radius + (compact ? 25 : 32);
const bottomEnd = height - 24;
const angle = phase + timePhase;
const tipX = cx + radius * amplitude * Math.cos(angle);
const tipY = cy - radius * amplitude * Math.sin(angle);
const progress = (((timePhase % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI)) / (2 * Math.PI);
const sineCursorX = rightStart + (rightEnd - rightStart) * progress;
const cosineCursorY = bottomStart + (bottomEnd - bottomStart) * progress;
const textColor = getComputedStyle(applet).color || '#263238';
const gridColor = 'rgba(120,130,140,.38)';
const axisColor = 'rgba(80,90,100,.75)';
const iColor = color('--iq-i-color', '#168aad');
const qColor = color('--iq-q-color', '#2a9d6f');
const vectorColor = color('--iq-vector-color', '#d85a30');
ctx.clearRect(0, 0, width, height);
ctx.font = (compact ? '11px' : '13px') + ' system-ui, sans-serif';
ctx.fillStyle = textColor;
// I/Q-Ebene und Kreisbahn
ctx.strokeStyle = gridColor;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, radius, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = axisColor;
arrow(ctx, cx - radius - 10, cy, cx + radius + 14, cy, axisColor, 1);
arrow(ctx, cx, cy + radius + 10, cx, cy - radius - 14, axisColor, 1);
ctx.fillText('I', cx + radius + 16, cy - 7);
ctx.fillText('Q', cx + 7, cy - radius - 13);
// Phasenwinkel nur beim Anfangszustand
if (!running && Math.abs(phase) > .02) {
const angleRadius = radius * .27;
ctx.strokeStyle = vectorColor;
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.arc(cx, cy, angleRadius, 0, -phase, phase > 0);
ctx.stroke();
ctx.fillStyle = vectorColor;
ctx.font = 'italic ' + (compact ? '12px' : '14px') + ' system-ui, sans-serif';
ctx.fillText('φ', cx + angleRadius * 1.35 * Math.cos(phase / 2), cy - angleRadius * 1.35 * Math.sin(phase / 2));
}
// Rotierender Zeiger
if (amplitude > 0) arrow(ctx, cx, cy, tipX, tipY, vectorColor, 3);
ctx.fillStyle = vectorColor;
ctx.beginPath(); ctx.arc(tipX, tipY, 4.5, 0, 2 * Math.PI); ctx.fill();
// Sinusschwingung rechts: senkrechte Projektion
ctx.strokeStyle = axisColor;
arrow(ctx, rightStart, cy, rightEnd, cy, axisColor, 1);
line(ctx, rightStart, cy + radius + 8, rightStart, cy - radius - 8);
ctx.fillStyle = qColor;
ctx.font = '600 ' + (compact ? '10px' : '12px') + ' system-ui, sans-serif';
ctx.fillText('Q: Sinus', rightStart, cy - radius - 13);
ctx.strokeStyle = qColor;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let p = 0; p <= Math.ceil(rightEnd - rightStart); p++) {
const theta = 2 * Math.PI * p / (rightEnd - rightStart);
const x = rightStart + p;
const y = cy - radius * amplitude * Math.sin(phase + theta);
p === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// Cosinusschwingung nach unten: waagerechte Projektion
ctx.strokeStyle = axisColor;
arrow(ctx, cx, bottomStart, cx, bottomEnd, axisColor, 1);
line(ctx, cx - radius - 8, bottomStart, cx + radius + 8, bottomStart);
ctx.fillStyle = iColor;
ctx.font = '600 ' + (compact ? '10px' : '12px') + ' system-ui, sans-serif';
ctx.fillText('I: Cosinus', cx + radius + 13, bottomStart + 4);
ctx.strokeStyle = iColor;
ctx.lineWidth = 2.5;
ctx.beginPath();
for (let p = 0; p <= Math.ceil(bottomEnd - bottomStart); p++) {
const theta = 2 * Math.PI * p / (bottomEnd - bottomStart);
const x = cx + radius * amplitude * Math.cos(phase + theta);
const y = bottomStart + p;
p === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.stroke();
// Projektionen des aktuellen Augenblickswerts
ctx.setLineDash([5, 4]);
ctx.lineWidth = 1.5;
ctx.strokeStyle = qColor;
line(ctx, tipX, tipY, sineCursorX, tipY);
ctx.strokeStyle = iColor;
line(ctx, tipX, tipY, tipX, cosineCursorY);
ctx.setLineDash([]);
ctx.fillStyle = qColor;
ctx.beginPath(); ctx.arc(sineCursorX, tipY, 4, 0, 2 * Math.PI); ctx.fill();
ctx.fillStyle = iColor;
ctx.beginPath(); ctx.arc(tipX, cosineCursorY, 4, 0, 2 * Math.PI); ctx.fill();
}
function update() {
const amplitude = Number(amplitudeInput.value);
const phaseDegrees = Number(phaseInput.value);
const phase = phaseDegrees * Math.PI / 180;
const i = amplitude * Math.cos(phase);
const q = amplitude * Math.sin(phase);
draw(amplitude, phase);
applet.querySelector('[data-iq-amplitude-output]').textContent = formatNumber(amplitude);
applet.querySelector('[data-iq-phase-output]').textContent = phaseDegrees + '°';
applet.querySelector('[data-iq-i-value]').textContent = formatNumber(i);
applet.querySelector('[data-iq-q-value]').textContent = formatNumber(q);
applet.querySelector('[data-iq-a-value]').textContent = formatNumber(amplitude);
applet.querySelector('[data-iq-phi-value]').textContent = phaseDegrees + '°';
}
function setRunning(enabled) {
running = enabled;
timePhase = 0;
toggleButton.setAttribute('aria-pressed', String(enabled));
toggleButton.textContent = enabled ? 'Anfangszustand anzeigen' : 'Zeiger drehen lassen';
toggleButton.classList.toggle('btn-primary', enabled);
toggleButton.classList.toggle('btn-outline-primary', !enabled);
caption.innerHTML = enabled
? 'Laufender Zeiger und seine beiden Projektionen'
: 'Anfangszustand des Symbols bei <var>t</var> = 0';
note.innerHTML = enabled
? 'Die gestrichelten Linien verbinden die Zeigerspitze mit den momentanen Werten beider Schwingungen.'
: 'Bei <var>t</var> = 0 ist die waagerechte Projektion der I-Anteil und die senkrechte Projektion der Q-Anteil.';
update();
}
function animate(now) {
const delta = Math.min((now - previousTime) / 1000, .1);
previousTime = now;
if (running) timePhase = (timePhase + delta * Math.PI * .8) % (2 * Math.PI);
update();
requestAnimationFrame(animate);
}
amplitudeInput.addEventListener('input', update);
phaseInput.addEventListener('input', update);
toggleButton.addEventListener('click', function () { setRunning(!running); });
new ResizeObserver(update).observe(applet);
document.addEventListener('visibilitychange', function () { previousTime = performance.now(); });
requestAnimationFrame(animate);
})();
</script>
</section>
+364
View File
@@ -0,0 +1,364 @@
<style>
.qpsk-applet {
--qpsk-primary: #009fda;
--qpsk-current: #d85a30;
--qpsk-received: #7451b9;
margin: 1rem 0;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
overflow: hidden;
}
.qpsk-applet .qpsk-controls {
display: flex;
flex-wrap: wrap;
gap: .65rem;
align-items: center;
margin-bottom: .8rem;
}
.qpsk-applet .qpsk-noise-control {
display: grid;
grid-template-columns: 1fr auto;
gap: .25rem .65rem;
align-items: center;
width: 100%;
}
.qpsk-applet input[type="range"] {
grid-column: 1 / -1;
width: 100%;
min-width: 0;
}
.qpsk-applet output {
text-align: right;
font-variant-numeric: tabular-nums;
}
.qpsk-applet .qpsk-stream {
display: flex;
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
contain: inline-size;
gap: .3rem;
overflow-x: auto;
padding: .45rem .15rem .65rem;
margin-bottom: .7rem;
scrollbar-width: thin;
}
.qpsk-applet .qpsk-symbol {
flex: 0 0 auto;
min-width: 2.25rem;
padding: .28rem .38rem;
border: 1px solid var(--bs-border-color, #ccd2d8);
border-radius: .3rem;
text-align: center;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 650;
transition: background .15s, border-color .15s, color .15s;
}
.qpsk-applet .qpsk-symbol.sent {
background: var(--bs-secondary-bg, #edf0f2);
color: var(--bs-secondary-color, #687078);
}
.qpsk-applet .qpsk-symbol.current {
border-color: var(--qpsk-current);
background: var(--qpsk-current);
color: #fff;
}
.qpsk-applet figure { margin: 0; }
.qpsk-applet canvas {
display: block;
width: 100%;
aspect-ratio: 1 / 1;
border: 1px solid var(--bs-border-color, #d8dde2);
border-radius: .35rem;
background: var(--bs-body-bg, #fff);
}
@media (min-width: 800px) {
.course .qpsk-applet { width: 59.3%; }
.course .margin .qpsk-applet { width: 100%; }
}
</style>
<section class="qpsk-applet" aria-label="QPSK-Übertragung mit Rauschen">
<div class="qpsk-controls">
<button data-qpsk-start type="button" class="btn btn-primary btn-sm">Übertragung starten</button>
<button data-qpsk-new type="button" class="btn btn-outline-secondary btn-sm">Neuer Bitstrom</button>
</div>
<div class="qpsk-noise-control">
<label for="qpsk-noise">Rauschen</label>
<input data-qpsk-noise id="qpsk-noise" type="range" min="0" max="60" step="1" value="18">
<output data-qpsk-noise-output>18 %</output>
</div>
<div data-qpsk-stream class="qpsk-stream" aria-label="Zu übertragender Bitstrom"></div>
<figure>
<canvas data-qpsk-canvas aria-label="QPSK-Konstellationsdiagramm"></canvas>
</figure>
<script>
(function () {
const applet = document.currentScript.closest('.qpsk-applet');
const canvas = applet.querySelector('[data-qpsk-canvas]');
const startButton = applet.querySelector('[data-qpsk-start]');
const newButton = applet.querySelector('[data-qpsk-new]');
const noiseInput = applet.querySelector('[data-qpsk-noise]');
const streamElement = applet.querySelector('[data-qpsk-stream]');
const mapping = {
'11': { angle: 45, i: 1, q: 1 },
'01': { angle: 135, i: -1, q: 1 },
'00': { angle: 225, i: -1, q: -1 },
'10': { angle: 315, i: 1, q: -1 }
};
const symbolOrder = ['11', '01', '00', '10'];
const pointColors = {
'11': '#d85a30',
'01': '#2a9d6f',
'00': '#7451b9',
'10': '#168aad'
};
let symbols = [];
let receivedPoints = [];
let currentIndex = -1;
let timer = 0;
let running = false;
function makeBitstream() {
symbols = [];
for (let repetition = 0; repetition < 8; repetition++) {
const group = symbolOrder.slice();
for (let i = group.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
const temporary = group[i];
group[i] = group[j];
group[j] = temporary;
}
symbols.push.apply(symbols, group);
}
receivedPoints = [];
currentIndex = -1;
renderStream(true);
draw();
}
function renderStream(rebuild) {
if (rebuild) {
streamElement.replaceChildren();
symbols.forEach(function (bits) {
const span = document.createElement('span');
span.className = 'qpsk-symbol';
span.textContent = bits;
streamElement.appendChild(span);
});
streamElement.scrollLeft = 0;
}
Array.from(streamElement.children).forEach(function (span, index) {
span.classList.toggle('sent', index < currentIndex);
span.classList.toggle('current', index === currentIndex);
});
const current = streamElement.children[currentIndex];
if (current) {
const streamRect = streamElement.getBoundingClientRect();
const currentRect = current.getBoundingClientRect();
const centeredPosition = streamElement.scrollLeft
+ currentRect.left - streamRect.left
- (streamElement.clientWidth - currentRect.width) / 2;
streamElement.scrollTo({
left: Math.max(0, centeredPosition),
behavior: 'smooth'
});
}
}
function gaussianNoise() {
let u = 0;
let v = 0;
while (u === 0) u = Math.random();
while (v === 0) v = Math.random();
return Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
}
function transmitNext() {
currentIndex += 1;
if (currentIndex >= symbols.length) {
stopTransmission(true);
return;
}
const bits = symbols[currentIndex];
const target = mapping[bits];
const sigma = Number(noiseInput.value) / 100;
const receivedI = target.i + gaussianNoise() * sigma;
const receivedQ = target.q + gaussianNoise() * sigma;
receivedPoints.push({
i: receivedI,
q: receivedQ,
bits: bits
});
renderStream(false);
draw();
}
function startTransmission() {
if (running) return;
if (currentIndex >= symbols.length - 1) makeBitstream();
running = true;
startButton.disabled = true;
startButton.textContent = 'Übertragung läuft …';
newButton.disabled = true;
transmitNext();
timer = window.setInterval(transmitNext, 420);
}
function stopTransmission(finished) {
running = false;
window.clearInterval(timer);
startButton.disabled = false;
newButton.disabled = false;
startButton.textContent = finished ? 'Erneut übertragen' : 'Übertragung starten';
}
function setupCanvas() {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
const pixelWidth = Math.round(rect.width * dpr);
const pixelHeight = Math.round(rect.height * dpr);
if (canvas.width !== pixelWidth || canvas.height !== pixelHeight) {
canvas.width = pixelWidth;
canvas.height = pixelHeight;
}
const ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
return { ctx: ctx, width: rect.width, height: rect.height };
}
function line(ctx, x0, y0, x1, y1) {
ctx.beginPath();
ctx.moveTo(x0, y0);
ctx.lineTo(x1, y1);
ctx.stroke();
}
function drawCross(ctx, x, y, size, strokeColor) {
ctx.strokeStyle = strokeColor;
ctx.lineWidth = 2;
line(ctx, x - size, y - size, x + size, y + size);
line(ctx, x - size, y + size, x + size, y - size);
}
function drawArrow(ctx, x0, y0, x1, y1, strokeColor) {
const angle = Math.atan2(y1 - y0, x1 - x0);
const head = 9;
ctx.strokeStyle = strokeColor;
ctx.fillStyle = strokeColor;
ctx.lineWidth = 2.5;
line(ctx, x0, y0, x1, y1);
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x1 - head * Math.cos(angle - Math.PI / 6), y1 - head * Math.sin(angle - Math.PI / 6));
ctx.lineTo(x1 - head * Math.cos(angle + Math.PI / 6), y1 - head * Math.sin(angle + Math.PI / 6));
ctx.closePath();
ctx.fill();
}
function draw() {
const setup = setupCanvas();
const ctx = setup.ctx;
const width = setup.width;
const height = setup.height;
const cx = width / 2;
const cy = height / 2;
const unit = Math.min(width, height) * .265;
const limit = 1.65;
const textColor = getComputedStyle(applet).color || '#263238';
const axisColor = 'rgba(90,100,110,.72)';
function x(i) { return cx + i * unit; }
function y(q) { return cy - q * unit; }
ctx.clearRect(0, 0, width, height);
// Entscheidungsbereiche
ctx.fillStyle = 'rgba(216,90,48,.035)';
ctx.fillRect(cx, 0, width - cx, cy);
ctx.fillStyle = 'rgba(42,157,111,.035)';
ctx.fillRect(0, 0, cx, cy);
ctx.fillStyle = 'rgba(116,81,185,.035)';
ctx.fillRect(0, cy, cx, height - cy);
ctx.fillStyle = 'rgba(22,138,173,.035)';
ctx.fillRect(cx, cy, width - cx, height - cy);
// Kreis und Achsen
ctx.strokeStyle = 'rgba(120,130,140,.32)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, Math.SQRT2 * unit, 0, 2 * Math.PI);
ctx.stroke();
ctx.strokeStyle = axisColor;
line(ctx, x(-limit), cy, x(limit), cy);
line(ctx, cx, y(-limit), cx, y(limit));
ctx.fillStyle = textColor;
ctx.font = '13px system-ui, sans-serif';
ctx.fillText('I', x(limit) - 7, cy - 9);
ctx.fillText('Q', cx + 9, y(limit) + 11);
// Ideale Symbole und Beschriftungen
symbolOrder.forEach(function (bits) {
const symbol = mapping[bits];
drawCross(ctx, x(symbol.i), y(symbol.q), 6, pointColors[bits]);
ctx.fillStyle = textColor;
ctx.font = '600 13px ui-monospace, monospace';
ctx.textAlign = symbol.i > 0 ? 'left' : 'right';
ctx.fillText(bits, x(symbol.i) + (symbol.i > 0 ? 11 : -11), y(symbol.q) - 9);
});
ctx.textAlign = 'left';
// Empfangspunkte
receivedPoints.forEach(function (point, index) {
ctx.globalAlpha = index === receivedPoints.length - 1 ? 1 : .58;
ctx.fillStyle = pointColors[point.bits];
ctx.beginPath();
ctx.arc(x(point.i), y(point.q), index === receivedPoints.length - 1 ? 5 : 3.2, 0, 2 * Math.PI);
ctx.fill();
});
ctx.globalAlpha = 1;
// Zeiger zum aktuell empfangenen, verrauschten Signalpunkt
if (currentIndex >= 0 && receivedPoints.length > 0) {
const current = receivedPoints[receivedPoints.length - 1];
drawArrow(ctx, cx, cy, x(current.i), y(current.q), pointColors[current.bits]);
}
}
startButton.addEventListener('click', startTransmission);
newButton.addEventListener('click', function () {
stopTransmission(false);
makeBitstream();
});
noiseInput.addEventListener('input', function () {
applet.querySelector('[data-qpsk-noise-output]').textContent = noiseInput.value + ' %';
});
new ResizeObserver(draw).observe(applet);
makeBitstream();
})();
</script>
</section>