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:
parent
759652207f
commit
cd085eac91
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
|
@ -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'/);
|
||||
});
|
||||
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
Loading…
Reference in New Issue