diff --git a/src/celestialRing.test.mjs b/src/celestialRing.test.mjs
index 7260c99..a3737a4 100644
--- a/src/celestialRing.test.mjs
+++ b/src/celestialRing.test.mjs
@@ -87,7 +87,7 @@ test('circular angle distance remains small across the wrap point', () => {
test('celestial ring is available only in Normal style', () => {
assert.equal(isCelestialRingStyleSupported('normal'), true);
- for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']) {
+ for (const style of ['retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar']) {
assert.equal(isCelestialRingStyleSupported(style), false);
}
});
diff --git a/src/sharelink.js b/src/sharelink.js
index b6eb6b3..cc8ce60 100644
--- a/src/sharelink.js
+++ b/src/sharelink.js
@@ -26,6 +26,7 @@ const STYLE_TO_URL = {
anime: 'anime',
noir: 'noir',
snow: 'snow',
+ sonar: 'sonar',
};
const SHARE_UI_STATE_PARAM = 'ui';
@@ -84,6 +85,12 @@ const SHARE_STYLE_PARAM_REGISTRY = Object.freeze({
{ key: 'density', token: 'd', min: 0, max: 1 },
{ key: 'wind', token: 'w', min: 0, max: 1 },
]),
+ sonar: Object.freeze([
+ { key: 'sweepSpeed', token: 's', min: 0.1, max: 3 },
+ { key: 'ringDensity', token: 'r', min: 1, max: 10 },
+ { key: 'persistence', token: 'p', min: 0.1, max: 1 },
+ { key: 'gain', token: 'g', min: 0.5, max: 2.5 },
+ ]),
});
export class ShareLinkManager {
diff --git a/src/styles/sonar.js b/src/styles/sonar.js
new file mode 100644
index 0000000..84e325a
--- /dev/null
+++ b/src/styles/sonar.js
@@ -0,0 +1,88 @@
+/**
+ * Sonar Style — Tactical Naval / Hydrographic Acoustic Display
+ * Rotating radial sweep beam + phosphor persistence trail +
+ * concentric range rings + high-contrast acoustic echo return
+ */
+export const sonarShader = {
+ name: 'sonar',
+ uniforms: {
+ sweepSpeed: { default: 0.8, min: 0.1, max: 3.0, label: 'Sweep speed' },
+ ringDensity: { default: 4.0, min: 1.0, max: 10.0, label: 'Range rings' },
+ persistence: { default: 0.7, min: 0.1, max: 1.0, label: 'Persistence' },
+ gain: { default: 1.4, min: 0.5, max: 2.5, label: 'Acoustic gain' },
+ },
+ fragmentShader: /* glsl */ `
+ uniform sampler2D colorTexture;
+ uniform vec2 colorTextureDimensions;
+ uniform float intensity;
+ uniform float time;
+ uniform float sweepSpeed;
+ uniform float ringDensity;
+ uniform float persistence;
+ uniform float gain;
+ in vec2 v_textureCoordinates;
+
+ #define PI 3.14159265359
+ #define TWO_PI 6.28318530718
+
+ void main() {
+ vec2 uv = v_textureCoordinates;
+ vec4 color = texture(colorTexture, uv);
+
+ // Centered coordinates with aspect ratio correction
+ vec2 centered = uv * 2.0 - 1.0;
+ float aspect = colorTextureDimensions.x / max(1.0, colorTextureDimensions.y);
+ centered.x *= aspect;
+
+ float dist = length(centered);
+ float angle = atan(centered.y, centered.x);
+ if (angle < 0.0) angle += TWO_PI;
+
+ // Rotating sweep angle (clockwise)
+ float sweepAngle = mod(time * sweepSpeed, TWO_PI);
+ float sweepDiff = mod(sweepAngle - angle, TWO_PI);
+
+ // Sharp leading sweep line with smooth falloff
+ float sweepLine = smoothstep(0.045, 0.0, sweepDiff);
+
+ // Exponential phosphor persistence trail behind the sweep
+ float decayRate = 3.5 / max(0.08, persistence);
+ float trail = exp(-sweepDiff * decayRate);
+
+ // Concentric circular range rings
+ float ringPattern = abs(sin(dist * ringDensity * PI));
+ float rings = smoothstep(0.96, 0.995, ringPattern) * 0.22;
+ rings *= smoothstep(1.5, 0.2, dist);
+
+ // Subtle crosshair axes
+ float crosshairs = (smoothstep(0.003 * aspect, 0.0, abs(centered.x)) +
+ smoothstep(0.003, 0.0, abs(centered.y))) * 0.12;
+ crosshairs *= smoothstep(1.2, 0.1, dist);
+
+ // Acoustic echo response: extract scene luminance and boost contrast
+ float luma = dot(color.rgb, vec3(0.299, 0.587, 0.114));
+ float pingEcho = pow(clamp(luma * gain, 0.0, 1.0), 1.6);
+
+ // Palette: deep oceanic navy baseline -> bright phosphor cyan
+ vec3 oceanDark = vec3(0.012, 0.042, 0.11);
+ vec3 phosphorCyan = vec3(0.0, 0.96, 0.78);
+ vec3 pingBright = vec3(0.65, 1.0, 0.92);
+
+ // Illuminated echo: contacts light up as the sweep passes, decaying with the trail
+ float echoIllumination = pingEcho * (trail * 0.88 + 0.12);
+ vec3 echoColor = mix(oceanDark, phosphorCyan, echoIllumination);
+ echoColor += pingBright * pingEcho * sweepLine * 0.75;
+
+ // Add sweep beam, rings, and crosshairs
+ vec3 result = echoColor;
+ result += phosphorCyan * sweepLine * 0.45;
+ result += phosphorCyan * (rings + crosshairs) * (trail * 0.45 + 0.55);
+
+ // Circular viewport vignette
+ float vignette = smoothstep(1.45, 0.4, dist);
+ result *= vignette;
+
+ out_FragColor = vec4(mix(color.rgb, result, intensity), color.a);
+ }
+ `,
+};
diff --git a/src/styles/sonar.test.mjs b/src/styles/sonar.test.mjs
new file mode 100644
index 0000000..0f0a34a
--- /dev/null
+++ b/src/styles/sonar.test.mjs
@@ -0,0 +1,72 @@
+import test from 'node:test';
+import assert from 'node:assert/strict';
+import { sonarShader } from './sonar.js';
+
+test('sonarShader: metadata and name contract', () => {
+ assert.equal(typeof sonarShader, 'object');
+ assert.equal(sonarShader.name, 'sonar');
+ assert.equal(typeof sonarShader.fragmentShader, 'string');
+ assert.ok(sonarShader.fragmentShader.length > 100);
+});
+
+test('sonarShader: uniforms schema and bounds validity', () => {
+ const { uniforms } = sonarShader;
+ assert.ok(uniforms, 'uniforms object must be defined');
+
+ const expectedUniforms = ['sweepSpeed', 'ringDensity', 'persistence', 'gain'];
+ for (const name of expectedUniforms) {
+ const u = uniforms[name];
+ assert.ok(u, `uniform ${name} must exist`);
+ assert.equal(typeof u.default, 'number', `${name}.default must be number`);
+ assert.equal(typeof u.min, 'number', `${name}.min must be number`);
+ assert.equal(typeof u.max, 'number', `${name}.max must be number`);
+ assert.equal(typeof u.label, 'string', `${name}.label must be string`);
+ assert.ok(u.min <= u.default, `${name}.min must be <= default`);
+ assert.ok(u.default <= u.max, `${name}.default must be <= max`);
+ }
+});
+
+test('sonarShader: GLSL fragment shader declares required uniforms and ins/outs', () => {
+ const glsl = sonarShader.fragmentShader;
+
+ assert.match(glsl, /uniform\s+sampler2D\s+colorTexture;/);
+ assert.match(glsl, /uniform\s+vec2\s+colorTextureDimensions;/);
+ assert.match(glsl, /uniform\s+float\s+intensity;/);
+ assert.match(glsl, /uniform\s+float\s+time;/);
+ assert.match(glsl, /uniform\s+float\s+sweepSpeed;/);
+ assert.match(glsl, /uniform\s+float\s+ringDensity;/);
+ assert.match(glsl, /uniform\s+float\s+persistence;/);
+ assert.match(glsl, /uniform\s+float\s+gain;/);
+ assert.match(glsl, /in\s+vec2\s+v_textureCoordinates;/);
+ assert.match(glsl, /out_FragColor\s*=/);
+});
+
+test('sonarShader: contains rotational sweep and distance calculations', () => {
+ const glsl = sonarShader.fragmentShader;
+ assert.ok(glsl.includes('atan('), 'must compute angular coordinate');
+ assert.ok(glsl.includes('length('), 'must compute radial distance');
+ assert.ok(glsl.includes('smoothstep('), 'must use smoothstep for soft edges');
+ assert.ok(glsl.includes('exp('), 'must use exponential falloff for phosphor decay');
+});
+
+test('sonarShader: integration with sharelink parameters and URL tokens', async () => {
+ const sharelinkModule = await import('../sharelink.js');
+ const fileContent = await import('node:fs').then((fs) =>
+ fs.promises.readFile(new URL('../sharelink.js', import.meta.url), 'utf8')
+ );
+ assert.match(fileContent, /sonar:\s*'sonar'/);
+ assert.match(fileContent, /sonar:\s*Object\.freeze\(\[/);
+ assert.match(fileContent, /key:\s*'sweepSpeed'/);
+ assert.match(fileContent, /key:\s*'ringDensity'/);
+ assert.match(fileContent, /key:\s*'persistence'/);
+ assert.match(fileContent, /key:\s*'gain'/);
+});
+
+test('sonarShader: integration with voice actions grammar and aliases', async () => {
+ const fileContent = await import('node:fs').then((fs) =>
+ fs.promises.readFile(new URL('../voice/gevActions.js', import.meta.url), 'utf8')
+ );
+ assert.match(fileContent, /'sonar'/);
+ assert.match(fileContent, /raw === 'sonar' \|\| raw === 'tactical sonar'/);
+});
+
diff --git a/src/ui.js b/src/ui.js
index 668e752..128c6bc 100644
--- a/src/ui.js
+++ b/src/ui.js
@@ -5,6 +5,7 @@ import { noirShader } from './styles/noir.js';
import { snowShader } from './styles/snow.js';
import { nightVisionShader } from './styles/surveillance.js';
import { thermalShader } from './styles/thermal.js';
+import { sonarShader } from './styles/sonar.js';
import {
BLOOM_INTENSITY_DEFAULT,
BLOOM_SCALE_VERSION,
@@ -198,7 +199,7 @@ import {
/** Duration (ms) for shader intensity crossfade between style presets. */
const TRANSITION_DURATION_MS = 500;
/** Map of style name to its GLSL shader module for post-process stages. */
-const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader };
+const STYLES = { retro: retroShader, surveillance: nightVisionShader, thermal: thermalShader, anime: animeShader, noir: noirShader, snow: snowShader, sonar: sonarShader };
/** Versioned localStorage namespace prefix to invalidate stale panel layouts. */
const PANEL_LAYOUT_STORAGE_VERSION = 'v6';
const SHARE_PANEL_STATE_SPECS = Object.freeze([
@@ -363,6 +364,7 @@ const STYLE_STATUS_LABELS = {
anime: 'ANIME',
noir: 'NOIR',
snow: 'SNOW',
+ sonar: 'SONAR',
};
/**
* The tactical detection look: Dense at 75%.
@@ -3341,7 +3343,7 @@ export class StyleManager {
const keyMap = {
'1': 'normal', '2': 'retro', '3': 'surveillance',
'4': 'thermal', '5': 'anime', '6': 'noir',
- '7': 'snow',
+ '7': 'snow', '8': 'sonar',
};
if (keyMap[e.key]) this.setStyle(keyMap[e.key]);
if (e.key === 'Escape') {
@@ -8986,7 +8988,7 @@ export class StyleManager {
* 2. Crossfades the new shader stage intensity to 1.
* 3. Applies style preset defaults (bloom/sharpen/HUD) if applyPreset is true.
* 4. Updates button highlights, style indicator, slider panel, HUD, and detection overlay.
- * @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow').
+ * @param {string} styleName - Target style ('normal'|'retro'|'surveillance'|'thermal'|'anime'|'noir'|'snow'|'sonar').
* @param {object} [options]
* @param {boolean} [options.applyPreset=true] - Whether to apply STYLE_PRESET_DEFAULTS for the new style.
* @returns {void}
diff --git a/src/voice/gevActions.js b/src/voice/gevActions.js
index a56c840..daf77c6 100644
--- a/src/voice/gevActions.js
+++ b/src/voice/gevActions.js
@@ -21,7 +21,7 @@ import { resolveRegionRingForQuery } from '../annotations/annotationResolver.js'
import { normalizeRadioCountryInput } from '../data/radioCountry.js';
import { TR3B_CLASS } from '../data/tr3bRegistry.js';
-const ALLOWED_STYLES = new Set(['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow']);
+const ALLOWED_STYLES = new Set(['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar']);
const PANEL_ALIASES = new Map([
['data', 'data-panel'],
['data layers', 'data-panel'],
@@ -2201,6 +2201,7 @@ function normalizeStyle(value) {
if (raw === 'filter off' || raw === 'off' || raw === 'default') return 'normal';
if (raw === 'night vision' || raw === 'nvg') return 'surveillance';
if (raw === 'flir') return 'thermal';
+ if (raw === 'sonar' || raw === 'tactical sonar') return 'sonar';
if (ALLOWED_STYLES.has(raw)) return raw;
return null;
}
diff --git a/vite.config.js b/vite.config.js
index 34dac52..8a9859d 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -5893,7 +5893,7 @@ const GEV_REALTIME_TOOLS = [
properties: {
style: {
type: 'string',
- enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow'],
+ enum: ['normal', 'retro', 'surveillance', 'thermal', 'anime', 'noir', 'snow', 'sonar'],
},
},
required: ['style'],