feat(styles): add tactical naval sonar visual style

Add a tactical naval sonar post-process visual style accessible via Key 8, the style picker toolbar, voice commands, and URL share links.

- Implements GLSL radial sweep beam with angular phosphor persistence decay trail, concentric range rings, and acoustic echo highlights on contacts.
- Exposes interactive tuning uniforms for sweep speed, ring density, persistence, and acoustic gain with dynamic slider panel controls.
- Integrates with URL share state serialization (sp tokens: s, r, p, g) and voice actions grammar.
- Adds comprehensive unit and integration test coverage.

Signed-off-by: Kushagra Kumar <kkushagra86@gmail.com>
This commit is contained in:
Kushagra Kumar 2026-09-09 15:39:08 +05:30
parent 759652207f
commit cd085eac91
No known key found for this signature in database
10 changed files with 187 additions and 7 deletions

View File

@ -5,6 +5,11 @@ of current runtime behavior, see [`docs/CURRENT-STATE.md`](docs/CURRENT-STATE.md
## [Unreleased]
### Added
- Tactical Naval Sonar visual style (`Key 8`) with dynamic rotating acoustic sweep,
phosphor persistence decay trail, concentric range rings, and acoustic gain controls.
### Fixed
- Mapped-site outages show their scheduled retry countdown and distinguish

View File

@ -201,7 +201,7 @@ Choose a first-run mission, or try these in order. The GIFs show Google Photorea
7. **Talk to it** *(needs an OpenAI key)*: *"Take me to LAX and select the nearest airborne aircraft."*
8. **Come home.** Hit **Reset Globe** — or just say *"zoom out to a globe view."*
**Keyboard:** `1``7` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
**Keyboard:** `1``8` visual styles · `H` HUD · `D` detection · `C` cockpit · `Esc` out.
---

View File

@ -527,6 +527,11 @@
<span class="btn-label">Snow</span>
<span class="btn-key">7</span>
</button>
<button class="style-btn" data-style="sonar" title="Simulate tactical naval sonar with rotating acoustic sweep, range rings, and phosphor persistence.">
<span class="btn-icon">📡</span>
<span class="btn-label">Sonar</span>
<span class="btn-key">8</span>
</button>
</div>
<section class="map-source-section" aria-labelledby="map-source-label">
<div class="map-source-heading">

View File

@ -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);
}
});

View File

@ -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 {

88
src/styles/sonar.js Normal file
View File

@ -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);
}
`,
};

72
src/styles/sonar.test.mjs Normal file
View File

@ -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'/);
});

View File

@ -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}

View File

@ -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;
}

View File

@ -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'],