// Enhanced Tuning V3.1.1 – Analog Scale Integration, AM Scanner, Full Admin UI
// -------------------------------------------------------------------------------------
/* global document, socket, window, WebSocket, Event, addIconToPluginPanel */
(() => {
let pluginConfig = {
LAYOUT_STYLE: 'modern',
HIDE_ALL_BUTTONS: false,
SHOW_LOOP_BUTTON: true,
SHOW_BAND_RANGE: true,
HIDE_DECIMAL_FOR_HF: false,
HIDE_DECIMAL_HF_THRESHOLD: 30,
ENABLE_TUNE_STEP_FEATURE: true,
TUNE_STEP_TIMEOUT_SECONDS: 20,
ENABLED_BANDS: ['FM', 'OIRT', 'SW', 'MW', 'LW'],
TUNING_STANDARD: 'international',
ENABLE_MW_STEP_TOGGLE: true,
ENABLE_FREQUENCY_MEMORY: true,
ENABLE_SMART_KHZ_INPUT: true,
ENABLE_AM_BW: true,
FIRMWARE_TYPE: 'FM-DX-Tuner',
ENABLE_DEFAULT_AM_BW: false,
DEFAULT_AM_BW_VALUE: '56000',
overrideServerTuningLimit: true,
fmLower: 64.0, fmUpper: 108.0,
amLower: 0.144, amUpper: 30.0,
ENABLE_AM_SCANNER: false,
ENABLE_ANALOG_SCALE: true,
ANALOG_SCALE_AUTOSTART: false,
SHOW_TUNING_KNOB: true,
ANALOG_SCALE_BRIGHTNESS: 1.5,
ENABLE_VU_METER: true,
VU_METER_GAIN_FM: 1.5,
VU_METER_GAIN_AM: 1.5,
VU_METER_MODE: 'RMS',
ENABLE_MAGIC_EYE: true,
ENABLE_PS_SCALE: true,
ENABLE_SPECTRUM_OVERLAY: true,
ENABLE_SW_STATIONS_SCALE: false,
customMainBands: {
'AM_SUPER': { name: 'AM', tune: 1.000, start: 0.144, end: 27.0 },
'FM': { name: 'FM', tune: 87.500, start: 87.5, end: 108.0 },
'OIRT': { name: 'OIRT', tune: 65.900, start: 65.9, end: 74.0 },
'SW': { name: 'SW', tune: 9.400, start: 1.710, end: 27.0 },
'MW': { name: 'MW', tune: 0.504, start: 0.504, end: 1.701 },
'LW': { name: 'LW', tune: 0.144, start: 0.144, end: 0.351 }
},
customSwBands: {
'160m': { tune: 1.8, start: 1.8, end: 2.0 }, '120m': { tune: 2.3, start: 2.3, end: 2.5 },
'90m': { tune: 3.2, start: 3.2, end: 3.4 }, '75m': { tune: 3.9, start: 3.9, end: 4.0 },
'60m': { tune: 4.75, start: 4.75, end: 5.06 }, '49m': { tune: 5.9, start: 5.9, end: 6.2 },
'41m': { tune: 7.2, start: 7.2, end: 7.6 }, '31m': { tune: 9.4, start: 9.4, end: 9.9 },
'25m': { tune: 11.6, start: 11.6, end: 12.1 }, '22m': { tune: 13.57, start: 13.57, end: 13.87 },
'19m': { tune: 15.1, start: 15.1, end: 15.83 }, '16m': { tune: 17.48, start: 17.48, end: 17.9 },
'15m': { tune: 18.9, start: 18.9, end: 19.02 }, '13m': { tune: 21.45, start: 21.45, end: 21.85 },
'11m': { tune: 25.67, start: 25.67, end: 26.1 }
}
};
let isAdmin = false;
let isLocalScannerRunning = false;
let isInternalScannerTuning = false;
let localScannerTimer = null;
let localScannerMode = null;
let localScannerDir = 'up';
let _startLocalScanner = () => {};
let _stopLocalScanner = () => {};
const getGlobalCurrentFrequencyInMHz = () => {
const el = document.getElementById('data-frequency');
if (!el) return 0;
const freqText = el.textContent;
let freqValue = parseFloat(freqText);
if (freqText.toLowerCase().includes('khz')) freqValue /= 1000;
return freqValue;
};
const safeTuneToFrequency = (frequencyInMHz) => {
if (typeof socket !== 'undefined' && socket && socket.readyState === WebSocket.OPEN) {
if (isLocalScannerRunning) isInternalScannerTuning = true;
socket.send("T" + Math.round(parseFloat(frequencyInMHz) * 1000));
isInternalScannerTuning = false;
}
};
const originalFillRect = CanvasRenderingContext2D.prototype.fillRect;
CanvasRenderingContext2D.prototype.fillRect = function(x, y, w, h) {
if (this.canvas && this.canvas.id === 'sdr-graph' && AnalogScaleEngine.isVisible) {
if (y === 9) return;
}
originalFillRect.call(this, x, y, w, h);
};
// =========================================================================
// DYNAMIC ANALOG SCALE ENGINE
// =========================================================================
const AnalogScaleEngine = {
isVisible: false,
isFading: false,
wrap: null,
vuCanvas: null,
scaleCanvas: null,
knobCanvas: null,
rafId: null,
lastRenderTime: 0,
lastKnobState: "",
outerVelocity: 0,
innerVelocity: 0,
FRICTION: 0.90,
lastDragTime: 0,
animFreq: null,
currentFreq: null,
dragFreq: null,
lastTuneTime: 0,
finalTuneTimer: null,
SMOOTHING: 0.08,
stationScrollY: 0,
targetStationScrollY: 0,
clearHovered: false,
clearClickedTime: 0,
isDraggingScale: false,
isDraggingOuterKnob: false,
isDraggingInnerKnob: false,
lastDragAngle: 0,
accumulatedOuterAngle: 0,
outerKnobAngle: 0,
innerKnobAngle: 0,
isFineTuningMode: false,
knobDragMoved: false,
mX: 0, mY: 0, mW: 0, mH: 0,
knobX: 0, knobY: 0, knobOuterR: 0, knobInnerR: 0,
currentMin: 87.5,
currentMax: 108.0,
currentUnit: 'MHz',
currentName: 'FM',
currentKey: 'FM',
_colorCache: {},
_btnAdding: false,
_hasAutostarted: false,
_resizeObserver: null,
renderedStations:[],
swStations:[],
lastFetchedSwBand: null,
hoveredFreq: null,
scanHovered: false,
scanClickedTime: 0,
getBrightness() {
return parseFloat(localStorage.getItem('et_user_scale_brightness')) || pluginConfig.ANALOG_SCALE_BRIGHTNESS;
},
isVuEnabled() {
return pluginConfig.ENABLE_VU_METER && localStorage.getItem('et_user_vu_enabled') !== 'false';
},
isPsEnabled() {
return pluginConfig.ENABLE_PS_SCALE && localStorage.getItem('et_user_ps_scale') !== 'false';
},
isSpectrumEnabled() {
return pluginConfig.ENABLE_SPECTRUM_OVERLAY && localStorage.getItem('et_user_spectrum_overlay') !== 'false';
},
isSwScaleEnabled() {
return pluginConfig.ENABLE_SW_STATIONS_SCALE && localStorage.getItem('et_user_sw_scale') !== 'false';
},
async getServerLocation() {
// Hvis vi allerede har hentet dem, bruk dem direkte
if (this.serverLat !== undefined && this.serverLon !== undefined) {
return { lat: this.serverLat, lon: this.serverLon };
}
try {
// Henter statiske serverdata fra FM-DX
const res = await fetch('/static_data');
if (res.ok) {
const data = await res.json();
// FIX: Bruker qthLatitude/qthLongitude fra din JSON (med lat/lon som fallback)
let apiLat = data.qthLatitude || data.lat;
let apiLon = data.qthLongitude || data.lon;
if (apiLat !== undefined && apiLon !== undefined && apiLat !== 0 && apiLon !== 0) {
this.serverLat = parseFloat(apiLat);
this.serverLon = parseFloat(apiLon);
// Lagre i localStorage
localStorage.setItem('rx_lat', this.serverLat);
localStorage.setItem('rx_lon', this.serverLon);
return { lat: this.serverLat, lon: this.serverLon };
}
}
} catch(e) {
console.warn("[Enhanced Tuning] Kunne ikke hente koordinater fra /static_data", e);
}
// Fallback
let lat = localStorage.getItem('rx_lat');
let lon = localStorage.getItem('rx_lon');
if (lat && lon && lat !== '0' && lon !== '0') {
this.serverLat = parseFloat(lat);
this.serverLon = parseFloat(lon);
return { lat: this.serverLat, lon: this.serverLon };
}
return { lat: 0, lon: 0 };
},
async fetchSwStations(startMhz, endMhz) {
try {
// Hent de ekte koordinatene fra den nye funksjonen vår
const coords = await this.getServerLocation();
const fStart = Math.round(startMhz * 1000);
const fEnd = Math.round(endMhz * 1000);
// Sender de nøyaktige server-koordinatene til AM Station Info API-et
const res = await fetch(`/aoki-api-proxy?lat=${coords.lat}&lon=${coords.lon}&freqStart=${fStart}&freqEnd=${fEnd}`);
if (!res.ok) return;
const data = await res.json();
if (data && data.status === 'success') {
this.swStations = data.stations ||[];
if (this.isVisible && this.scaleCanvas) {
this.drawScale(this.scaleCanvas, this.animFreq !== null ? this.animFreq : this.currentFreq);
}
}
} catch (e) {
console.error("[Enhanced Tuning] Error fetching SW stations:", e);
}
},
async fetchMwLwFavorites() {
try {
const res = await fetch('/AM-Station_info/favorites');
if (res.ok) {
this.mwLwFavorites = await res.json();
if (this.isVisible && this.scaleCanvas) {
this.drawScale(this.scaleCanvas, this.animFreq !== null ? this.animFreq : this.currentFreq);
}
}
} catch (e) {
console.warn("[Enhanced Tuning] Error fetching MW/LW favorites:", e);
}
},
applyScaleLayout() {
if (!this.wrap) return;
const scaleDiv = document.getElementById("et-analog-scale-container");
const knobDiv = document.getElementById("et-analog-knob-container");
const vuDiv = document.getElementById("et-analog-vu-container");
if (!scaleDiv || !knobDiv || !vuDiv) return;
const showKnob = pluginConfig.SHOW_TUNING_KNOB !== false;
const showVu = this.isVuEnabled();
knobDiv.style.display = showKnob ? "block" : "none";
vuDiv.style.display = showVu ? "block" : "none";
if (showKnob && showVu) {
scaleDiv.style.flex = "0 0 58%";
knobDiv.style.flex = "0 0 12%";
vuDiv.style.flex = "0 0 30%";
vuDiv.style.marginLeft = "0px";
} else if (showKnob && !showVu) {
scaleDiv.style.flex = "0 0 85%";
knobDiv.style.flex = "0 0 15%";
vuDiv.style.marginLeft = "0px";
} else if (!showKnob && showVu) {
scaleDiv.style.flex = "0 0 70%";
vuDiv.style.flex = "0 0 30%";
vuDiv.style.marginLeft = "0px";
} else {
scaleDiv.style.flex = "0 0 100%";
}
this.resizeCanvas();
this.lastKnobState = "";
},
audioInitialized: false,
audioCtx: null,
analyserL: null, analyserR: null,
dataL: null, dataR: null,
currentVuLeft: 0, currentVuRight: 0,
init() {
if (!pluginConfig.ENABLE_ANALOG_SCALE) return;
this.hookPS();
this.setupRetroTooltip();
const themeObserver = new MutationObserver((mutations) => {
let themeChanged = false;
for (const mutation of mutations) {
if (mutation.type === 'attributes' &&
(mutation.attributeName === 'class' || mutation.attributeName === 'style' || mutation.attributeName === 'data-theme')) {
themeChanged = true;
break;
}
}
if (themeChanged) {
this.lastKnobState = "";
console.log("[Enhanced Tuning] Tema-endring oppdaget! Oppdaterer hjulet...");
}
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ['class', 'style', 'data-theme'] });
themeObserver.observe(document.body, { attributes: true, attributeFilter:['class', 'style', 'data-theme'] });
},
tryInitAudio() {
if (!this.isVuEnabled()) return;
if (this.audioInitialized && this.audioCtx) {
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume().catch(() => {});
}
if (typeof Stream !== 'undefined' && Stream && Stream.Fallback && Stream.Fallback.Player && Stream.Fallback.Player.Amplification) {
if (this.audioCtx !== Stream.Fallback.Player.Amplification.context) {
this.audioInitialized = false;
}
}
if (this.audioInitialized) return;
}
if (typeof Stream !== 'undefined' && Stream && Stream.Fallback && Stream.Fallback.Player && Stream.Fallback.Player.Amplification) {
try {
let source = Stream.Fallback.Player.Amplification;
if (!source.context) return;
this.audioCtx = source.context;
let splitter = this.audioCtx.createChannelSplitter(2);
this.analyserL = this.audioCtx.createAnalyser();
this.analyserR = this.audioCtx.createAnalyser();
this.analyserL.fftSize = 512;
this.analyserR.fftSize = 512;
this.dataL = new Float32Array(this.analyserL.fftSize);
this.dataR = new Float32Array(this.analyserR.fftSize);
try { source.connect(splitter); } catch(e){}
try { splitter.connect(this.analyserL, 0); } catch(e){}
try { splitter.connect(this.analyserR, 1); } catch(e){}
if (this.audioCtx.state === 'suspended') {
this.audioCtx.resume().catch(() => {});
}
this.audioInitialized = true;
} catch(e) {}
}
},
getLevel(floatArray) {
if (!floatArray || floatArray.length === 0) return 0;
const isAmBand = (this.currentFreq !== null && this.currentFreq < 30.0);
const gain = isAmBand ? (pluginConfig.VU_METER_GAIN_AM || 2.5) : (pluginConfig.VU_METER_GAIN_FM || 1.5);
let level = 0;
if (pluginConfig.VU_METER_MODE === 'Peak') {
let max = 0;
for (let i = 0; i < floatArray.length; i++) {
let abs = Math.abs(floatArray[i]);
if (abs > max) max = abs;
}
if (max < 0.01) return 0;
level = max * (2.0 * gain);
} else {
let sumSquares = 0;
for (let i = 0; i < floatArray.length; i++) {
sumSquares += floatArray[i] * floatArray[i];
}
let rms = Math.sqrt(sumSquares / floatArray.length);
if (rms < 0.002) return 0;
level = rms * (3.5 * gain);
if (level > 0.05) {
level += (Math.random() - 0.5) * 0.025;
}
}
return Math.min(1.0, level);
},
updateBand(bandData, freq, bandKey) {
if (!bandData || !pluginConfig.ENABLE_ANALOG_SCALE) return;
if (this.currentFreq === null) {
this.currentFreq = freq;
this.animFreq = freq;
this.dragFreq = freq;
} else {
if (!this.isDraggingScale && !this.isDraggingOuterKnob && !this.isDraggingInnerKnob && Math.abs(this.outerVelocity) < 0.001 && Math.abs(this.innerVelocity) < 0.001) {
if (this.currentFreq !== freq) {
let diff = freq - this.currentFreq;
if (this.currentKey === (bandKey || 'FM') || Math.abs(diff) < 3.0) {
let step = this.getStep();
if (this.isFineTuningMode) {
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const fineStep = isFmSpectrum ? 0.010 : 0.001;
this.innerKnobAngle += (diff / fineStep) * (Math.PI / 6);
} else {
this.outerKnobAngle += (diff / step) * (Math.PI / 20);
}
}
}
this.currentFreq = freq;
}
}
this.currentKey = bandKey || 'FM';
this.addButton();
const isSwSubband = this.currentKey && (this.currentKey in pluginConfig.customSwBands || this.currentKey.endsWith('m'));
const isMwLw = this.currentKey === 'MW' || this.currentKey === 'LW';
if (isSwSubband && this.isSwScaleEnabled()) {
if (this.lastFetchedSwBand !== this.currentKey) {
this.fetchSwStations(bandData.start, bandData.end);
this.lastFetchedSwBand = this.currentKey;
}
} else if (isMwLw && this.isSwScaleEnabled()) {
// FIX: Sjekk at vi ikke allerede har lastet dem!
if (this.lastFetchedSwBand !== this.currentKey) {
this.fetchMwLwFavorites();
this.lastFetchedSwBand = this.currentKey;
}
} else {
this.swStations = [];
this.mwLwFavorites =[];
this.lastFetchedSwBand = null;
}
if (this.currentMin !== bandData.start || this.currentMax !== bandData.end || this.currentKey !== bandKey) {
this.currentMin = bandData.start;
this.currentMax = bandData.end;
this.currentUnit = bandData.displayUnit || 'MHz';
// --- FIX FOR BÅND-NAVN ---
// Hvis navnet mangler, sjekker vi om keyen slutter på 'm' (f.eks '31m').
// Da erstatter vi 'm' med ' meter'. Hvis ikke, bruker vi 'BAND'.
if (bandData.name) {
this.currentName = bandData.name;
} else if (bandKey && bandKey.endsWith('m')) {
this.currentName = bandKey.replace('m', ' meter');
} else {
this.currentName = 'BAND';
}
// -------------------------
if (this.isVisible && this.scaleCanvas) {
this.resizeCanvas();
}
}
if (pluginConfig.ANALOG_SCALE_AUTOSTART && !this._hasAutostarted) {
this._hasAutostarted = true;
setTimeout(() => {
if (!this.isVisible && !this.isFading) this.toggle();
}, 1200);
}
},
getStep() {
if (this.currentKey === 'FM') return (pluginConfig.TUNING_STANDARD === 'americas') ? 0.200 : 0.100;
if (this.currentKey === 'MW') return (localStorage.getItem('mwStepPreference') === 'true' || pluginConfig.TUNING_STANDARD === 'americas') ? 0.010 : 0.009;
if (this.currentKey === 'LW') return 0.009;
if (this.currentKey === 'OIRT') return 0.030;
if (this.currentKey === 'AM_SUPER') return 0.001;
return 0.005;
},
snapFreq(f) {
const step = this.getStep();
let snappedOffset = Math.round((f - this.currentMin) / step) * step;
let snappedFreq = this.currentMin + snappedOffset;
return parseFloat(Math.max(this.currentMin, Math.min(this.currentMax, snappedFreq)).toFixed(3));
},
getTheme() {
const cssVar = (name, fallback) => getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
return {
bg1: cssVar("--color-1", "#071c33"),
bg2: cssVar("--color-2", "#0d2640"),
accent: cssVar("--color-4", "#3abf9a"),
text: cssVar("--color-text", "#e0e0e0")
};
},
parseColor(cssColor) {
if (this._colorCache[cssColor]) return this._colorCache[cssColor];
const tmp = document.createElement("canvas");
tmp.width = tmp.height = 1;
const c = tmp.getContext("2d");
c.fillStyle = "#000"; c.fillRect(0,0,1,1);
c.fillStyle = cssColor; c.fillRect(0,0,1,1);
const d = c.getImageData(0,0,1,1).data;
const result = { r: d[0], g: d[1], b: d[2] };
this._colorCache[cssColor] = result;
return result;
},
rgba(cssColor, alpha) {
const { r, g, b } = this.parseColor(cssColor);
return `rgba(${r},${g},${b},${alpha})`;
},
getClientX(evt) { return (evt.touches && evt.touches.length > 0) ? evt.touches[0].clientX : evt.clientX; },
getClientY(evt) { return (evt.touches && evt.touches.length > 0) ? evt.touches[0].clientY : evt.clientY; },
getAngle(x, y) { return Math.atan2(y - this.knobY, x - this.knobX); },
updateMetrics(sW, sH, kW) {
this.mX = 15;
this.mY = sH * 0.10;
this.mH = sH * 0.80;
if (kW > 0) {
this.mW = sW - this.mX - 15;
this.knobX = kW * 0.50;
this.knobY = sH * 0.5;
this.knobOuterR = Math.min(kW * 0.43, sH * 0.43);
this.knobInnerR = this.knobOuterR * 0.65;
} else {
this.mW = sW - (this.mX * 2);
}
},
ensureElements() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return false;
if (document.getElementById("et-analog-scale-wrap")) {
this.wrap = document.getElementById("et-analog-scale-wrap");
this.applyScaleLayout();
return true;
}
this.wrap = document.createElement("div");
this.wrap.id = "et-analog-scale-wrap";
Object.assign(this.wrap.style, {
display: "none", flexDirection: "row", width: "100%", height: "160px", position: "relative",
zIndex: "5",
transform: "translateX(10px)",
margin: "0",
overflow: "hidden", boxSizing: "border-box", userSelect: "none", webkitUserSelect: "none",
borderRadius: "16px", boxShadow: "0 6px 15px rgba(0,0,0,0.4)",
backgroundColor: "var(--color-1)"
});
const scaleDiv = document.createElement("div");
scaleDiv.id = "et-analog-scale-container";
Object.assign(scaleDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.scaleCanvas = document.createElement("canvas");
this.scaleCanvas.id = "et-analog-scale-canvas";
Object.assign(this.scaleCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
scaleDiv.appendChild(this.scaleCanvas);
this.wrap.appendChild(scaleDiv);
const knobDiv = document.createElement("div");
knobDiv.id = "et-analog-knob-container";
Object.assign(knobDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.knobCanvas = document.createElement("canvas");
this.knobCanvas.id = "et-analog-knob-canvas";
Object.assign(this.knobCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
knobDiv.appendChild(this.knobCanvas);
this.wrap.appendChild(knobDiv);
const vuDiv = document.createElement("div");
vuDiv.id = "et-analog-vu-container";
Object.assign(vuDiv.style, { position: "relative", height: "100%", overflow: "hidden", minWidth: "0" });
this.vuCanvas = document.createElement("canvas");
this.vuCanvas.id = "et-analog-vu-canvas";
Object.assign(this.vuCanvas.style, { width: "100%", height: "100%", display: "block", cursor: "default", touchAction: "none" });
vuDiv.appendChild(this.vuCanvas);
this.wrap.appendChild(vuDiv);
cc.appendChild(this.wrap);
if (!this._resizeObserver) {
this._resizeObserver = new ResizeObserver(() => {
if (this.isVisible) this.resizeCanvas();
});
this._resizeObserver.observe(this.wrap);
window.addEventListener('resize', () => {
if (this.isVisible) this.resizeCanvas();
});
}
this.scaleCanvas.addEventListener("wheel", (evt) => {
if (this.isDraggingScale) return;
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const showStations = (isFmSpectrum && this.isPsEnabled()) || (!isFmSpectrum && this.isSwScaleEnabled());
if (!showStations) return;
evt.preventDefault();
this.targetStationScrollY += Math.sign(evt.deltaY) * 35;
this.targetStationScrollY = Math.max(0, this.targetStationScrollY);
}, { passive: false });
this.scaleCanvas.addEventListener("mousemove", (evt) => {
if (this.isDraggingScale || this.isDraggingOuterKnob || this.isDraggingInnerKnob) return;
const rect = this.scaleCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
let isOverStation = false;
for (let st of this.renderedStations) {
if (x >= st.left && x <= st.right && y >= st.top && y <= st.bottom) {
isOverStation = true; break;
}
}
if (isOverStation) {
this.scaleCanvas.style.cursor = "pointer"; return;
}
const sdrBtn = document.getElementById('spectrum-graph-button');
const isSpecActive = sdrBtn && (sdrBtn.classList.contains('active') || sdrBtn.classList.contains('bg-color-4'));
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const showStations = (isFmSpectrum && this.isPsEnabled()) || (!isFmSpectrum && this.isSwScaleEnabled());
const paperX = this.mX + 2;
const paperW = this.mW - 4;
const paperH = this.mH - 4;
const baseY = showStations ? (this.mY + 2 + paperH * 0.35) : (this.mY + 2 + paperH * 0.85);
const actionBtnY = baseY - (showStations ? 5 : 15);
const scanBtnLeft = paperX + paperW - 40;
const scanBtnRight = paperX + paperW;
const scanBtnTop = actionBtnY - 15;
const scanBtnBottom = actionBtnY + 15;
const clearBtnLeft = paperX;
const clearBtnRight = paperX + 40;
const clearBtnTop = actionBtnY - 15;
const clearBtnBottom = actionBtnY + 15;
let hoverScan = isFmSpectrum && isSpecActive && x >= scanBtnLeft && x <= scanBtnRight && y >= scanBtnTop && y <= scanBtnBottom;
let hoverClear = isFmSpectrum && this.isPsEnabled() && x >= clearBtnLeft && x <= clearBtnRight && y >= clearBtnTop && y <= clearBtnBottom;
if (hoverScan || hoverClear) {
this.scaleCanvas.style.cursor = "pointer";
if (this.scanHovered !== hoverScan || this.clearHovered !== hoverClear) {
this.scanHovered = hoverScan;
this.clearHovered = hoverClear;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
return;
} else if (this.scanHovered || this.clearHovered) {
this.scanHovered = false; this.clearHovered = false;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
const tX = paperX + paperW * 0.04;
const tW = paperW * 0.92;
const needleX = tX + ((this.animFreq - this.currentMin) / (this.currentMax - this.currentMin)) * tW;
if (x >= needleX - 10 && x <= needleX + 10 && y >= this.mY && y <= this.mY + this.mH) {
this.scaleCanvas.style.cursor = "ew-resize";
} else {
this.scaleCanvas.style.cursor = "default";
}
});
this.scaleCanvas.addEventListener("mouseleave", () => {
this.scanHovered = false; this.clearHovered = false;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
const tooltip = document.getElementById('retro-station-tooltip');
if (tooltip) tooltip.style.display = 'none';
this.hoveredFreq = null;
});
const startScaleDrag = (evt) => {
const rect = this.scaleCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
for (let st of this.renderedStations) {
if (x >= st.left && x <= st.right && y >= st.top && y <= st.bottom) {
let targetFreq = this.currentUnit === 'kHz' ? st.f / 1000 : st.f;
safeTuneToFrequency(targetFreq);
this.currentFreq = targetFreq; this.animFreq = targetFreq; this.dragFreq = targetFreq;
return;
}
}
if (this.scanHovered) {
const origBtn = document.getElementById('spectrum-scan-button');
if (origBtn) origBtn.click();
this.scanClickedTime = Date.now();
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
return;
}
if (this.clearHovered) {
if (confirm("Are you sure you want to clear all saved PS stations?")) {
localStorage.removeItem('retro_station_db');
this.clearClickedTime = Date.now();
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
return;
}
if (x >= this.mX && x <= this.mX + this.mW && y >= this.mY && y <= this.mY + this.mH) {
this.isDraggingScale = true;
this.scaleCanvas.style.cursor = "col-resize";
this.handleGlobalMove(evt);
}
};
this.scaleCanvas.addEventListener("mousedown", startScaleDrag);
this.scaleCanvas.addEventListener("touchstart", startScaleDrag, { passive: false });
if (this.knobCanvas) {
this.knobCanvas.addEventListener("mousemove", (evt) => {
if (this.isDraggingScale || this.isDraggingOuterKnob || this.isDraggingInnerKnob) return;
const rect = this.knobCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const distSq = (x - this.knobX) * (x - this.knobX) + (y - this.knobY) * (y - this.knobY);
if (distSq <= this.knobOuterR * this.knobOuterR) {
this.knobCanvas.style.cursor = "grab";
} else {
this.knobCanvas.style.cursor = "default";
}
});
const startKnobDrag = (evt) => {
const rect = this.knobCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
const dx = x - this.knobX;
const dy = y - this.knobY;
const distSq = dx * dx + dy * dy;
this.knobDragMoved = false;
this.outerVelocity = 0;
this.innerVelocity = 0;
this.lastDragTime = Date.now();
if (distSq <= this.knobInnerR * this.knobInnerR) {
this.isDraggingInnerKnob = true;
this.lastDragAngle = this.getAngle(x, y);
this.knobCanvas.style.cursor = "grabbing";
} else if (distSq <= this.knobOuterR * this.knobOuterR) {
this.isDraggingOuterKnob = true;
this.accumulatedOuterAngle = 0;
this.lastDragAngle = this.getAngle(x, y);
this.knobCanvas.style.cursor = "grabbing";
}
};
this.knobCanvas.addEventListener("mousedown", startKnobDrag);
this.knobCanvas.addEventListener("touchstart", startKnobDrag, { passive: false });
this.knobCanvas.addEventListener("dblclick", (evt) => {
const rect = this.knobCanvas.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
const distSq = (x - this.knobX) * (x - this.knobX) + (y - this.knobY) * (y - this.knobY);
if (distSq <= this.knobInnerR * this.knobInnerR && this.currentFreq !== null) {
let snappedFreq = this.snapFreq(this.currentFreq);
this.currentFreq = snappedFreq; this.animFreq = snappedFreq; this.dragFreq = snappedFreq;
safeTuneToFrequency(snappedFreq);
}
});
}
const stopDrag = () => {
const wasActivelyDragging = (this.isDraggingScale) || ((this.isDraggingInnerKnob || this.isDraggingOuterKnob) && this.knobDragMoved);
if (Date.now() - this.lastDragTime > 50) {
this.outerVelocity = 0;
this.innerVelocity = 0;
}
if ((this.isDraggingInnerKnob || this.isDraggingOuterKnob) && !this.knobDragMoved) {
this.isFineTuningMode = !this.isFineTuningMode;
if (navigator.vibrate) navigator.vibrate(50);
if (this.knobCanvas) this.drawKnob(this.knobCanvas);
}
this.isDraggingOuterKnob = false;
this.isDraggingInnerKnob = false;
this.isDraggingScale = false;
if (this.scaleCanvas) this.scaleCanvas.style.cursor = "default";
if (this.knobCanvas) this.knobCanvas.style.cursor = "default";
clearTimeout(this.finalTuneTimer);
if (wasActivelyDragging && this.dragFreq !== null) {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}
};
window.addEventListener("mousemove", (e) => this.handleGlobalMove(e));
window.addEventListener("mouseup", stopDrag);
window.addEventListener("mouseleave", stopDrag);
window.addEventListener("touchmove", (e) => this.handleGlobalMove(e), { passive: false });
window.addEventListener("touchend", stopDrag);
window.addEventListener("touchcancel", stopDrag);
this.applyScaleLayout();
this.resizeCanvas();
return true;
},
applyKnobRotation(isOuter, deltaAngle) {
let f = this.currentFreq;
const step = this.getStep();
if (isOuter) {
this.outerKnobAngle += deltaAngle;
this.accumulatedOuterAngle += deltaAngle;
const anglePerStep = Math.PI / 20;
if (Math.abs(this.accumulatedOuterAngle) >= anglePerStep) {
const steps = Math.trunc(this.accumulatedOuterAngle / anglePerStep);
this.accumulatedOuterAngle -= steps * anglePerStep;
f = this.snapFreq(f);
f += steps * step;
}
} else {
this.innerKnobAngle += deltaAngle;
if (typeof this.accumulatedInnerAngle === 'undefined') {
this.accumulatedInnerAngle = 0;
}
this.accumulatedInnerAngle += deltaAngle;
const anglePerInnerStep = Math.PI / 6;
if (Math.abs(this.accumulatedInnerAngle) >= anglePerInnerStep) {
const steps = Math.trunc(this.accumulatedInnerAngle / anglePerInnerStep);
this.accumulatedInnerAngle -= steps * anglePerInnerStep;
if (!this.isFineTuningMode) {
f = this.snapFreq(f);
f += steps * step;
} else {
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const fineStep = isFmSpectrum ? 0.010 : 0.001;
f += steps * fineStep;
}
}
}
f = Math.max(this.currentMin, Math.min(this.currentMax, f));
f = parseFloat(f.toFixed(3));
if (this.dragFreq !== f) {
this.dragFreq = f;
this.currentFreq = f;
const now = Date.now();
if (now - this.lastTuneTime > 80) { safeTuneToFrequency(f); this.lastTuneTime = now; }
clearTimeout(this.finalTuneTimer);
this.finalTuneTimer = setTimeout(() => {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}, 100);
}
},
handleGlobalMove(evt) {
if (!this.scaleCanvas || this.currentFreq === null) return;
if (this.isDraggingOuterKnob || this.isDraggingInnerKnob || this.isDraggingScale) {
if (evt.cancelable) evt.preventDefault();
} else {
return;
}
if (this.isDraggingOuterKnob || this.isDraggingInnerKnob) {
const rect = this.knobCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const y = this.getClientY(evt) - rect.top;
const currentAngle = this.getAngle(x, y);
let deltaAngle = currentAngle - this.lastDragAngle;
if (deltaAngle > Math.PI) deltaAngle -= 2 * Math.PI;
if (deltaAngle < -Math.PI) deltaAngle += 2 * Math.PI;
if (Math.abs(deltaAngle) > 0.02) {
this.knobDragMoved = true;
}
this.lastDragTime = Date.now();
if (this.isDraggingOuterKnob) {
this.outerVelocity = deltaAngle;
this.applyKnobRotation(true, deltaAngle);
} else {
this.innerVelocity = deltaAngle;
this.applyKnobRotation(false, deltaAngle);
}
this.lastDragAngle = currentAngle;
} else if (this.isDraggingScale) {
const rect = this.scaleCanvas.getBoundingClientRect();
const x = this.getClientX(evt) - rect.left;
const paperX = this.mX + 2;
const paperW = this.mW - 4;
const tX = paperX + paperW * 0.04;
const tW = paperW * 0.92;
let rawF = this.currentMin + ((x - tX) / tW) * (this.currentMax - this.currentMin);
let f = this.isFineTuningMode ? rawF : this.snapFreq(rawF);
f = Math.max(this.currentMin, Math.min(this.currentMax, f));
f = parseFloat(f.toFixed(3));
if (this.dragFreq !== f) {
this.dragFreq = f;
this.currentFreq = f;
if (this.isDraggingScale) this.animFreq = f;
const now = Date.now();
if (now - this.lastTuneTime > 80) { safeTuneToFrequency(f); this.lastTuneTime = now; }
clearTimeout(this.finalTuneTimer);
this.finalTuneTimer = setTimeout(() => {
const finalF = this.isFineTuningMode ? parseFloat(this.dragFreq.toFixed(3)) : this.snapFreq(this.dragFreq);
safeTuneToFrequency(finalF);
}, 100);
}
}
},
resizeCanvas() {
if (!this.scaleCanvas || !this.wrap) return;
const dpr = window.devicePixelRatio || 1;
const sW = this.scaleCanvas.parentElement.clientWidth;
const sH = this.scaleCanvas.parentElement.clientHeight || 160;
if(sW === 0 || sH === 0) return;
this.scaleCanvas.width = Math.round(sW * dpr);
this.scaleCanvas.height = Math.round(sH * dpr);
let kW = 0;
if (this.knobCanvas && this.knobCanvas.parentElement.style.display !== "none") {
kW = this.knobCanvas.parentElement.clientWidth || 96;
const kH = this.knobCanvas.parentElement.clientHeight || 160;
this.knobCanvas.width = Math.round(kW * dpr);
this.knobCanvas.height = Math.round(kH * dpr);
}
if (this.isVuEnabled() && this.vuCanvas) {
const vW = this.vuCanvas.parentElement.clientWidth;
const vH = this.vuCanvas.parentElement.clientHeight || 160;
this.vuCanvas.width = Math.round(vW * dpr);
this.vuCanvas.height = Math.round(vH * dpr);
}
this.updateMetrics(sW, sH, kW);
this.lastKnobState = "";
},
applyWrapperStyle() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return;
cc.style.padding = "0";
cc.style.lineHeight = "0";
cc.style.overflow = "visible";
cc.style.position = "relative";
cc.style.height = "auto";
cc.style.minHeight = "160px";
cc.style.flexShrink = "0";
cc.style.marginTop = "10px";
cc.style.marginBottom = "0px";
const p = cc.parentElement;
if (p) {
const cs = getComputedStyle(p);
const pL = parseFloat(cs.paddingLeft) || 0;
const pR = parseFloat(cs.paddingRight) || 0;
cc.style.marginLeft = `-${pL}px`;
cc.style.marginRight = `-${pR}px`;
cc.style.width = `calc(98.4% + ${pL + pR}px)`;
}
},
resetWrapperStyle() {
const cc = document.querySelector(".canvas-container.hide-phone");
if (!cc) return;
const mmActive =["mm-mpx-combo-flex", "mm-signal-analyzer-flex", "mm-scope-flex"].some(id => {
const el = document.getElementById(id);
return el && el.style.display === "flex";
});
if (!mmActive) {["padding", "margin", "marginTop", "marginBottom", "lineHeight", "overflow", "marginLeft", "marginRight", "width", "position", "height", "minHeight", "flexShrink"].forEach(p => cc.style[p] = "");
}
},
toggle() {
if (this.isFading) return;
const btn = document.getElementById("et-analog-scale-btn");
if (this.isVisible) {
this.isFading = true;
if (this.wrap) {
this.wrap.style.transition = "opacity 400ms ease-in-out";
this.wrap.style.opacity = "0";
}
setTimeout(() => {
this.isVisible = false;
if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; }
if (this.wrap) { this.wrap.style.display = "none"; this.wrap.style.transition = ""; }
document.body.classList.remove('et-analog-active');
this.resetWrapperStyle();
if (btn) btn.classList.remove("active");
this.isFading = false;
}, 400);
} else {
if (!this.ensureElements()) return;
if (this.audioCtx && this.audioCtx.state === 'suspended') this.audioCtx.resume();
document.body.classList.add('et-analog-active');
this.applyWrapperStyle();
this.wrap.style.display = "flex";
this.isVisible = true;
this.resizeCanvas();
if (!this.rafId) this.rafId = requestAnimationFrame(() => this.renderLoop());
this.wrap.style.opacity = "0";
this.wrap.style.transition = "opacity 400ms ease-in-out";
// --- DEN GENIALE ENDRINGEN ---
// Vi setter opacity til 0.99 i stedet for 1!
// Dette tvinger Chromium til å holde spektrumet bak skalaen i live!
requestAnimationFrame(() => requestAnimationFrame(() => { this.wrap.style.opacity = "0.99"; }));
setTimeout(() => { if (this.wrap) this.wrap.style.transition = ""; this.isFading = false; }, 400);
if (btn) btn.classList.add("active");
}
},
rrect(ctx, x, y, w, h, r) {
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + w - r, y); ctx.quadraticCurveTo(x + w, y, x + w, y + r);
ctx.lineTo(x + w, y + h - r); ctx.quadraticCurveTo(x + w, y + h, x + w - r, y + h); ctx.lineTo(x + r, y + h);
ctx.quadraticCurveTo(x, y + h, x, y + h - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath();
},
drawSilverKnurledRing(ctx, radius, width, ridges, angleOffset) {
ctx.beginPath(); ctx.arc(0, 0, radius, 0, Math.PI * 2); ctx.arc(0, 0, radius - width, 0, Math.PI * 2, true);
const silverGrad = ctx.createLinearGradient(-radius, -radius, radius, radius);
silverGrad.addColorStop(0, "#777777"); silverGrad.addColorStop(0.2, "#aaaaaa");
silverGrad.addColorStop(0.5, "#555555"); silverGrad.addColorStop(0.8, "#bbbbbb"); silverGrad.addColorStop(1, "#444444");
ctx.fillStyle = silverGrad; ctx.fill();
ctx.lineWidth = 1.0;
for (let i = 0; i < ridges; i++) {
const angle = (i / ridges) * Math.PI * 2 + angleOffset;
const x1 = Math.cos(angle) * radius, y1 = Math.sin(angle) * radius;
const x2 = Math.cos(angle) * (radius - width), y2 = Math.sin(angle) * (radius - width);
ctx.strokeStyle = "rgba(0,0,0,0.6)"; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke();
const a2 = angle + (Math.PI * 2 / ridges) * 0.4;
const x3 = Math.cos(a2) * radius, y3 = Math.sin(a2) * radius;
const x4 = Math.cos(a2) * (radius - width), y4 = Math.sin(a2) * (radius - width);
ctx.strokeStyle = "rgba(255,255,255,0.35)"; ctx.beginPath(); ctx.moveTo(x3, y3); ctx.lineTo(x4, y4); ctx.stroke();
}
},
drawConcentricKnob(ctx, x, y, outerR, innerR, T) {
ctx.save(); ctx.translate(x, y);
ctx.beginPath(); ctx.arc(0, 0, outerR + 2, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,0.7)"; ctx.shadowColor = "rgba(0,0,0,0.9)";
ctx.shadowBlur = 12; ctx.shadowOffsetY = 6; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, outerR, 0, Math.PI * 2);
ctx.lineWidth = 1.5; ctx.strokeStyle = "rgba(255,255,255,0.3)"; ctx.stroke();
this.drawSilverKnurledRing(ctx, outerR, outerR * 0.07, 80, this.outerKnobAngle);
ctx.beginPath(); ctx.arc(0, 0, outerR * 0.93, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 6; ctx.shadowOffsetY = 2;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.lineWidth = 2; ctx.stroke(); ctx.shadowColor = "transparent";
const outerDarkR = outerR * 0.93;
ctx.beginPath(); ctx.arc(0, 0, outerDarkR, 0, Math.PI * 2);
const outerGrad = ctx.createLinearGradient(-outerDarkR, -outerDarkR, outerDarkR, outerDarkR);
outerGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); outerGrad.addColorStop(0.5, this.rgba(T.bg1, 0.9)); outerGrad.addColorStop(1, this.rgba(T.bg1, 1.0));
ctx.fillStyle = outerGrad; ctx.fill();
const outerDimpleX = Math.cos(this.outerKnobAngle - Math.PI / 2) * (outerDarkR * 0.82);
const outerDimpleY = Math.sin(this.outerKnobAngle - Math.PI / 2) * (outerDarkR * 0.82);
ctx.beginPath(); ctx.arc(outerDimpleX, outerDimpleY, outerR * 0.06, 0, Math.PI * 2);
const outerDimpleGrad = ctx.createRadialGradient(outerDimpleX, outerDimpleY, 0, outerDimpleX, outerDimpleY, outerR * 0.06);
if (!this.isFineTuningMode) {
outerDimpleGrad.addColorStop(0, "#ffffff");
outerDimpleGrad.addColorStop(1, "#3498db");
ctx.shadowColor = "#3498db"; ctx.shadowBlur = 8;
} else {
outerDimpleGrad.addColorStop(0, "#111");
outerDimpleGrad.addColorStop(1, this.rgba(T.bg2, 1.0));
}
ctx.fillStyle = outerDimpleGrad; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(outerDimpleX, outerDimpleY, outerR * 0.06, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 1;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.stroke(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, innerR + 3, 0, Math.PI * 2);
ctx.fillStyle = "rgba(0,0,0,1.0)"; ctx.shadowColor = "rgba(0,0,0,0.95)";
ctx.shadowBlur = 18; ctx.shadowOffsetY = 10; ctx.shadowOffsetX = 4; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(0, 0, innerR + 1, 0, Math.PI * 1); ctx.fillStyle = "#000000"; ctx.fill();
ctx.beginPath(); ctx.arc(0, 0, innerR + 1, 0, Math.PI * 2);
const rimGrad = ctx.createLinearGradient(0, -innerR, 0, innerR);
rimGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); rimGrad.addColorStop(1, "#111"); ctx.fillStyle = rimGrad; ctx.fill();
this.drawSilverKnurledRing(ctx, innerR, innerR * 0.10, 60, this.innerKnobAngle);
ctx.beginPath(); ctx.arc(0, 0, innerR * 0.90, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 5; ctx.shadowOffsetY = 2;
ctx.strokeStyle = "rgba(0,0,0,0.6)"; ctx.lineWidth = 1.5; ctx.stroke(); ctx.shadowColor = "transparent";
const innerDarkR = innerR * 0.90;
ctx.beginPath(); ctx.arc(0, 0, innerDarkR, 0, Math.PI * 2);
const innerGrad = ctx.createLinearGradient(-innerDarkR, -innerDarkR, innerDarkR, innerDarkR);
innerGrad.addColorStop(0, this.rgba(T.bg2, 1.0)); innerGrad.addColorStop(0.6, this.rgba(T.bg1, 0.9)); innerGrad.addColorStop(1, this.rgba(T.bg1, 1.0));
ctx.fillStyle = innerGrad; ctx.fill();
ctx.beginPath(); ctx.arc(0, 0, innerDarkR, 0, Math.PI * 2); ctx.lineWidth = 1;
const highlightGrad = ctx.createLinearGradient(0, -innerDarkR, 0, innerDarkR);
highlightGrad.addColorStop(0, "rgba(255,255,255,0.4)"); highlightGrad.addColorStop(0.3, "rgba(255,255,255,0.05)"); highlightGrad.addColorStop(1, "rgba(255,255,255,0)");
ctx.strokeStyle = highlightGrad; ctx.stroke();
const dimpleX = Math.cos(this.innerKnobAngle - Math.PI / 2) * (innerDarkR * 0.7);
const dimpleY = Math.sin(this.innerKnobAngle - Math.PI / 2) * (innerDarkR * 0.7);
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
const dimpleGrad = ctx.createRadialGradient(dimpleX, dimpleY, 0, dimpleX, dimpleY, innerR * 0.08);
if (this.isFineTuningMode) {
dimpleGrad.addColorStop(0, "#ffffff");
dimpleGrad.addColorStop(1, "#3498db");
ctx.shadowColor = "#3498db"; ctx.shadowBlur = 8;
} else {
dimpleGrad.addColorStop(0, "#111");
dimpleGrad.addColorStop(1, this.rgba(T.bg2, 1.0));
}
ctx.fillStyle = dimpleGrad; ctx.fill(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
ctx.shadowColor = "rgba(0,0,0,0.8)"; ctx.shadowBlur = 3; ctx.shadowOffsetY = 1;
ctx.strokeStyle = "rgba(0,0,0,0.5)"; ctx.stroke(); ctx.shadowColor = "transparent";
ctx.beginPath(); ctx.arc(dimpleX, dimpleY, innerR * 0.08, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.2)"; ctx.lineWidth = 0.5; ctx.stroke();
ctx.restore();
},
drawScale(canvas, freq) {
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const CW = canvas.width / dpr;
const CH = canvas.height / dpr;
if(CW === 0 || CH === 0) return;
const T = this.getTheme();
let dMin = this.currentUnit === 'kHz' ? this.currentMin * 1000 : this.currentMin;
let dMax = this.currentUnit === 'kHz' ? this.currentMax * 1000 : this.currentMax;
let dFreq = this.currentUnit === 'kHz' ? freq * 1000 : freq;
let dRange = dMax - dMin;
let majStep, midStep, minStep, lblStep;
if (dRange > 1000) { lblStep = 100; majStep = 100; midStep = 50; minStep = 10; }
else if (dRange > 100) { lblStep = 20; majStep = 20; midStep = 10; minStep = 2; }
else if (dRange > 15) { lblStep = 2; majStep = 2; midStep = 1; minStep = 0.1; }
else if (dRange > 5) { lblStep = 1; majStep = 1; midStep = 0.5; minStep = 0.1; }
else { lblStep = 0.1; majStep = 0.1; midStep = 0.05; minStep = 0.01; }
ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, CW, CH);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fillRect(0, 0, CW, CH);
const mR = 8;
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.85)"; ctx.shadowBlur = 10; ctx.shadowOffsetY = 4; ctx.shadowOffsetX = 2;
this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fill();
ctx.restore();
ctx.save(); ctx.filter = `brightness(${this.getBrightness()})`;
const paperX = this.mX + 2, paperY = this.mY + 2, paperW = this.mW - 4, paperH = this.mH - 4;
ctx.fillStyle = this.rgba(T.bg2, 0.5);
this.rrect(ctx, paperX, paperY, paperW, paperH, 2); ctx.fill();
const paper = ctx.createRadialGradient(paperX + paperW * 0.5, paperY + paperH * 0.5, paperH * 0.1, paperX + paperW * 0.5, paperY + paperH * 0.5, paperW * 0.65);
paper.addColorStop(0.00, this.rgba(T.accent, 0.80)); paper.addColorStop(0.40, this.rgba(T.accent, 0.50)); paper.addColorStop(1.00, this.rgba(T.accent, 0.10));
ctx.fillStyle = paper; this.rrect(ctx, paperX, paperY, paperW, paperH, 2); ctx.fill();
const isFmSpectrum = (this.currentKey === 'FM' || this.currentKey === 'OIRT');
const psMode = isFmSpectrum && this.isPsEnabled();
const swMode = !isFmSpectrum && this.currentKey.endsWith('m') && this.isSwScaleEnabled();
const mwLwMode = !isFmSpectrum && (this.currentKey === 'MW' || this.currentKey === 'LW') && this.isSwScaleEnabled();
const showStationsOnScale = psMode || swMode || mwLwMode;
const tX = paperX + paperW * 0.04, tW = paperW * 0.92;
const baseY = showStationsOnScale ? paperY + paperH * 0.35 : paperY + paperH * 0.85;
const numY = showStationsOnScale ? paperY + paperH * 0.15 : paperY + paperH * 0.32;
const fX = f => tX + ((f - dMin) / dRange) * tW;
if (isFmSpectrum && this.isSpectrumEnabled()) {
try {
const sdrCanvas = document.getElementById('sdr-graph');
const specBtn = document.getElementById('spectrum-graph-button');
if (sdrCanvas && specBtn && (specBtn.classList.contains('active') || specBtn.classList.contains('bg-color-4')) && sdrCanvas.width > 100) {
// 1. Les av nøyaktig hvilke frekvenser original-canvaset spenner over
const specContainer = sdrCanvas.closest('.canvas-container') || sdrCanvas.parentElement;
let ariaLabel = specContainer ? specContainer.getAttribute('aria-label') : '';
let srcMin = 86.0; // Fallback
let srcMax = 108.0;
if (ariaLabel) {
// Trekker ut minFreq og maxFreq fra "aria-label" attributtet
const match = ariaLabel.match(/from ([\d.]+) to ([\d.]+) MHz/);
if (match) {
srcMin = parseFloat(match[1]);
srcMax = parseFloat(match[2]);
}
} else {
// Siste utvei-fallback hvis aria-label mangler
const limitSpan = document.querySelector(".tuner-desc .text-small .color-4");
if (limitSpan) {
const match = limitSpan.textContent.match(/(\d+(\.\d+)?)\s*MHz\s*-\s*(\d+(\.\d+)?)/);
if (match) {
srcMin = Math.max(Number(match[1]), 86);
srcMax = Number(match[3]);
}
}
}
// 2. Hent xOffset-avstanden for Y-aksen (eksakt som i orginal-koden)
const signalText = localStorage.getItem('signalUnit') || 'dbf';
const xOffset = (signalText === 'dbm') ? 36 : 30;
// 3. Definer området i kilde-canvaset som kun inneholder graf-data
const srcX = xOffset;
const srcY = 16;
const srcW = sdrCanvas.width - xOffset;
const srcH = sdrCanvas.height - 40;
// 4. Kalkuler hvor på VÅR skala disse frekvensene befinner seg
const dMin = this.currentUnit === 'kHz' ? this.currentMin * 1000 : this.currentMin;
const dMax = this.currentUnit === 'kHz' ? this.currentMax * 1000 : this.currentMax;
// Matematisk mapping av startposisjon og bredde
const destX = tX + ((srcMin - dMin) / (dMax - dMin)) * tW;
const destW = ((srcMax - srcMin) / (dMax - dMin)) * tW;
if (srcW > 0 && srcH > 0) {
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
ctx.filter = 'invert(1) grayscale(1) contrast(2.0) brightness(0.9)';
ctx.globalAlpha = 0.3;
ctx.globalCompositeOperation = 'multiply';
// Tegn bildet strukket nøyaktig til de riktige matematiske punktene!
ctx.drawImage(sdrCanvas, srcX, srcY, srcW, srcH, destX, paperY + 2, destW, (baseY - paperY) - 2);
ctx.restore();
}
}
} catch (e) {}
}
const inkColor = "rgba(10, 15, 20, 0.95)";
const inkFaded = "rgba(10, 15, 20, 0.70)";
ctx.beginPath(); ctx.moveTo(tX, baseY); ctx.lineTo(tX + tW, baseY);
ctx.strokeStyle = inkFaded; ctx.lineWidth = 2.0; ctx.stroke();
for (let i = Math.floor(dMin / minStep); i <= Math.ceil(dMax / minStep); i++) {
const f = i * minStep;
if (f < dMin - 0.001 || f > dMax + 0.001) continue;
const x = fX(f);
const isMaj = Math.abs(Math.round(f / majStep) * majStep - f) < 0.001;
const isMid = !isMaj && Math.abs(Math.round(f / midStep) * midStep - f) < 0.001;
let tH = paperH * (showStationsOnScale ? 0.03 : 0.06), col = "rgba(10,15,20,0.5)", lw = 1.0;
if (showStationsOnScale) {
if (isMaj) { tH = paperH * 0.10; col = inkColor; lw = 2.0; } else if (isMid) { tH = paperH * 0.06; col = inkColor; lw = 1.5; }
} else {
if (isMaj) { tH = paperH * 0.35; col = inkColor; lw = 2.0; } else if (isMid) { tH = paperH * 0.22; col = inkColor; lw = 1.5; }
}
ctx.beginPath(); ctx.moveTo(x, baseY); ctx.lineTo(x, baseY - tH);
ctx.strokeStyle = col; ctx.lineWidth = lw; ctx.stroke();
}
this.renderedStations =[];
if (showStationsOnScale) {
let visibleStations =[];
if (psMode) {
const stationDB = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
for (let fStr in stationDB) {
let stFreq = parseFloat(fStr);
if (stFreq >= dMin - 0.2 && stFreq <= dMax + 0.2) {
visibleStations.push({ freq: stFreq, name: stationDB[fStr].ps, x: fX(stFreq), data: stationDB[fStr], isSw: false });
}
}
} else if (swMode || mwLwMode) {
let activeList;
if (swMode) {
activeList = this.swStations;
} else {
activeList = this.mwLwFavorites;
}
if (activeList !== null && activeList !== undefined) {
for (let i = 0; i < activeList.length; i++) {
let st = activeList[i];
let stFreq;
// SW bruker MHz (må deles på 1000), MW/LW bruker kHz direkte
if (mwLwMode) {
stFreq = st.frequency;
} else {
stFreq = st.frequency / 1000;
}
if (stFreq >= dMin - 0.05 && stFreq <= dMax + 0.05) {
let shortName = st.name.replace(/(Radio|Broadcasting|Corporation|Voice of|International)/ig, '').trim();
if (shortName.startsWith('-') || shortName.startsWith(',')) {
shortName = shortName.substring(1).trim();
}
if (shortName.length > 10) {
shortName = shortName.substring(0, 9) + '..';
}
if (shortName === '') {
shortName = st.name.substring(0, 10);
}
visibleStations.push({ freq: stFreq, name: shortName, x: fX(stFreq), data: st, isSw: true });
}
}
}
}
visibleStations.sort((a,b) => a.x - b.x);
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
const startY = baseY + paperH * 0.02;
const baseRowHeight = ((paperY + paperH) - startY - (paperH * 0.02)) / 5;
const rowHeight = Math.min(baseRowHeight, 35);
const stFontSize = Math.max((this.isVuEnabled() ? 7 : 9), rowHeight * (this.isVuEnabled() ? 0.60 : 0.70));
ctx.textAlign = "center"; ctx.textBaseline = "middle";
let rowRightEdges =[];
let maxRow = 0;
const hoverThreshold = isFmSpectrum ? 0.04 : (this.currentUnit === 'kHz' ? 3 : 0.003);
visibleStations.forEach((st, index) => {
const isHovered = (this.hoveredFreq === st.freq) || (Math.abs(dFreq - st.freq) <= hoverThreshold);
const isActive = st.isSw ? true : (st.data.active !== false);
ctx.font = `${stFontSize}px "Arial Narrow", Arial, sans-serif`;
const baseBoxWidth = Math.max(ctx.measureText(this.isVuEnabled() ? "000000" : "00000000").width, ctx.measureText(st.name).width);
const baseTotalWidth = baseBoxWidth + ((this.isVuEnabled() ? 2 : 4) * 2);
let targetX = st.x;
if (targetX - (baseTotalWidth / 2) < paperX + 4) targetX = paperX + 4 + (baseTotalWidth / 2);
if (targetX + (baseTotalWidth / 2) > paperX + paperW - 4) targetX = paperX + paperW - 4 - (baseTotalWidth / 2);
let assignedRow = -1;
for (let r = 0; r < 200; r++) {
if (!rowRightEdges[r]) rowRightEdges[r] = -9999;
if (targetX - (baseTotalWidth / 2) > rowRightEdges[r] + (this.isVuEnabled() ? 6 : 8)) {
assignedRow = r; break;
}
}
if (assignedRow === -1) assignedRow = 0;
maxRow = Math.max(maxRow, assignedRow);
rowRightEdges[assignedRow] = Math.max(rowRightEdges[assignedRow], targetX + (baseTotalWidth / 2));
const y = startY + assignedRow * rowHeight + (rowHeight / 2) - this.stationScrollY;
if (y > paperY - rowHeight && y < paperY + paperH + rowHeight) {
const hoverFactor = 1.15;
ctx.font = `${isHovered ? 'bold ' : ''}${isHovered ? stFontSize * hoverFactor : stFontSize}px "Arial Narrow", Arial, sans-serif`;
// --- FIX FOR SW BAKGRUNNSBOKSER ---
// Bestemmer om den mørke bakgrunnsboksen skal tegnes.
// For SW: Kun når stasjonen er i fokus (isHovered). For FM: Når stasjonen er aktiv.
const drawBgBox = st.isSw ? isHovered : isActive;
if (drawBgBox) {
ctx.fillStyle = this.rgba(T.bg1, 0.20);
if (isHovered) { ctx.shadowColor = "rgba(0,0,0,0.3)"; ctx.shadowBlur = 4; }
this.rrect(ctx, targetX - baseBoxWidth/2 - 4, y - (stFontSize*1.05)/2 - (isHovered?1:0), baseBoxWidth + 8, (stFontSize*1.05) + (isHovered?2:0), 4);
ctx.fill();
ctx.shadowBlur = 0;
}
// Setter fargen på teksten.
// Alle SW-stasjoner og aktive FM-stasjoner forblir tydelige (inkColor), selv uten boks.
if (st.isSw || isActive) {
ctx.fillStyle = inkColor;
} else {
// Inaktive FM-stasjoner falmes som før
if (isHovered) { ctx.shadowColor = "rgba(0,0,0,0.5)"; ctx.shadowBlur = 4; }
ctx.fillStyle = inkFaded;
}
// ------------------------------------
const rangeScale = this.currentUnit === 'kHz' ? 1000.0 : 1.0;
ctx.fillText(st.name, targetX, y, ((rangeScale / dRange) * tW * 0.90) * (isHovered ? hoverFactor : 1));
ctx.shadowBlur = 0;
this.renderedStations.push({ f: st.freq, left: targetX - baseBoxWidth/2 - 4, right: targetX + baseBoxWidth/2 + 4, top: y - stFontSize, bottom: y + stFontSize, data: st.data, isSw: st.isSw });
}
});
ctx.restore();
const maxScroll = Math.max(0, (maxRow * rowHeight) - (paperH * 0.5));
if (this.targetStationScrollY > maxScroll) this.targetStationScrollY = maxScroll;
}
const numSize = Math.max(showStationsOnScale ? 10 : 12, Math.min(18, paperW / 55));
ctx.font = `700 ${numSize}px "Arial Narrow", Arial, sans-serif`;
ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillStyle = inkColor;
// Vi lagrer det første tallet som tegnes i en variabel (startNum)
const startNum = Math.ceil(dMin / lblStep) * lblStep;
for (let f = startNum; f <= dMax + 0.001; f += lblStep) {
ctx.fillText(lblStep < 1 ? f.toFixed(1) : Math.round(f).toString(), fX(f), numY);
}
const lblSize = Math.max(showStationsOnScale ? 9 : 10, paperH * (showStationsOnScale ? 0.14 : 0.20));
const smallLabelFont = `700 ${lblSize * (showStationsOnScale ? 0.8 : 0.6)}px Arial, sans-serif`;
if (showStationsOnScale) {
// --- FIX FOR MHz / kHz PLASSERING ---
// Vi regner ut X-posisjonen og bredden til det aller første tallet
const firstNumX = fX(startNum);
const firstNumText = lblStep < 1 ? startNum.toFixed(1) : Math.round(startNum).toString();
ctx.font = `700 ${numSize}px "Arial Narrow", Arial, sans-serif`;
const firstNumWidth = ctx.measureText(firstNumText).width;
ctx.textAlign = "left"; // Tegner teksten fra venstre mot høyre
ctx.textBaseline = "middle";
ctx.font = smallLabelFont;
ctx.fillStyle = inkColor;
// Vi plasserer "MHz" nøyaktig 4 piksler til HØYRE for det første tallet
const unitX = firstNumX + (firstNumWidth / 2) + 4;
ctx.fillText(this.currentUnit, unitX, numY);
// ------------------------------------
ctx.textBaseline = "bottom";
ctx.textAlign = "left";
ctx.font = `900 ${lblSize}px "Arial Narrow", Arial, sans-serif`;
ctx.fillText(this.currentName, paperX + 3, paperY + paperH - 4);
} else {
ctx.textBaseline = "top"; ctx.textAlign = "left"; ctx.font = `900 ${lblSize}px "Arial Narrow", Arial, sans-serif`; ctx.fillText(this.currentName, paperX + 8, paperY + 5);
ctx.textAlign = "right"; ctx.font = smallLabelFont; ctx.fillText(this.currentUnit, paperX + paperW - 8, paperY + 5);
}
const actionBtnY = baseY - (showStationsOnScale ? 5 : 15);
ctx.textBaseline = "middle";
if (isFmSpectrum) {
const sdrBtnDraw = document.getElementById('spectrum-graph-button');
if (sdrBtnDraw && (sdrBtnDraw.classList.contains('active') || sdrBtnDraw.classList.contains('bg-color-4'))) {
ctx.font = `700 ${lblSize * (showStationsOnScale ? 0.8 : 0.65)}px Arial, sans-serif`;
ctx.textAlign = "right";
ctx.fillStyle = (Date.now() - this.scanClickedTime < 150) ? "#ff3300" : (this.scanHovered ? inkColor : inkFaded);
ctx.fillText("↻ ", paperX + paperW - 5, actionBtnY);
}
}
if (isFmSpectrum && this.isPsEnabled()) {
ctx.font = `700 ${lblSize * 0.55}px Arial, sans-serif`;
ctx.textAlign = "left";
ctx.fillStyle = (Date.now() - this.clearClickedTime < 150) ? "#ff3300" : (this.clearHovered ? inkColor : inkFaded);
ctx.fillText(" ✖", paperX + 5, actionBtnY);
}
const nx = fX(Math.max(dMin, Math.min(dMax, dFreq)));
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.60)"; ctx.shadowBlur = 4; ctx.shadowOffsetX = 2; ctx.shadowOffsetY = 1;
ctx.beginPath(); ctx.moveTo(nx, this.mY - 1); ctx.lineTo(nx, this.mY + this.mH + 1);
ctx.strokeStyle = "#ff3300"; ctx.lineWidth = 2.5; ctx.stroke(); ctx.restore();
ctx.beginPath(); ctx.moveTo(nx, this.mY); ctx.lineTo(nx, this.mY + this.mH);
ctx.strokeStyle = "rgba(255,200,200,0.60)"; ctx.lineWidth = 0.9; ctx.stroke();
const blockH = CH * 0.09, blockW = 10;
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.70)"; ctx.shadowBlur = 4; ctx.shadowOffsetY = 2;
const topBlock = ctx.createLinearGradient(nx - blockW/2, 0, nx + blockW/2, 0);
topBlock.addColorStop(0, "#c01500"); topBlock.addColorStop(0.4, "#ff3a1a"); topBlock.addColorStop(0.6, "#ff3a1a"); topBlock.addColorStop(1, "#8a0d00");
ctx.fillStyle = topBlock; ctx.fillRect(nx - blockW / 2, 0, blockW, blockH); ctx.restore();
ctx.strokeStyle = "rgba(255,200,180,0.45)"; ctx.lineWidth = 0.7; ctx.strokeRect(nx - blockW / 2, 0, blockW, blockH);
ctx.save(); ctx.shadowColor = "rgba(0,0,0,0.70)"; ctx.shadowBlur = 4; ctx.shadowOffsetY = -1;
const botBlock = ctx.createLinearGradient(nx - 4, 0, nx + 4, 0);
botBlock.addColorStop(0, "#8a0d00"); botBlock.addColorStop(0.5, "#dd2a10"); botBlock.addColorStop(1, "#8a0d00");
ctx.fillStyle = botBlock; ctx.fillRect(nx - 4, CH - blockH * 0.85, 8, blockH * 0.85); ctx.restore();
ctx.restore();
ctx.save();
const bezelTop = ctx.createLinearGradient(0, this.mY, 0, this.mY + 6); bezelTop.addColorStop(0, "rgba(255,255,255,0.40)"); bezelTop.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = bezelTop; ctx.fillRect(this.mX, this.mY, this.mW, 6);
const bezelBot = ctx.createLinearGradient(0, this.mY + this.mH - 6, 0, this.mY + this.mH); bezelBot.addColorStop(0, "rgba(0,0,0,0.00)"); bezelBot.addColorStop(1, "rgba(0,0,0,0.60)");
ctx.fillStyle = bezelBot; ctx.fillRect(this.mX, this.mY + this.mH - 6, this.mW, 6); ctx.restore();
ctx.save(); this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR); ctx.strokeStyle = "rgba(255,255,255,0.15)"; ctx.lineWidth = 1; ctx.stroke();
this.rrect(ctx, this.mX + 1, this.mY + 1, this.mW - 2, this.mH - 2, mR - 1); ctx.strokeStyle = "rgba(0,0,0,0.50)"; ctx.lineWidth = 1; ctx.stroke(); ctx.restore();
ctx.save(); this.rrect(ctx, this.mX, this.mY, this.mW, this.mH, mR); ctx.clip();
const glare = ctx.createLinearGradient(0, this.mY, 0, this.mY + this.mH * 0.22); glare.addColorStop(0, "rgba(255,255,255,0.08)"); glare.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = glare; ctx.fillRect(this.mX, this.mY, this.mW, this.mH * 0.22);
ctx.beginPath(); ctx.moveTo(this.mX + this.mW * 0.05, this.mY); ctx.lineTo(this.mX + this.mW * 0.30, this.mY); ctx.lineTo(this.mX + this.mW * 0.18, this.mY + this.mH * 0.55); ctx.lineTo(this.mX + this.mW * 0.00, this.mY + this.mH * 0.55); ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.03)"; ctx.fill(); ctx.restore();
ctx.restore();
},
drawKnob(canvas) {
if (!canvas) return false;
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const CW = canvas.width / dpr;
const CH = canvas.height / dpr;
if(CW === 0 || CH === 0) return false;
const T = this.getTheme();
ctx.save(); ctx.scale(dpr, dpr); ctx.clearRect(0, 0, CW, CH);
ctx.fillStyle = this.rgba(T.bg1, 1.0); ctx.fillRect(0, 0, CW, CH);
this.drawConcentricKnob(ctx, this.knobX, this.knobY, this.knobOuterR, this.knobInnerR, T);
ctx.restore();
return true;
},
drawVuMeter(canvas, levelL, levelR) {
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const ctx = canvas.getContext("2d");
const cw = canvas.width;
const ch = canvas.height;
if(cw === 0 || ch === 0) return;
ctx.save();
ctx.scale(dpr, dpr);
const w = cw / dpr;
const h = ch / dpr;
const T = this.getTheme();
ctx.fillStyle = this.rgba(T.bg1, 1.0);
ctx.fillRect(0, 0, w, h);
const mR = 8;
const mX = 5;
const vY = this.mY;
const vH = this.mH;
const vW = w - 20
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.85)";
ctx.shadowBlur = 10;
ctx.shadowOffsetY = 4;
ctx.shadowOffsetX = 2;
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.fillStyle = this.rgba(T.bg1, 1.0);
ctx.fill();
ctx.restore();
ctx.save();
ctx.filter = `brightness(${this.getBrightness()})`;
const paperX = mX + 2;
const paperY = vY + 2;
const paperW = vW - 4;
const paperH = vH - 4;
ctx.fillStyle = this.rgba(T.bg2, 0.5);
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
const paper = ctx.createRadialGradient(paperX + paperW * 0.5, paperY + paperH * 0.5, paperH * 0.1, paperX + paperW * 0.5, paperY + paperH * 0.5, paperW * 0.65);
paper.addColorStop(0.00, this.rgba(T.accent, 0.80));
paper.addColorStop(0.40, this.rgba(T.accent, 0.50));
paper.addColorStop(1.00, this.rgba(T.accent, 0.10));
ctx.fillStyle = paper;
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
ctx.save();
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.clip();
const innerTop = ctx.createLinearGradient(0, paperY, 0, paperY + 15);
innerTop.addColorStop(0, "rgba(0,0,0,0.7)");
innerTop.addColorStop(1, "rgba(0,0,0,0)");
ctx.fillStyle = innerTop;
ctx.fillRect(paperX, paperY, paperW, 15);
const vignette = ctx.createRadialGradient(paperX + paperW/2, paperY + paperH/2, paperW*0.3, paperX + paperW/2, paperY + paperH/2, paperW*0.6);
vignette.addColorStop(0, "rgba(0,0,0,0)");
vignette.addColorStop(1, "rgba(0,0,0,0.5)");
ctx.fillStyle = vignette;
this.rrect(ctx, paperX, paperY, paperW, paperH, 2);
ctx.fill();
ctx.restore();
const cx1 = paperX + paperW * 0.26;
const cx2 = paperX + paperW * 0.74;
const cy = paperY + paperH * 0.85;
const radius = Math.min(paperW * 0.21, paperH * 0.48);
const minAngle = -1.00;
const maxAngle = 1.00;
this.drawDial(ctx, cx1, cy, radius, minAngle, maxAngle);
this.drawDial(ctx, cx2, cy, radius, minAngle, maxAngle);
const angleL = minAngle + (levelL * (maxAngle - minAngle));
const angleR = minAngle + (levelR * (maxAngle - minAngle));
this.drawNeedle(ctx, cx1, cy, radius * 0.9, angleL);
this.drawNeedle(ctx, cx2, cy, radius * 0.9, angleR);
ctx.restore();
ctx.save();
const bezelTop = ctx.createLinearGradient(0, vY, 0, vY + 6);
bezelTop.addColorStop(0, "rgba(255,255,255,0.40)");
bezelTop.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = bezelTop;
ctx.fillRect(mX, vY, vW, 6);
const bezelBot = ctx.createLinearGradient(0, vY + vH - 6, 0, vY + vH);
bezelBot.addColorStop(0, "rgba(0,0,0,0.00)");
bezelBot.addColorStop(1, "rgba(0,0,0,0.60)");
ctx.fillStyle = bezelBot;
ctx.fillRect(mX, vY + vH - 6, vW, 6);
ctx.restore();
ctx.save();
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.strokeStyle = "rgba(255,255,255,0.15)";
ctx.lineWidth = 1;
ctx.stroke();
this.rrect(ctx, mX + 1, vY + 1, vW - 2, vH - 2, mR - 1);
ctx.strokeStyle = "rgba(0,0,0,0.50)";
ctx.lineWidth = 1;
ctx.stroke();
ctx.restore();
ctx.save();
this.rrect(ctx, mX, vY, vW, vH, mR);
ctx.clip();
const glare = ctx.createLinearGradient(0, vY, 0, vY + vH * 0.22);
glare.addColorStop(0, "rgba(255,255,255,0.08)");
glare.addColorStop(1, "rgba(255,255,255,0.00)");
ctx.fillStyle = glare;
ctx.fillRect(mX, vY, vW, vH * 0.22);
ctx.beginPath();
ctx.moveTo(mX + vW * 0.05, vY);
ctx.lineTo(mX + vW * 0.30, vY);
ctx.lineTo(mX + vW * 0.18, vY + vH * 0.55);
ctx.lineTo(mX + vW * 0.00, vY + vH * 0.55);
ctx.closePath();
ctx.fillStyle = "rgba(255,255,255,0.03)";
ctx.fill();
ctx.restore();
ctx.restore();
},
drawDial(ctx, cx, cy, radius, minAngle, maxAngle) {
const inkColor = "rgba(10, 15, 20, 0.85)";
const redColor = "#e63946";
const zeroAngle = minAngle + 0.8 * (maxAngle - minAngle);
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.6)";
ctx.shadowBlur = 3;
ctx.shadowOffsetY = 1;
ctx.shadowOffsetX = 1;
ctx.lineWidth = 3.5;
ctx.lineCap = 'round';
ctx.beginPath();
ctx.arc(cx, cy, radius, Math.PI * 1.5 + minAngle, Math.PI * 1.5 + zeroAngle);
ctx.strokeStyle = inkColor; ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, radius, Math.PI * 1.5 + zeroAngle, Math.PI * 1.5 + maxAngle);
ctx.strokeStyle = redColor; ctx.stroke();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, radius - 4, Math.PI * 1.5 + minAngle, Math.PI * 1.5 + zeroAngle);
ctx.strokeStyle = inkColor; ctx.stroke();
ctx.beginPath();
ctx.arc(cx, cy, radius - 4, Math.PI * 1.5 + zeroAngle, Math.PI * 1.5 + maxAngle);
ctx.strokeStyle = redColor; ctx.stroke();
const ticks =[
{ label: '-20', pos: 0.0, isRed: false, major: true },
{ label: '10', pos: 0.25, isRed: false, major: true },
{ label: '7', pos: 0.4, isRed: false, major: true },
{ label: '5', pos: 0.52, isRed: false, major: true },
{ label: '', pos: 0.58, isRed: false, major: false },
{ label: '3', pos: 0.64, isRed: false, major: true },
{ label: '', pos: 0.69, isRed: false, major: false },
{ label: '1', pos: 0.74, isRed: false, major: true },
{ label: '0', pos: 0.8, isRed: false, major: true },
{ label: '1', pos: 0.86, isRed: true, major: true },
{ label: '', pos: 0.895, isRed: true, major: false },
{ label: '3', pos: 0.93, isRed: true, major: true },
{ label: '', pos: 0.965, isRed: true, major: false },
{ label: '+5', pos: 1.0, isRed: true, major: true }
];
ctx.textAlign = 'center';
ctx.textBaseline = 'bottom';
ticks.forEach(t => {
const angle = minAngle + t.pos * (maxAngle - minAngle);
const aRad = Math.PI * 1.5 + angle;
ctx.strokeStyle = t.isRed ? redColor : inkColor;
ctx.fillStyle = t.isRed ? redColor : inkColor;
ctx.lineWidth = t.major ? 2.5 : 1.5;
const innerR = radius - 4;
const outerR = t.major ? radius + 8 : radius + 3;
ctx.beginPath();
ctx.moveTo(cx + Math.cos(aRad) * innerR, cy + Math.sin(aRad) * innerR);
ctx.lineTo(cx + Math.cos(aRad) * outerR, cy + Math.sin(aRad) * outerR);
ctx.stroke();
if (t.label) {
const fontSize = Math.max(9, radius * 0.12);
ctx.font = `bold ${fontSize}px "Arial Narrow", "Helvetica Neue", Arial, sans-serif`;
const textR = radius + (radius * 0.30);
ctx.fillText(t.label, cx + Math.cos(aRad) * textR, cy + Math.sin(aRad) * textR + (fontSize * 0.4));
}
});
const vuSize = Math.max(10, radius * 0.18);
ctx.font = `bold ${vuSize}px "Arial Narrow", Arial, sans-serif`;
ctx.fillStyle = inkColor;
ctx.fillText("VU", cx, cy - (radius * 0.25));
ctx.restore();
},
drawNeedle(ctx, cx, cy, length, angle) {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(angle);
ctx.shadowColor = "rgba(0,0,0,0.60)";
ctx.shadowBlur = 4;
ctx.shadowOffsetX = 2;
ctx.shadowOffsetY = 1;
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -length);
ctx.strokeStyle = "#ff3300"; ctx.lineWidth = 2.5; ctx.stroke();
ctx.shadowColor = "transparent";
ctx.beginPath();
ctx.moveTo(0, 0); ctx.lineTo(0, -length);
ctx.strokeStyle = "rgba(255,200,200,0.60)"; ctx.lineWidth = 0.9; ctx.stroke();
ctx.beginPath();
ctx.arc(0, 0, length * 0.08, 0, Math.PI * 2);
ctx.fillStyle = "#111"; ctx.fill();
ctx.restore();
},
renderLoop() {
if (!this.scaleCanvas || !this.isVisible) { this.rafId = null; return; }
const now = Date.now();
if (now - this.lastRenderTime < 16) {
this.rafId = requestAnimationFrame(() => this.renderLoop());
return;
}
this.lastRenderTime = now;
this.tryInitAudio();
let targetL = 0; let targetR = 0;
if (this.audioInitialized && this.audioCtx && this.isVuEnabled()) {
this.analyserL.getFloatTimeDomainData(this.dataL);
this.analyserR.getFloatTimeDomainData(this.dataR);
targetL = this.getLevel(this.dataL);
targetR = this.getLevel(this.dataR);
}
const attack = 0.35;
const decay = 0.08;
this.currentVuLeft += (targetL > this.currentVuLeft) ? (targetL - this.currentVuLeft) * attack : (targetL - this.currentVuLeft) * decay;
this.currentVuRight += (targetR > this.currentVuRight) ? (targetR - this.currentVuRight) * attack : (targetR - this.currentVuRight) * decay;
if (this.isVuEnabled() && this.vuCanvas) {
this.drawVuMeter(this.vuCanvas, this.currentVuLeft, this.currentVuRight);
}
if (Math.abs(this.targetStationScrollY - this.stationScrollY) > 0.5) {
this.stationScrollY += (this.targetStationScrollY - this.stationScrollY) * 0.15;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
if (this.animFreq !== null) {
if (!this.isDraggingScale) {
const diff = this.currentFreq - this.animFreq;
this.animFreq += Math.abs(diff) > 0.002 ? diff * this.SMOOTHING : diff;
}
this.drawScale(this.scaleCanvas, this.animFreq);
}
if (!this.isDraggingOuterKnob && Math.abs(this.outerVelocity) > 0.001) {
this.outerVelocity *= this.FRICTION;
this.applyKnobRotation(true, this.outerVelocity);
} else if (!this.isDraggingOuterKnob) {
this.outerVelocity = 0;
}
if (!this.isDraggingInnerKnob && Math.abs(this.innerVelocity) > 0.001) {
this.innerVelocity *= this.FRICTION;
this.applyKnobRotation(false, this.innerVelocity);
} else if (!this.isDraggingInnerKnob) {
this.innerVelocity = 0;
}
const currentKnobState = `${this.outerKnobAngle}_${this.innerKnobAngle}_${this.isFineTuningMode}`;
if (this.knobCanvas && this.lastKnobState !== currentKnobState) {
if (this.drawKnob(this.knobCanvas) !== false) {
this.lastKnobState = currentKnobState;
}
}
this.rafId = requestAnimationFrame(() => this.renderLoop());
},
addButton() {
if (!pluginConfig.ENABLE_ANALOG_SCALE) return;
if (this._btnAdding) return;
const BTN_ID = "et-analog-scale-btn";
if (document.getElementById(BTN_ID)) return;
this._btnAdding = true;
document.head.insertAdjacentHTML("beforeend", ``);
const tryAdd = (attempt = 0) => {
if (typeof addIconToPluginPanel === "function") {
try {
addIconToPluginPanel(BTN_ID, "Analog Scale", "solid", "ruler-horizontal", `Retro Design Elements V1.2 ET
By Highpoint`);
setTimeout(() => { const btn = document.getElementById(BTN_ID); if (btn) btn.classList.add("hide-phone"); }, 50);
} catch(e) {}
const btn = document.getElementById(BTN_ID);
if(btn) btn.addEventListener("click", () => this.toggle());
this._btnAdding = false;
return;
}
if (attempt < 60) setTimeout(() => tryAdd(attempt + 1), 500);
else this._btnAdding = false;
};
tryAdd();
},
removeButton() {
const btn = document.getElementById("et-analog-scale-btn");
if (btn) btn.remove();
this._btnAdding = false;
},
hookPS() {
const psElement = document.getElementById("data-ps");
const freqElement = document.getElementById("data-frequency");
if (!psElement || !freqElement) { setTimeout(() => this.hookPS(), 1000); return; }
let deleteTimer = null; let activeDeleteFreq = null;
let lastFreq = null; let freqSettledTime = Date.now();
let lastPS = null; let psSettledTime = Date.now();
const saveCurrentPS = () => {
if (this.currentFreq === null) return;
if (this.currentFreq !== lastFreq) { lastFreq = this.currentFreq; freqSettledTime = Date.now(); }
const ps = (psElement.innerText || psElement.textContent || "").replace(/\u00A0/g, ' ').trim();
if (ps !== lastPS) { lastPS = ps; psSettledTime = Date.now(); }
const freqStr = (Math.round(this.currentFreq * 10) / 10).toFixed(1);
let db = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
const isValidPS = ps.length >= 3 && ps !== freqStr && !ps.match(/^[.\-]+$/) && !ps.includes("MHz");
const isOnCenter = Math.abs(this.currentFreq - parseFloat(freqStr)) <= 0.015;
if (isValidPS) {
if (deleteTimer) { clearTimeout(deleteTimer); deleteTimer = null; activeDeleteFreq = null; }
if (Date.now() - freqSettledTime < 800) {
return;
}
let shouldSave = true;
for (let key in db) {
if (db[key].ps === ps && key !== freqStr) {
if (Math.abs(parseFloat(key) - parseFloat(freqStr)) <= 0.2) { shouldSave = false; break; }
}
}
if (shouldSave) {
let entry = db[freqStr] || { ps: ps };
let isUpdated = false;
if (entry.ps !== ps) {
entry.ps = ps; isUpdated = true;['name','city','itu','erp','pol','dist','azi'].forEach(k => delete entry[k]);
}
if (entry.active !== true) { entry.active = true; isUpdated = true; }
if (Date.now() - freqSettledTime > 2000 && Date.now() - psSettledTime > 2000) {
const domFreq = parseFloat(freqElement.innerText);
if (Math.abs(domFreq - parseFloat(freqStr)) < 0.02) {
const getT = (id) => { const el = document.getElementById(id); return el ? el.innerText.trim() : ""; };
const meta = { name: 'data-station-name', city: 'data-station-city', itu: 'data-station-itu', erp: 'data-station-erp', pol: 'data-station-pol', dist: 'data-station-distance', azi: 'data-station-azimuth' };
for (let m in meta) {
let val = getT(meta[m]);
if (val && entry[m] !== val) { entry[m] = val; isUpdated = true; }
}
}
}
if (isUpdated || !db[freqStr]) {
entry.lastSeen = Date.now();
db[freqStr] = entry;
localStorage.setItem('retro_station_db', JSON.stringify(db));
if (this.isVisible && this.scaleCanvas) this.drawScale(this.scaleCanvas, this.currentFreq);
}
}
} else {
if (isOnCenter && db[freqStr]) {
if (activeDeleteFreq !== freqStr) {
clearTimeout(deleteTimer); activeDeleteFreq = freqStr;
deleteTimer = setTimeout(() => {
let currentDb = JSON.parse(localStorage.getItem('retro_station_db') || '{}');
if (currentDb[freqStr]) {
delete currentDb[freqStr]; localStorage.setItem('retro_station_db', JSON.stringify(currentDb));
if (this.isVisible && this.scaleCanvas) this.drawScale(this.scaleCanvas, this.currentFreq);
}
activeDeleteFreq = null;
}, 3000);
}
} else {
if (deleteTimer) {
clearTimeout(deleteTimer); deleteTimer = null; activeDeleteFreq = null;
}
}
}
};
new MutationObserver(() => setTimeout(saveCurrentPS, 300)).observe(psElement, { childList: true, subtree: true, characterData: true });
new MutationObserver(saveCurrentPS).observe(freqElement, { childList: true, subtree: true, characterData: true });
setInterval(saveCurrentPS, 2000);
},
setupRetroTooltip() {
let tooltip = document.getElementById('retro-station-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'retro-station-tooltip';
tooltip.style.cssText = `position:fixed; background:rgba(15,25,30,0.95); color:#eee; padding:10px 14px; border-radius:6px; border:1px solid rgba(80,160,180,0.3); font-family:Arial,sans-serif; font-size:13px; z-index:90000; pointer-events:none; display:none; box-shadow:0 4px 12px rgba(0,0,0,0.5); backdrop-filter:blur(4px); min-width:150px;`;
document.body.appendChild(tooltip);
}
window.addEventListener('mousemove', (evt) => {
if (!this.isVisible || !this.scaleCanvas) return;
let hovered = null;
for (let st of this.renderedStations) {
if (evt.clientX >= this.scaleCanvas.getBoundingClientRect().left + st.left && evt.clientX <= this.scaleCanvas.getBoundingClientRect().left + st.right &&
evt.clientY >= this.scaleCanvas.getBoundingClientRect().top + st.top && evt.clientY <= this.scaleCanvas.getBoundingClientRect().top + st.bottom) {
hovered = st; break;
}
}
if (hovered) {
if (this.hoveredFreq !== hovered.f) {
this.hoveredFreq = hovered.f;
if (this.animFreq !== null) this.drawScale(this.scaleCanvas, this.animFreq);
}
const d = hovered.data;
const isSw = hovered.isSw;
const displayName = isSw ? d.name : (d.name || d.ps);
let freqStr = "";
if (this.currentUnit === 'kHz') {
freqStr = `${Math.round(hovered.f)} kHz`;
} else {
freqStr = `${hovered.f.toFixed(isSw ? 3 : 1)} MHz`;
}
let html = `