Merge pull request #1738 from bilawalsidhu/manjunath/visual-panel

Visual panel: scope opacity, a reset, and grouped controls
This commit is contained in:
Sameh Khamis authored and GitHub committed 2026-11-14 15:51:02 -08:00
commit cd785b1cec
45 files changed
+1224 -50

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
- Group the Visual panel into Look, Map source, Overlay and Effects with a
Reset at its foot, and add a Scope opacity slider (50–100%) that share links
carry when it is below full; Reset keeps the HUD layout, and the HUD gets
shadows when Scope is off (manjunath22466, samehkhamis, #1738).
+49
View File
@@ -646,6 +646,53 @@ panel that holds its row, and `set_panel_open` reaches it as
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
it sits collapsed in the Layers sheet under Data Layers.
## The Visual panel — November 14, 2026
Visual (`#pp-toggles`, `src/ui/views/panels/DisplayPanel.vue`) is headed
VISUAL, has the edge switch in its header like the other side panels, and is
grouped from the top: Look, the six style presets with their 1–6 keys, and
Map source, the map source chips (`VisualPresets.vue`); Overlay, the HUD,
detection, style parameters, 3D aircraft, Scope and Clean UI; Effects,
Celestial, Bloom and Sharpen; and Reset to default at the foot. Each control
is a card (`src/ui/styles/visual-panel.css`) whose header row ends in an
on/off pill worded in the current language (Clean UI, an action, has none;
the buttons carry their state as `aria-pressed`). Under the Cyber HUD the
cards keep Cyber's own red skin. The presets and chips call the
same Display store actions as the dock's Visual Presets tray, which stays on
desktop and phone as before, along with Location; voice `Visual Presets`
still opens that tray. On a phone the More sheet shows that tray right above
Visual, so Visual leaves out its own presets and chips there. In Cockpit the
groups Cockpit's Display popover takes
leave the panel as before and keep their own styling there.
Scope has an Opacity slider (`#scope-opacity-slider`, 50–100%, default 100%;
`SCOPE_OPACITY_MIN_PCT` in `src/ui/visualSettings.js`, since a fainter mask
reads as Scope off while labels still fade outside the keyhole)
that fades the whole mask (`setScopeMaskOpacity` in `src/scopeMask.js`, the
canvas's CSS opacity) without repainting it or changing the altitude
terminus; it is disabled while Scope is off and keeps its value. Share links
carry it as `sco` in the long form and field 30 in the compact form, written
only below 100%, so links made before it keep their codes and open at full
opacity, and a link without it sets the opacity back to 100%; a link asking
for less than 50% opens at 50%. Scenes do not
save it: a scene shot leaves the opacity as it is.
Reset to default (`resetVisual` in `src/ui/displayBindings.js`) brings back
the out-of-the-box look: the Normal style, the first-run post defaults
(bloom off, sharpen on at 49%, the HUD shown, Dense detection at 75%
with its fade and outside opacity, the celestial ring off), Elastic
allocation, Scope on at 100% feather and 100% opacity, and 3D aircraft on in
Proximity. The HUD layout follows the Normal style, as choosing it does: the
Tactical HUD, except that the Cyber HUD stays Cyber. Inside Contacts, which
lends detection for the session, the reset's detection becomes what leaving
Contacts puts back and what a share link carries. It leaves the map source, the pinned scope terminus, Sonar's
settings, other styles' parameters, the layers and the panels alone.
With Scope off the side rails, open panels and the dock's trays carry a soft
drop shadow and the rails' labels and icons a text shadow
(`src/ui/styles/scope-off-shadows.css`), so their edges hold against bright
imagery.
## Side rails and the screen edge — November 14, 2026
Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last
@@ -768,6 +815,8 @@ Display's native Scope checkbox defaults ON with feather at 100%, adjustable
from 0–200%. OFF disables the slider while retaining its value. HUD layout
changes preserve both settings; share links and scenes restore them. The
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
Its Opacity slider (0–100%, default 100%) fades the mask; share links
restore it (`sco`), scenes do not (see The Visual panel).
With Scope OFF, Tactical and Minimal add text-sized black backplates
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
+5
View File
@@ -148,6 +148,7 @@ probability of about 255 in 256 (many of them already fail as base64url).
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
| 29 | `library` | LEN | none | Data Library ledger tokens, two base-36 characters each, concatenated |
| 30 | `scopeOpacityPct` | uint | 100 | 0–100, how strongly the scope mask shows |
Field 1 absent means `thing` when field 13 is present and `place` otherwise,
so v1 never writes it; a later view kind writes its code.
@@ -170,6 +171,10 @@ tokens in `src/library/ledger.json` (the long form's `lib=ab.cd`). A share
without it switches no library entry on; the recipient's added entries stay
in their panel, off. Tokens this build does not know are skipped.
Field 30 is how strongly the scope mask shows, from the Visual panel's
Opacity slider (the long form's `sco`). It is written only below 100, so a
link made before it, which opens at full opacity, keeps its code.
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
+1 -1
View File
@@ -26,7 +26,7 @@
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
names a glyph this list is missing, because a missing glyph does not draw
a box — the ligature never forms and the element renders the literal word. -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20,400,0,0&icon_names=adjust,align_horizontal_left,align_horizontal_right,arrow_drop_down,arrow_forward,arrow_left,arrow_right,bolt,bug_report,category,chevron_left,chevron_right,close,close_fullscreen,dark_mode,delete,directions_boat,draw,east,eco,egg_alt,flare,flight,handyman,layers,layers_clear,light_mode,local_fire_department,location_on,more_horiz,movie,my_location,navigation,normal,on,open_in_full,palette,partly_cloudy_day,pause,pentagon,pest_control,pets,play_arrow,public,question_mark,radar,radio,raven,restart_alt,right_panel_close,right_panel_open,rocket_launch,satellite_alt,search,set_meal,settings,skip_next,skip_previous,streetview,timeline,traffic,tune,umbrella,upload_file,videocam,view_in_ar,visibility,visibility_off,water_drop,zoom_in_map" rel="stylesheet" />
</head>
<body>
<div class="gev-view" data-gev-view="scene-chrome"></div>
+3 -1
View File
@@ -1459,5 +1459,7 @@
"src/ui/styles/left-tools.css",
"src/ui/styles/panel-edge.css",
"src/data/placementProvider.test.mjs",
"src/layers/vessels/placement.test.mjs"
"src/layers/vessels/placement.test.mjs",
"src/ui/styles/visual-panel.css",
"src/ui/styles/scope-off-shadows.css"
]
+1
View File
@@ -102,6 +102,7 @@
"src/ui/stores/streetLevel.js",
"src/ui/rail/RailChips.vue",
"src/ui/views/panels/DisplayPanel.vue",
"src/ui/views/panels/VisualPresets.vue",
"src/ui/views/panels/GeoImport.vue",
"src/ui/views/panels/DrawControls.vue",
"src/ui/views/panels/DrawMarkList.vue",
+2 -2
View File
@@ -530,12 +530,12 @@ test('Cockpit owns a focused shared Display portal and compact Radio controls',
);
});
test('Display orders 3D above Celestial, Clean UI below it, and Parameters below Detection', () => {
test('Visual orders Parameters below Detection, 3D below them, and Clean UI above the Effects', () => {
const display = html.match(/id="pp-toggles"[\s\S]*?id="clean-view-exit"/);
assert.ok(display, 'Display markup is missing');
assert.match(
display[0],
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="celestial-toggle"[\s\S]*?id="clean-view-toggle"/,
/id="detection-toggle"[\s\S]*?id="param-slider-panel"[\s\S]*?id="models3d-toggle"[\s\S]*?id="scope-toggle"[\s\S]*?id="clean-view-toggle"[\s\S]*?class="pp-section-heading">Effects<[\s\S]*?id="celestial-toggle"/,
);
assert.equal(
(html.match(/id="param-slider-panel"/g) || []).length,
+9 -1
View File
@@ -3969,5 +3969,13 @@
"Funicular": "قطار جبلي مائل",
"Gondola": "تلفريك",
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة"
"Move panels over the rail, to the edge": "نقل اللوحات فوق الشريط إلى الحافة",
"panel|VISUAL": "المرئيات",
"Look": "المظهر",
"Overlay": "التراكب",
"Effects": "المؤثرات",
"Scope opacity": "عتامة المنظار",
"How dark the scope shades the view outside the keyhole": "مدى تعتيم المنظار للعرض خارج الفتحة",
"Reset to default": "إعادة التعيين إلى الافتراضي",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "إعادة المرئيات إلى إعداداتها الافتراضية: المظهر العادي وHUD والمنظار والتسميات والمؤثرات"
}
+9 -1
View File
@@ -3621,5 +3621,13 @@
"Funicular": "Standseilbahn",
"Gondola": "Gondelbahn",
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben"
"Move panels over the rail, to the edge": "Bedienfelder über die Leiste an den Rand verschieben",
"panel|VISUAL": "DARSTELLUNG",
"Look": "Look",
"Overlay": "Overlay",
"Effects": "Effekte",
"Scope opacity": "Scope-Deckkraft",
"How dark the scope shades the view outside the keyhole": "Wie stark der Scope die Ansicht außerhalb des Schlüssellochs abdunkelt",
"Reset to default": "Auf Standard zurücksetzen",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Darstellung auf die Standardwerte zurücksetzen: normaler Look, HUD, Scope, Beschriftungen und Effekte"
}
+9 -1
View File
@@ -3708,5 +3708,13 @@
"Funicular": "Funicular",
"Gondola": "Góndola",
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde"
"Move panels over the rail, to the edge": "Mover los paneles sobre la barra, hasta el borde",
"panel|VISUAL": "VISUAL",
"Look": "Aspecto",
"Overlay": "Superposición",
"Effects": "Efectos",
"Scope opacity": "Opacidad de la mirilla",
"How dark the scope shades the view outside the keyhole": "Cuánto oscurece la mirilla la vista fuera del ojo de cerradura",
"Reset to default": "Restablecer valores predeterminados",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Restablecer Visual a sus valores predeterminados: aspecto Normal, HUD, mirilla, etiquetas y efectos"
}
+9 -1
View File
@@ -3708,5 +3708,13 @@
"Funicular": "Funiculaire",
"Gondola": "Télécabine",
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord"
"Move panels over the rail, to the edge": "Déplacer les panneaux par-dessus la barre, jusqu’au bord",
"panel|VISUAL": "VISUEL",
"Look": "Aspect",
"Overlay": "Surcouche",
"Effects": "Effets",
"Scope opacity": "Opacité de la lunette",
"How dark the scope shades the view outside the keyhole": "À quel point la lunette assombrit la vue hors de l'ouverture",
"Reset to default": "Rétablir les valeurs par défaut",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Rétablir les valeurs par défaut de Visuel : aspect Normal, HUD, lunette, libellés et effets"
}
+9 -1
View File
@@ -3708,5 +3708,13 @@
"Funicular": "פוניקולר",
"Gondola": "רכבל",
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה"
"Move panels over the rail, to the edge": "הזזת הלוחות מעל הסרגל, אל הקצה",
"panel|VISUAL": "חזותי",
"Look": "מראה",
"Overlay": "שכבת על",
"Effects": "אפקטים",
"Scope opacity": "אטימות הסקופ",
"How dark the scope shades the view outside the keyhole": "עד כמה הסקופ מכהה את התצוגה מחוץ לחור המנעול",
"Reset to default": "איפוס לברירת המחדל",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "איפוס החזותי לברירות המחדל: מראה רגיל, HUD, סקופ, תוויות ואפקטים"
}
+9 -1
View File
@@ -3708,5 +3708,13 @@
"Funicular": "Funicolare",
"Gondola": "Cabinovia",
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo"
"Move panels over the rail, to the edge": "Sposta i pannelli sopra la barra, fino al bordo",
"panel|VISUAL": "VISUALE",
"Look": "Aspetto",
"Overlay": "Overlay",
"Effects": "Effetti",
"Scope opacity": "Opacità dello scope",
"How dark the scope shades the view outside the keyhole": "Quanto lo scope scurisce la vista fuori dal foro",
"Reset to default": "Ripristina predefiniti",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Ripristina Visuale ai valori predefiniti: aspetto Normale, HUD, scope, etichette ed effetti"
}
+9 -1
View File
@@ -3534,5 +3534,13 @@
"Funicular": "ケーブルカー",
"Gondola": "ゴンドラ",
"Move panels back beside the rail": "パネルをレールの横に戻す",
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動"
"Move panels over the rail, to the edge": "パネルをレールの上、画面端へ移動",
"panel|VISUAL": "ビジュアル",
"Look": "見た目",
"Overlay": "オーバーレイ",
"Effects": "エフェクト",
"Scope opacity": "スコープの不透明度",
"How dark the scope shades the view outside the keyhole": "キーホール外のビューをスコープがどれだけ暗くするか",
"Reset to default": "既定に戻す",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "ビジュアルを既定に戻す:標準の見た目、HUD、スコープ、ラベル、エフェクト"
}
+9 -1
View File
@@ -3708,5 +3708,13 @@
"Funicular": "Funicular",
"Gondola": "Teleférico",
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda"
"Move panels over the rail, to the edge": "Mover os painéis sobre a barra, até a borda",
"panel|VISUAL": "VISUAL",
"Look": "Aparência",
"Overlay": "Sobreposição",
"Effects": "Efeitos",
"Scope opacity": "Opacidade da mira",
"How dark the scope shades the view outside the keyhole": "Quanto a mira escurece a vista fora do visor",
"Reset to default": "Redefinir para o padrão",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Redefinir o Visual para o padrão: aparência Normal, HUD, mira, rótulos e efeitos"
}
+9 -1
View File
@@ -3795,5 +3795,13 @@
"Funicular": "Фуникулёр",
"Gondola": "Канатная дорога",
"Move panels back beside the rail": "Вернуть панели к боковой панели",
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю"
"Move panels over the rail, to the edge": "Переместить панели поверх боковой панели к краю",
"panel|VISUAL": "ВИЗУАЛ",
"Look": "Вид",
"Overlay": "Наложение",
"Effects": "Эффекты",
"Scope opacity": "Непрозрачность прицела",
"How dark the scope shades the view outside the keyhole": "Насколько прицел затемняет вид за пределами маски",
"Reset to default": "Сбросить по умолчанию",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Сбросить «Визуал» к настройкам по умолчанию: обычный вид, HUD, прицел, подписи и эффекты"
}
+9 -1
View File
@@ -3621,5 +3621,13 @@
"Funicular": "Bergbana",
"Gondola": "Kabinbana",
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten"
"Move panels over the rail, to the edge": "Flytta panelerna över listen, till kanten",
"panel|VISUAL": "VISUELLT",
"Look": "Utseende",
"Overlay": "Överlägg",
"Effects": "Effekter",
"Scope opacity": "Scope-opacitet",
"How dark the scope shades the view outside the keyhole": "Hur mycket scopet mörkar vyn utanför nyckelhålet",
"Reset to default": "Återställ till standard",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Återställ Visuellt till standard: Normal stil, HUD, scope, etiketter och effekter"
}
+9 -1
View File
@@ -3795,5 +3795,13 @@
"Funicular": "Фунікулер",
"Gondola": "Канатна дорога",
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю"
"Move panels over the rail, to the edge": "Перемістити панелі поверх бічної смуги до краю",
"panel|VISUAL": "ВІЗУАЛ",
"Look": "Вигляд",
"Overlay": "Накладка",
"Effects": "Ефекти",
"Scope opacity": "Непрозорість прицілу",
"How dark the scope shades the view outside the keyhole": "Наскільки приціл затемнює вигляд за межами прицілу",
"Reset to default": "Скинути до типових",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "Скинути «Візуал» до типових налаштувань: звичайний вигляд, HUD, приціл, підписи та ефекти"
}
+9 -1
View File
@@ -3534,5 +3534,13 @@
"Funicular": "缆索铁路",
"Gondola": "缆车",
"Move panels back beside the rail": "将面板移回侧栏旁",
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘"
"Move panels over the rail, to the edge": "将面板移到侧栏上方,贴靠边缘",
"panel|VISUAL": "视觉",
"Look": "外观",
"Overlay": "叠加层",
"Effects": "效果",
"Scope opacity": "视域不透明度",
"How dark the scope shades the view outside the keyhole": "视域遮罩使视域之外的画面变暗的程度",
"Reset to default": "恢复默认",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "将视觉恢复为默认设置:常规外观、HUD、视域、标签和效果"
}
+9 -1
View File
@@ -3534,5 +3534,13 @@
"Funicular": "纜索鐵路",
"Gondola": "纜車",
"Move panels back beside the rail": "將面板移回側欄旁",
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣"
"Move panels over the rail, to the edge": "將面板移到側欄上方,貼齊邊緣",
"panel|VISUAL": "視覺",
"Look": "外觀",
"Overlay": "疊加層",
"Effects": "效果",
"Scope opacity": "視域不透明度",
"How dark the scope shades the view outside the keyhole": "視域遮罩使視域之外的畫面變暗的程度",
"Reset to default": "還原預設",
"Reset Visual to its defaults: Normal look, HUD, scope, labels and effects": "將視覺還原為預設設定:常規外觀、HUD、視域、標籤和效果"
}
+5
View File
@@ -113,6 +113,7 @@
"Layout",
"Live",
"London",
"Look",
"Los Angeles",
"MIL",
"MINI",
@@ -140,6 +141,7 @@
"Oldies",
"OpenStreetMap + optional Google Maps Places",
"Operator",
"Overlay",
"PAUSE",
"PLAY",
"POS: LIVE",
@@ -383,6 +385,7 @@
"mph",
"nominal",
"panel",
"panel|VISUAL",
"view band|GLOBAL",
"voice|ERROR",
"{hours} h",
@@ -732,6 +735,7 @@
"ON",
"ON/OFF",
"Oldies",
"Overlay",
"PAYLOAD",
"PLAY",
"POS: LIVE",
@@ -1047,6 +1051,7 @@
"mini",
"mph",
"nominal",
"panel|VISUAL",
"via {source}",
"view band|GLOBAL",
"{hours} h",
+1 -1
View File
@@ -286,7 +286,7 @@ test('expanded right panels highlight the title divider without changing collaps
assert.match(
html,
/class="compact pp-header-row"[\s\S]*?class="pp-header-label">DISPLAY<\/span>[\s\S]*?class="panel-divider"/,
/class="compact pp-header-row"[\s\S]*?class="pp-header-label">VISUAL<\/span>[\s\S]*?class="panel-divider"/,
);
assert.match(
css,
+1 -1
View File
@@ -253,7 +253,7 @@ test('detection-on-by-default is a default, not an operator override', () => {
// A factory default is not that. If applying the baseline set the flag, a
// fresh session would silently lose the style auto-enable behaviour — a
// separate landed feature, taken out by an unrelated change.
const applyDefaults = uiBlock(' _applyGlobalPostDefaults() {', '\n }\n');
const applyDefaults = uiBlock(' _applyGlobalPostDefaults({ keepHudVariant = false } = {}) {', '\n }\n');
assert.match(applyDefaults, /this\._setDetectionMode\(defaults\.detectionMode\)/,
'the baseline still goes through the real detection path');
assert.doesNotMatch(applyDefaults, /_detectionUserOverridden/,
+25
View File
@@ -89,6 +89,12 @@ let _container = null;
let _viewer = null;
let _enabled = true;
let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
/**
* How strongly the whole mask shows, from the Display panel's Opacity slider:
* a fade over the painted mask, separate from the altitude terminus above,
* so the terminus band and its repaint gate stay as they are.
*/
let _maskOpacity = 1;
let _resizeObserver = null;
let _dprQuery = null;
let _dprListener = null;
@@ -433,6 +439,7 @@ export function installScopeMask(viewer) {
_canvas = document.createElement('canvas');
_canvas.id = 'scope-mask';
_canvas.setAttribute('aria-hidden', 'true');
_canvas.style.opacity = String(_maskOpacity);
_container.appendChild(_canvas);
_resizeObserver = new ResizeObserver(() => draw());
_resizeObserver.observe(_container);
@@ -537,6 +544,23 @@ export function getScopeMaskFeather() {
return _featherRatio;
}
/**
* @param {number} ratio - How strongly the mask shows, 0 (clear) to 1.
* @returns {void}
*/
export function setScopeMaskOpacity(ratio) {
_maskOpacity = Math.max(
0,
Math.min(1, Number.isFinite(Number(ratio)) ? Number(ratio) : 1),
);
if (_canvas) _canvas.style.opacity = String(_maskOpacity);
}
/** @returns {number} */
export function getScopeMaskOpacity() {
return _maskOpacity;
}
/**
* Tear the mask down (canvas + observer). Reinstall with installScopeMask.
* @returns {void}
@@ -567,6 +591,7 @@ export function _resetScopeMaskForTest() {
_viewer = null;
_enabled = true;
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
_maskOpacity = 1;
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
_terminusOverride = null;
_terminusRepaints = 0;
+33
View File
@@ -19,6 +19,8 @@ import {
SCOPE_TERMINUS_MAX_PCT,
SCOPE_FEATHER_RATIO_DEFAULT,
getScopeMaskFeather,
setScopeMaskOpacity,
getScopeMaskOpacity,
SCOPE_OUTSIDE_ALPHA,
SCOPE_TERMINUS_ALPHA_NEAR,
SCOPE_TERMINUS_FAR_M,
@@ -205,6 +207,37 @@ test('scope appearance follows direct state changes and install/destroy lifecycl
}
});
test('scope opacity fades the painted mask without repainting it', () => {
const dom = stubScopeMaskDom();
try {
assert.equal(getScopeMaskOpacity(), 1, 'full by default');
setScopeMaskOpacity(0.4);
installScopeMask({ container: dom.container });
assert.equal(dom.canvas.style.opacity, '0.4', 'a choice made before install applies');
const paints = { ...dom.ops };
const terminus = getScopeTerminusAlpha();
setScopeMaskOpacity(0.25);
assert.equal(dom.canvas.style.opacity, '0.25');
assert.deepEqual(dom.ops, paints, 'the mask is not repainted');
assert.equal(getScopeTerminusAlpha(), terminus, 'the terminus is its own setting');
setScopeMaskOpacity(3);
assert.equal(getScopeMaskOpacity(), 1);
setScopeMaskOpacity(-1);
assert.equal(getScopeMaskOpacity(), 0);
setScopeMaskOpacity('soft');
assert.equal(getScopeMaskOpacity(), 1, 'not a number is full');
setScopeMaskOpacity(0.5);
destroyScopeMask();
installScopeMask({ container: dom.container });
assert.equal(dom.canvas.style.opacity, '0.5', 'reinstall keeps it');
_resetScopeMaskForTest();
assert.equal(getScopeMaskOpacity(), 1, 'test reset restores full');
} finally {
destroyScopeMask();
dom.restore();
}
});
test('a DPR change with no resize still repaints the backing store', () => {
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
try {
+1
View File
@@ -43,6 +43,7 @@ export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
'celestialRing',
'scopeEnabled',
'scopeFeatherPct',
'scopeOpacityPct',
'scopeTerminusPct',
'mapStack',
'layerState',
+7
View File
@@ -393,6 +393,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
// Data Library entries switched on, as their two-character ledger tokens
// concatenated (src/library/ledger.json). Absent means none.
{ number: 29, key: 'library', type: 'bytes', default: null },
{ number: 30, key: 'scopeOpacityPct', type: 'uint', default: 100 },
]);
const FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
@@ -780,6 +781,12 @@ export function normalizeShareViewState(input = {}) {
state.scopeFeatherPct = finite(source.scopeFeatherPct)
? clampScopeFeatherPct(Number(source.scopeFeatherPct))
: d.scopeFeatherPct;
state.scopeOpacityPct = intOr(
source.scopeOpacityPct,
d.scopeOpacityPct,
0,
100,
);
state.scopeTerminusPct = intOr(
source.scopeTerminusPct,
null,
+51
View File
@@ -119,6 +119,7 @@ function randomState(random) {
detectionFadePct: maybe(0.5, int(0, 40)),
detectionOutsideOpacityPct: maybe(0.5, int(0, 100)),
scopeFeatherPct: maybe(0.5, int(0, 200)),
scopeOpacityPct: maybe(0.5, int(0, 100)),
scopeTerminusPct: maybe(
0.3,
int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT),
@@ -210,6 +211,7 @@ test('each setting costs bytes only when it differs from its default', () => {
detectionFadePct: 8,
detectionOutsideOpacityPct: 2,
scopeFeatherPct: 99,
scopeOpacityPct: 40,
scopeTerminusPct: 95,
};
const defaults = createDefaultShareViewState();
@@ -355,6 +357,7 @@ test('every registered field round-trips at once', () => {
celestialRing: true,
scopeEnabled: false,
scopeFeatherPct: 35,
scopeOpacityPct: 60,
scopeTerminusPct: 97,
mapStack: 'esri-imagery',
layerState,
@@ -509,6 +512,7 @@ test('the field registry is well formed and permanent', () => {
[27, 'styleParams', BY_STYLE],
[28, 'worldTime', null],
[29, 'library', null],
[30, 'scopeOpacityPct', 100],
],
);
assert.deepEqual(
@@ -1586,3 +1590,50 @@ test('Data Library tokens round-trip and malformed ones are skipped', () => {
assert.equal(normalizeLibraryTokens(null), null);
assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []);
});
test('scope opacity is field 30; a link made before it opens at full opacity', () => {
// Made before field 30 existed: thermal, Cyber, Scope off at 35% feather
// and a pinned terminus.
const before = 'ARD-7j4YuxMguBcoUEgQcAOAAQPIASPQAWGa';
const old = decodeOk(before).state;
assert.equal(old.scopeOpacityPct, 100);
assert.equal(old.scopeFeatherPct, 35);
assert.equal(encodeShareView(old), before, 'it still encodes to itself');
const state = {
lat: 51.5007,
lon: -0.1246,
alt: 1500,
heading: 40,
style: 'thermal',
scopeEnabled: false,
scopeFeatherPct: 35,
scopeTerminusPct: 97,
hudVariant: 'cyber',
};
assert.equal(encodeShareView(state), before);
assert.equal(encodeShareView({ ...state, scopeOpacityPct: 100 }), before);
const faded = assertRoundTrip({ ...state, scopeOpacityPct: 45 });
assert.notEqual(faded, before);
assert.equal(decodeOk(faded).state.scopeOpacityPct, 45);
assert.equal(
decodeOk(assertRoundTrip({ scopeOpacityPct: 0 })).state.scopeOpacityPct,
0,
);
// Out of range is clamped; anything not a number is the default.
assert.equal(
normalizeShareViewState({ scopeOpacityPct: 150 }).scopeOpacityPct,
100,
);
assert.equal(
normalizeShareViewState({ scopeOpacityPct: -5 }).scopeOpacityPct,
0,
);
assert.equal(
normalizeShareViewState({ scopeOpacityPct: 33.6 }).scopeOpacityPct,
34,
);
assert.equal(
normalizeShareViewState({ scopeOpacityPct: 'x' }).scopeOpacityPct,
100,
);
});
+23
View File
@@ -121,6 +121,29 @@ test('scope feather above 100% survives share parsing and serialization', () =>
clearTimeout(manager._debounceTimer);
});
test('scope opacity rides as sco only below full, and through the compact link', () => {
// Links without `sco`, every link made before it, open at full opacity.
assert.equal(makeManager('#v=2&lat=10&lon=20&scf=35').parseInitialHash().scopeOpacityPct, 100);
assert.equal(makeManager('#v=2&lat=10&lon=20&sco=40').parseInitialHash().scopeOpacityPct, 40);
assert.equal(makeManager('#v=2&lat=10&lon=20&sco=250').parseInitialHash().scopeOpacityPct, 100);
assert.equal(makeManager('#v=2&lat=10&lon=20&sco=-3').parseInitialHash().scopeOpacityPct, 0);
assert.equal(makeManager('#v=2&lat=10&lon=20&sco=x').parseInitialHash().scopeOpacityPct, 100);
const manager = makeManager();
manager.onToggleChange(false, true, {});
const before = manager._buildHashParams().toString();
assert.equal(manager._buildHashParams().has('sco'), false, 'full opacity writes nothing');
manager.onToggleChange(false, true, { scopeOpacityPct: 100 });
assert.equal(manager._buildHashParams().toString(), before);
manager.onToggleChange(false, true, { scopeOpacityPct: 45.4 });
assert.equal(manager._buildHashParams().get('sco'), '45');
assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 45);
manager.onToggleChange(false, true, { scopeOpacityPct: 100 });
assert.equal(manager._buildHashParams().has('sco'), false);
assert.equal(sharedState(manager.buildLink()).scopeOpacityPct, 100);
clearTimeout(manager._debounceTimer);
});
test('share-link serialization emits the current celestial state', () => {
const manager = makeManager();
manager.onToggleChange(false, false, { celestialRingEnabled: false });
+16
View File
@@ -104,6 +104,9 @@ export class ShareLinkManager {
this._celestialRingEnabled = false;
this._scopeEnabled = true;
this._scopeFeatherPct = 100;
// How strongly the scope mask shows, 0..100; 100 (the full mask) is the
// default and leaves `sco` out of the link.
this._scopeOpacityPct = 100;
// null = the altitude-adaptive terminus (the default). A number pins the
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
this._scopeTerminusPct = null;
@@ -286,6 +289,10 @@ export class ShareLinkManager {
// mirrors live state for the link this session generates, so it must match
// the mask, not the archive.)
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
scopeOpacityPct: Math.max(
0,
Math.min(100, Math.round(parseOr(params.get('sco'), 100))),
),
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
// a value pins the terminus opacity percent, clamped into the SUPPORTED
// 94..100 band. `sce=0` used to survive as a sub-94 terminus — a hole in
@@ -459,6 +466,7 @@ export class ShareLinkManager {
celestialRing: visualCurrent ? state.celestialRing : undefined,
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
scopeOpacityPct: visualCurrent ? state.scopeOpacityPct : undefined,
scopeTerminusPct: visualCurrent ? state.scopeTerminusPct : undefined,
mapStack: mapCurrent ? state.mapStack : undefined,
panelState,
@@ -625,6 +633,12 @@ export class ShareLinkManager {
if (typeof extras.scopeFeatherPct === 'number') {
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
}
if (typeof extras.scopeOpacityPct === 'number') {
this._scopeOpacityPct = Math.max(
0,
Math.min(100, Math.round(extras.scopeOpacityPct)),
);
}
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
else if (typeof extras.scopeTerminusPct === 'number') {
this._scopeTerminusPct = clampScopeTerminusPct(extras.scopeTerminusPct);
@@ -740,6 +754,8 @@ export class ShareLinkManager {
params.set('cr', this._celestialRingEnabled ? '1' : '0');
params.set('sc', this._scopeEnabled ? '1' : '0');
params.set('scf', Math.round(this._scopeFeatherPct).toString());
if (this._scopeOpacityPct !== 100)
params.set('sco', String(this._scopeOpacityPct));
// Only written when pinned — an absent `sce` IS the adaptive default, so a
// shared link never freezes the ramp for the recipient by accident. The
// same 94..100 clamp applies on the way OUT, so a link can never carry an
+3 -1
View File
@@ -25,7 +25,9 @@ const items = computed(() =>
id: panel.id,
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
// Static markup is translated at boot, so a host's title already is.
label: item ? t(item.label) : panel.title,
label: item
? t(item.label, item.context && { context: item.context })
: panel.title,
};
}),
);
+6
View File
@@ -226,6 +226,7 @@ export class StyleManager extends ShellFacade {
getDetectionTuning: services.getDetectionTuning,
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
getScopeMaskFeather: services.getScopeMaskFeather,
getScopeMaskOpacity: services.getScopeMaskOpacity,
getScopeTerminusOverride: services.getScopeTerminusOverride,
governorRequestRender: services.governorRequestRender,
holdContinuousRender: services.holdContinuousRender,
@@ -239,6 +240,7 @@ export class StyleManager extends ShellFacade {
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
setScopeMaskEnabled: services.setScopeMaskEnabled,
setScopeMaskFeather: services.setScopeMaskFeather,
setScopeMaskOpacity: services.setScopeMaskOpacity,
},
elements: {
_ppToggles: this._ppToggles,
@@ -594,6 +596,10 @@ export class StyleManager extends ShellFacade {
this._setDetectionAllocation(...args),
_setDetectionFade: (...args) => this._setDetectionFade(...args),
_setScope: (...args) => this._visualSettings._setScope(...args),
_applyGlobalPostDefaults: (...args) =>
this._applyGlobalPostDefaults(...args),
_adoptDetectionAsRestore: () =>
this._visualSettings.adoptDetectionAsRestore(),
setCelestialRingEnabled: (...args) =>
this.setCelestialRingEnabled(...args),
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
+4
View File
@@ -56,6 +56,8 @@ import {
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeMaskOpacity,
getScopeMaskOpacity,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
@@ -112,6 +114,8 @@ export class StyleManager extends ApplicationShell {
isScopeMaskEnabled,
setScopeMaskFeather,
getScopeMaskFeather,
setScopeMaskOpacity,
getScopeMaskOpacity,
setScopeTerminusOverride,
getScopeTerminusOverride,
clampScopeTerminusPct,
+45 -4
View File
@@ -1,4 +1,11 @@
import { getScopeMaskFeather, isScopeMaskEnabled, setScopeMaskEnabled, setScopeMaskFeather } from '../scopeMask.js';
import {
getScopeMaskFeather,
getScopeMaskOpacity,
isScopeMaskEnabled,
setScopeMaskEnabled,
setScopeMaskFeather,
setScopeMaskOpacity,
} from '../scopeMask.js';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
@@ -282,7 +289,7 @@ test('scope choice and expanded feather survive every HUD layout and both restor
let shown = null;
owner.store = { publish: (view) => (shown = view.scope) };
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
assert.deepEqual(shown, { enabled: false, featherPct: 200 });
assert.deepEqual(shown, { enabled: false, featherPct: 200, opacityPct: 100 });
for (const layout of ['minimal', 'cyber', 'tactical']) {
owner._setHudVariant(layout, { applyVisualDefaults: true });
assert.equal(isScopeMaskEnabled(), false, layout);
@@ -292,10 +299,44 @@ test('scope choice and expanded feather survive every HUD layout and both restor
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
assert.equal(isScopeMaskEnabled(), true);
assert.equal(getScopeMaskFeather(), 1.75);
assert.deepEqual(shown, { enabled: true, featherPct: 175 });
assert.deepEqual(shown, { enabled: true, featherPct: 175, opacityPct: 100 });
await owner.applyVisualState({ scope: { featherPct: 500 } });
assert.equal(getScopeMaskFeather(), 2);
assert.deepEqual(shown, { enabled: true, featherPct: 200 });
assert.deepEqual(shown, { enabled: true, featherPct: 200, opacityPct: 100 });
});
test('a shared scope opacity is restored and rides in the next share state', async (t) => {
const { owner } = styleOwner(t, 'tactical');
const previous = getScopeMaskOpacity();
t.after(() => setScopeMaskOpacity(previous));
Object.assign(owner.services, {
setScopeMaskEnabled() {},
setScopeMaskFeather() {},
setScopeMaskOpacity,
});
let shown = null;
owner.store = { publish: (view) => (shown = view.scope) };
await owner.restoreShareState({ scopeOpacityPct: 60 });
assert.equal(getScopeMaskOpacity(), 0.6);
assert.equal(shown.opacityPct, 60);
// A state without it leaves the opacity alone.
await owner.restoreShareState({ scopeFeatherPct: 80 });
assert.equal(getScopeMaskOpacity(), 0.6);
assert.equal(shown.opacityPct, 60);
Object.assign(owner.services, {
getDetectionTuning: () => ({ allocationStrategy: 'ELASTIC' }),
getDetectionMode: () => 'OFF',
isScopeMaskEnabled: () => true,
getScopeMaskFeather: () => 0.8,
getScopeMaskOpacity,
getScopeTerminusOverride: () => null,
});
owner.readCelestialRing = () => null;
assert.equal(owner._readShareState().options.scopeOpacityPct, 60);
// A link asking for less than the floor opens at the floor, and says so.
await owner.restoreShareState({ scopeOpacityPct: 20 });
assert.equal(getScopeMaskOpacity(), 0.5);
assert.equal(owner._readShareState().options.scopeOpacityPct, 50);
});
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
+27
View File
@@ -105,6 +105,11 @@ export class DisplayBindings {
this._setScope({ featherPct: value });
this._syncShareState();
},
setScopeOpacity: (value) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setScope({ opacityPct: Number(value) });
this._syncShareState();
},
setSharpenIntensity: (pct) => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this._setSharpenIntensity(pct);
@@ -168,6 +173,28 @@ export class DisplayBindings {
this._syncModels3dModeRow();
},
setModelsMode: (mode) => this._setModels3dMode(mode),
// Back to the out-of-the-box look: Normal style, the global post
// defaults (bloom, sharpen, HUD, labels, celestial), scope on at full
// feather and opacity, 3D aircraft on in Proximity.
resetVisual: () => {
this.shareLinkManager?.claimRestoreLane?.('visual');
this.setStyle('normal', { userInitiated: true });
// The HUD layout follows the Normal style, as choosing it does: the
// Cyber HUD stays Cyber.
this._applyGlobalPostDefaults({ keepHudVariant: true });
this._setDetectionAllocation('ELASTIC');
this._setScope({ enabled: true, featherPct: 100, opacityPct: 100 });
this._setModels3dEnabled(true);
this._setModels3dMode('proximity');
this._syncModels3dModeRow?.();
// Inside Contacts the detection shown is lent for the session, and
// leaving puts back the state from before. A reset there is the user's
// choice, so the defaults become what leaving puts back and what a
// share link carries.
this.claimDetection();
this._adoptDetectionAsRestore?.();
this._syncShareState();
},
});
this._detachDisplayControls = () => store.detach(token);
}
+105 -9
View File
@@ -108,7 +108,11 @@ test('bloom and sharpen render their state, slider and readout', async (t) => {
await nextTick();
assert.equal($('bloom-toggle').classList.contains('active'), false);
assert.equal($('bloom-slider-row').classList.contains('visible'), false);
assert.equal($('bloom-intensity-value').textContent, '140%', 'the amount stays');
assert.equal(
$('bloom-intensity-value').textContent,
'140%',
'the amount stays',
);
});
test('a publish never rewrites a slider the user is dragging', async (t) => {
@@ -314,6 +318,73 @@ test('Scope and Celestial render what VisualSettings shows', async (t) => {
assert.equal(celestial.title, 'Celestial ring — reveal the full globe');
});
test('scope opacity renders what VisualSettings shows and sets the mask', async (t) => {
const { store, $ } = fixture(t);
const { owner } = settings(store);
const mask = { opacity: 1 };
owner.services = {
...owner.services,
setScopeMaskEnabled() {},
setScopeMaskOpacity: (value) => (mask.opacity = value),
};
assert.equal($('scope-opacity-slider').value, '100');
assert.equal($('scope-opacity-value').textContent, '100%');
owner._setScope({ opacityPct: 62.4 });
await nextTick();
assert.equal(mask.opacity, 0.62);
assert.equal($('scope-opacity-slider').value, '62');
assert.equal($('scope-opacity-value').textContent, '62%');
assert.equal(store.view.scope.opacityPct, 62);
// No fainter than the floor, where the view would read as Scope off.
owner._setScope({ opacityPct: 10 });
await nextTick();
assert.equal(mask.opacity, 0.5, 'floored');
assert.equal($('scope-opacity-value').textContent, '50%');
assert.equal($('scope-opacity-slider').min, '50');
// A value that is not a number changes nothing.
owner._setScope({ opacityPct: Number.NaN });
await nextTick();
assert.equal($('scope-opacity-value').textContent, '50%');
owner._setScope({ opacityPct: 180 });
await nextTick();
assert.equal(mask.opacity, 1, 'clamped');
assert.equal($('scope-opacity-value').textContent, '100%');
owner._setScope({ opacityPct: 70 }, { apply: false });
await nextTick();
assert.equal(mask.opacity, 1, 'only shown');
assert.equal($('scope-opacity-value').textContent, '70%');
owner._setScope({ enabled: false });
await nextTick();
assert.equal($('scope-opacity-slider').disabled, true, 'off with the scope');
assert.equal(store.view.scope.opacityPct, 70, 'kept while off');
});
test('the Visual panel groups its controls and words its on/off pills', (t) => {
const { $ } = fixture(t);
const panel = $('pp-toggles');
assert.deepEqual(
[
...panel.querySelectorAll(
'.pp-section-heading, .visual-presets__heading',
),
].map((el) => el.textContent.trim()),
['Look', 'MAP SOURCE 3D', 'Overlay', 'Effects'],
);
assert.equal(panel.querySelector('.pp-header-label').textContent, 'VISUAL');
const body = panel.querySelector('.pp-panel-body');
assert.equal(body.style.getPropertyValue('--pp-pill-on'), '"ON"');
assert.equal(body.style.getPropertyValue('--pp-pill-off'), '"OFF"');
const reset = panel.querySelector('.pp-reset-btn');
assert.equal(reset, body.lastElementChild, 'the reset closes the panel');
assert.equal(reset.textContent.trim(), 'restart_altReset to default');
});
test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
const { store, $ } = fixture(t);
const params = [];
@@ -331,7 +402,10 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
assert.equal(coreClasses(button), 'pp-toggle-btn active');
assert.equal(button.getAttribute('aria-pressed'), 'true');
assert.equal(row.classList.contains('visible'), true);
assert.equal($('models3d-mode-proximity').getAttribute('aria-checked'), 'true');
assert.equal(
$('models3d-mode-proximity').getAttribute('aria-checked'),
'true',
);
// The button leaves the row to its own sync.
owner._setModels3dEnabled(false);
@@ -347,7 +421,10 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
await nextTick();
assert.equal(all.classList.contains('active'), true);
assert.equal(all.getAttribute('aria-checked'), 'true');
assert.equal($('models3d-mode-proximity').classList.contains('active'), false);
assert.equal(
$('models3d-mode-proximity').classList.contains('active'),
false,
);
assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]);
owner._syncModels3dFromLayerState({
@@ -376,12 +453,18 @@ test('the Draw controls render what the draw tool publishes', async (t) => {
assert.equal(segment('area').getAttribute('aria-label'), 'Area');
assert.equal(segment('area').title, 'Area');
assert.equal(segment('area').textContent, 'pentagon');
const swatch = (color) => document.querySelector(`#draw-colors [data-color="${color}"]`);
const swatch = (color) =>
document.querySelector(`#draw-colors [data-color="${color}"]`);
assert.equal(swatch('primary').getAttribute('aria-checked'), 'true');
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
assert.equal($('draw-hint').textContent, '');
store.publishDraw({ active: true, shape: 'line', color: 'amber', hint: 'Click to add points.' });
store.publishDraw({
active: true,
shape: 'line',
color: 'amber',
hint: 'Click to add points.',
});
await nextTick();
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
assert.equal(swatch('primary').classList.contains('active'), false);
@@ -477,7 +560,10 @@ test('the imported files render what the file import publishes and keep focus',
assert.equal(document.activeElement === button(first, 'toggle'), true);
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), 'true');
assert.equal(button(first, 'fly').title, 'Fly to <b>a</b>.geojson');
assert.equal(button(first, 'remove').getAttribute('aria-label'), 'Remove <b>a</b>.geojson');
assert.equal(
button(first, 'remove').getAttribute('aria-label'),
'Remove <b>a</b>.geojson',
);
await press(button(first, 'remove'));
assert.equal(rowsNow().length, 1);
@@ -593,7 +679,11 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider
assert.equal(store.parameters, null, 'the sliders go with their owner');
assert.equal($('param-slider-panel').classList.contains('active'), false);
assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined);
assert.equal(owner.stages.noir.uniforms.grainAmt, 0.3, 'a stopped owner no longer answers');
assert.equal(
owner.stages.noir.uniforms.grainAmt,
0.3,
'a stopped owner no longer answers',
);
// An older owner's stop leaves a newer owner's rows.
const newer = { style: 'noir', rows: [] };
owner._parameters = { style: 'noir', rows: [] };
@@ -605,10 +695,16 @@ test('VisualSettings publishes a style’s parameters and writes a moved slider
test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => {
const { store, $ } = fixture(t);
const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id);
const ids = ['hud-toggle', 'detection-toggle', 'param-slider-panel', 'models3d-toggle'];
const ids = [
'hud-toggle',
'detection-toggle',
'param-slider-panel',
'models3d-toggle',
];
const body = $('pp-toggles').querySelector('.pp-panel-body');
const order = () =>
[...body.children].filter((el) => el.matches('.pp-toggle-group, #param-slider-panel'))
[...body.children]
.filter((el) => el.matches('.pp-toggle-group, #param-slider-panel'))
.map((el) => el.id || el.querySelector('[id]')?.id);
const home = order();
const popover = document.body.appendChild(document.createElement('div'));
+123
View File
@@ -8,6 +8,7 @@ import { createApp, h, nextTick } from 'vue';
import { createPinia } from 'pinia';
import { useDisplayStore } from './stores/display.js';
import { DisplayBindings } from './displayBindings.js';
import { VisualSettings } from './visualSettings.js';
import CommandDockView from './views/CommandDockView.vue';
import DisplayControlsView from './views/DisplayControlsView.vue';
import DrawControls from './views/panels/DrawControls.vue';
@@ -231,3 +232,125 @@ test('several owners answer their own actions, the newest first', (t) => {
['display', 'toggleBloom'],
]);
});
test('the Visual panel’s scope opacity, reset and presets call their actions', async (t) => {
const store = mount(t);
const { calls } = recorder(store);
const selected = [];
store.connectMapSource({ select: async (id) => selected.push(id) });
store.publishMapSource({
status: { text: 'OSM', warn: false },
chips: [
{ id: 'photoreal', label: 'Google 3D', available: false },
{ id: 'osm', label: 'OSM', available: true, active: true },
],
});
await nextTick();
input($('scope-opacity-slider'), 60);
document.querySelector('#pp-toggles .pp-reset-btn').click();
const presets = document.querySelector('#pp-toggles .visual-presets');
presets.querySelector('[data-style="noir"]').click();
const chips = presets.querySelectorAll('.visual-presets__chip');
chips[0].click();
chips[1].click();
assert.deepEqual(calls, [
['setScopeOpacity', 60],
['resetVisual'],
['setStyle', 'noir'],
]);
assert.deepEqual(selected, ['osm'], 'an unavailable source is not selected');
// The presets follow the published style, as the dock's do.
assert.equal(
presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'),
'true',
);
store.publishStyle({ name: 'noir', label: 'NOIR' });
await nextTick();
assert.equal(
presets.querySelector('[data-style="noir"]').getAttribute('aria-pressed'),
'true',
);
assert.equal(
presets.querySelector('[data-style="normal"]').getAttribute('aria-pressed'),
'false',
);
// The dock keeps its own presets and map source beside them.
assert.ok(
document.querySelector('#control-panel .style-btn[data-style="noir"]'),
);
assert.ok($('map-stack-chips'));
});
test('DisplayBindings sets the scope opacity and resets Visual to its defaults', (t) => {
const store = mount(t);
const calls = [];
const bindings = new DisplayBindings({
viewer: null,
services: { isScopeMaskEnabled: () => true, cycleDetectionMode: () => {} },
elements: { _locationSearch: document.createElement('input') },
operations: {
setStyle: (style, options) => calls.push(['style', style, options]),
_applyGlobalPostDefaults: (options) =>
calls.push(['postDefaults', options]),
_setDetectionAllocation: (value) => calls.push(['allocation', value]),
_setScope: (scope) => calls.push(['scope', scope]),
_setModels3dEnabled: (on) => calls.push(['models3d', on]),
_setModels3dMode: (mode) => calls.push(['models3dMode', mode]),
_syncModels3dModeRow: () => calls.push(['models3dRow']),
_syncShareState: () => calls.push(['share']),
_adoptDetectionAsRestore: () => calls.push(['adoptDetection']),
},
readState: () => ({
shareLinkManager: {
claimRestoreLane: (lane) => calls.push(['claim', lane]),
},
}),
claimDetection: () => calls.push(['claimDetection']),
displayStore: store,
});
bindings._initUI();
t.after(() => bindings.destroy());
calls.length = 0;
input($('scope-opacity-slider'), 65);
assert.deepEqual(calls, [
['claim', 'visual'],
['scope', { opacityPct: 65 }],
['share'],
]);
calls.length = 0;
document.querySelector('#pp-toggles .pp-reset-btn').click();
assert.deepEqual(calls, [
['claim', 'visual'],
['style', 'normal', { userInitiated: true }],
['postDefaults', { keepHudVariant: true }],
['allocation', 'ELASTIC'],
['scope', { enabled: true, featherPct: 100, opacityPct: 100 }],
['models3d', true],
['models3dMode', 'proximity'],
['models3dRow'],
['claimDetection'],
['adoptDetection'],
['share'],
]);
});
test('a reset inside Contacts makes the shown detection what leaving puts back', () => {
const adopt = VisualSettings.prototype.adoptDetectionAsRestore;
const live = { detectionMode: 'DENSE', densityPct: 75 };
const inContacts = {
_contactsDetectionRestore: { mode: 'OFF', densityPct: 50 },
getDetectionState: () => live,
};
adopt.call(inContacts);
assert.deepEqual(inContacts._contactsDetectionRestore, {
mode: 'DENSE',
densityPct: 75,
});
// Outside Contacts there is no saved state to change.
const outside = {
_contactsDetectionRestore: null,
getDetectionState: () => live,
};
adopt.call(outside);
assert.equal(outside._contactsDetectionRestore, null);
});
+4 -2
View File
@@ -9,7 +9,7 @@ export const PANEL_RAIL_FALLBACK_ICON = 'more_horiz';
/**
* How each side panel appears on its rail. Labels are the panels' own titles.
* `shownBy` names a class the panel needs to be shown at all (`f` toggles
* Data Layers' `active`).
* Data Layers' `active`). `context` is the label's translation context.
*/
export const PANEL_RAIL_ITEMS = Object.freeze({
'data-panel': {
@@ -19,7 +19,9 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
},
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
'pp-toggles': { icon: 'palette', label: msg('VISUAL') },
// `context` keeps the panel's name apart from the satellite class of the
// same English word.
'pp-toggles': { icon: 'palette', label: msg('VISUAL'), context: 'panel' },
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
'news-panel': { icon: 'public', label: msg('NEWS') },
+4 -1
View File
@@ -599,7 +599,10 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
#pp-toggles.collapsed .pp-toggle-group,
#pp-toggles.collapsed #clean-view-toggle,
#pp-toggles.collapsed #param-slider-panel {
#pp-toggles.collapsed #param-slider-panel,
#pp-toggles.collapsed .visual-presets,
#pp-toggles.collapsed .pp-section-heading,
#pp-toggles.collapsed .pp-reset-btn {
display: none;
}
+26
View File
@@ -0,0 +1,26 @@
/* With Scope off the map fills the whole screen. The HUD keeps its black
plates (overlays.css); the glass chrome lifts off the map with a soft
drop shadow. */
:root[data-scope-mask='off'] {
--scope-off-panel-shadow:
0 10px 30px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.45);
}
/* Glass surfaces (rails, the rails' name panels, open panels and
the dock) get a lift so their edges hold against bright imagery. */
:root[data-scope-mask='off'] .ui-rail,
:root[data-scope-mask='off'] .gev-panel-rail .ui-rail--expanded::after,
:root[data-scope-mask='off']
:is(.data-panel-inner, .utilities-panel-inner, .ui-panel__inner),
:root[data-scope-mask='off'] #left-panel-stack > #pp-toggles,
:root[data-scope-mask='off']
#right-context-rail
> [data-panel-id]:not(.collapsed),
:root[data-scope-mask='off'] #command-dock > * {
box-shadow: var(--scope-off-panel-shadow);
}
:root[data-scope-mask='off'] .ui-rail__label,
:root[data-scope-mask='off'] .ui-rail__item .ui-icon {
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
+349
View File
@@ -0,0 +1,349 @@
/* ── Visual panel: sections and one card style for every control ───────
Look · Map source · Overlay (HUD, detection, 3D, Scope, Clean UI) ·
Effects (Celestial, Bloom, Sharpen) · Reset. Each control is a card: a
header row with its icon, name and an on/off pill, and its settings
underneath. */
/* ── Presets and map source at the top of the panel ─────────────────── */
.visual-presets {
display: grid;
gap: 8px;
padding: 2px 0 12px;
margin-bottom: 10px;
border-bottom: 1px solid var(--glass-border);
}
.visual-presets__heading {
margin: 4px 0 0;
color: var(--text-secondary);
font: 600 10px/1.2 var(--font-mono);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.visual-presets__heading--row {
display: flex;
justify-content: space-between;
gap: 8px;
}
.visual-presets__status {
color: var(--accent);
}
.visual-presets__status.warn {
color: #ff9f43;
}
.visual-presets__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.visual-presets__style {
position: relative;
display: grid;
justify-items: center;
gap: 4px;
padding: 9px 4px 7px;
color: var(--text-secondary);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
border-radius: var(--btn-radius);
cursor: pointer;
}
.visual-presets__style:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
}
.visual-presets__style.active {
color: var(--accent);
background: var(--accent-dim);
border-color: var(--accent-dim);
}
.visual-presets__icon {
font-size: 15px;
line-height: 1;
}
.visual-presets__label {
font: 500 11px/1 var(--font-sans);
}
.visual-presets__key {
position: absolute;
top: 4px;
inset-inline-end: 5px;
color: var(--text-secondary);
font: 9px/1 var(--font-mono);
opacity: 0.6;
}
.visual-presets__chips {
display: flex;
flex-wrap: wrap;
gap: 5px;
}
.visual-presets__chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 5px 9px;
color: var(--text-secondary);
font: 500 11px/1.2 var(--font-sans);
background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--glass-border);
border-radius: 999px;
cursor: pointer;
}
.visual-presets__chip small {
font-size: 9px;
opacity: 0.6;
}
.visual-presets__chip:hover {
color: var(--text-primary);
background: rgba(255, 255, 255, 0.06);
}
.visual-presets__chip.active {
color: var(--accent);
background: var(--accent-dim);
border-color: var(--accent-dim);
}
.visual-presets__chip.unavailable {
opacity: 0.45;
cursor: default;
}
/* The phone's More sheet shows the dock's Visual Presets tray right above
Visual, so the panel leaves out its own copy there. */
body.mobile-shell #pp-toggles .visual-presets {
display: none;
}
/* The body is display: contents, so its children are the panel's own flex
items: keep them full width and unshrunk. */
#pp-toggles .pp-panel-body > * {
flex: none !important;
align-self: stretch !important;
box-sizing: border-box;
}
#pp-toggles .pp-panel-body > .pp-toggle-group,
#pp-toggles .pp-panel-body > .pp-toggle-btn {
margin-bottom: 0.4rem !important;
}
#pp-toggles .pp-section-heading,
#pp-toggles .visual-presets__heading {
margin: 0.7rem 2px 0.35rem;
text-align: start;
color: rgba(199, 214, 222, 0.55);
font-family: var(--font-mono);
font-size: 0.56rem;
font-weight: 500;
letter-spacing: 0.14rem;
text-transform: uppercase;
}
#pp-toggles .visual-presets__heading:first-child {
margin-top: 0.2rem;
}
/* The card skin below is the default look; the Cyber HUD keeps its own red
skin for these controls (cyber.css), so the skin stands aside there. */
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-group {
width: 100%;
overflow: hidden;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: 10px;
}
/* The header row of a card, and the lone Clean UI button. */
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> .pp-toggle-btn,
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-panel-body > .pp-toggle-btn {
position: relative;
box-sizing: border-box;
width: 100%;
min-height: 38px;
padding: 0 12px;
gap: 10px;
background: transparent !important;
border: 0 !important;
border-radius: 0 !important;
box-shadow: none !important;
backdrop-filter: none;
-webkit-backdrop-filter: none;
}
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-panel-body > .pp-toggle-btn {
background: rgba(255, 255, 255, 0.03) !important;
border: 1px solid rgba(255, 255, 255, 0.07) !important;
border-radius: 10px !important;
}
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn .pp-icon {
width: 18px;
font-size: 15px;
text-align: center;
color: rgba(199, 214, 222, 0.75);
}
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn .pp-label {
color: rgba(236, 244, 248, 0.9);
font-family: var(--font-mono);
font-size: 0.62rem;
letter-spacing: 0.12rem;
text-transform: uppercase;
}
/* An on/off pill at the right of every button header (Scope keeps its
checkbox, which already says it). */
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> button.pp-toggle-btn::after,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-panel-body
> button.pp-toggle-btn::after {
/* The state is the button's own (aria-pressed); the pill only shows it. */
content: var(--pp-pill-off, 'OFF') / '';
margin-inline-start: auto;
padding: 2px 8px;
color: rgba(199, 214, 222, 0.5);
font-family: var(--font-mono);
font-size: 0.5rem;
letter-spacing: 0.12rem;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: 999px;
}
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> button.pp-toggle-btn.active::after,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> button.pp-toggle-btn[aria-pressed='true']::after,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-panel-body
> button.pp-toggle-btn.active::after {
content: var(--pp-pill-on, 'ON') / '';
color: #7af0ff;
border-color: rgba(54, 220, 255, 0.5);
background: rgba(54, 220, 255, 0.1);
}
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn.active .pp-icon,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-btn[aria-pressed='true']
.pp-icon,
:root:not([data-ui-theme='cyber']) #pp-toggles .pp-toggle-btn.active .pp-label,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-btn[aria-pressed='true']
.pp-label {
color: #7af0ff;
}
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> .pp-toggle-btn:hover,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-panel-body
> .pp-toggle-btn:hover {
background: rgba(255, 255, 255, 0.04) !important;
}
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-scope-toggle
input[type='checkbox'] {
margin-inline-start: auto;
}
/* Settings inside a card: separated by hairlines, no boxes of their own. */
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
.pp-slider-row.visible,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
.pp-slider-row:not([class*='cyber-sonar']):is(
.visible,
#scope-slider-row,
#scope-opacity-row
) {
margin: 0;
padding: 7px 12px;
background: transparent;
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 0;
}
/* The Dense button lights green elsewhere; in a card it uses the shared pill. */
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> .pp-toggle-btn.panoptic,
:root:not([data-ui-theme='cyber'])
#pp-toggles
.pp-toggle-group
> .pp-toggle-btn.active {
color: inherit;
}
/* Reset to default, at the foot of the Visual panel. */
#pp-toggles .pp-reset-btn {
display: flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
margin: 0.6rem 0 0.2rem !important;
padding: 0.55rem;
color: rgba(225, 236, 242, 0.85);
font-family: var(--font-mono);
font-size: 0.6rem;
letter-spacing: 0.12rem;
text-transform: uppercase;
background: transparent;
border: 1px dashed rgba(255, 255, 255, 0.18);
border-radius: 10px;
cursor: pointer;
}
#pp-toggles .pp-reset-btn .material-symbols-outlined {
font-size: 15px;
}
#pp-toggles .pp-reset-btn:hover,
#pp-toggles .pp-reset-btn:focus-visible {
color: #fff;
border-color: rgba(54, 220, 255, 0.5);
background: rgba(54, 220, 255, 0.06);
}
/* Clean UI is an action, not a setting, so it has no on/off pill. */
#pp-toggles #clean-view-toggle::after {
content: none;
}
+31 -10
View File
@@ -5,12 +5,14 @@ import { t } from '../../../i18n/index.js';
import { vPanelState } from '../../rail/panelState.js';
import { vClassToggle } from '../../rail/classToggle.js';
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
import UiPanelEdge from '../../kit/UiPanelEdge.vue';
import UiSegmented from '../../kit/UiSegmented.vue';
import UiSlider from '../../kit/UiSlider.js';
import { useDisplayStore } from '../../stores/display.js';
import UiButton from '../../kit/UiButton.vue';
import UiSelect from '../../kit/UiSelect.js';
import { StyleParameterRows } from '../../styleParameters.js';
import VisualPresets from './VisualPresets.vue';
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
@@ -21,6 +23,12 @@ const { view, models3d, parameters, cockpitPortal } = storeToRefs(display);
// A control's change goes to its owner (DisplayBindings).
const act = (name, ...args) => display.act(name, ...args);
const pct = (value) => `${value}%`;
// The words of the on/off pill each card header draws (visual-panel.css), as
// CSS strings.
const pillWords = computed(() => ({
'--pp-pill-on': JSON.stringify(t('ON')),
'--pp-pill-off': JSON.stringify(t('OFF')),
}));
const hud = computed(() => view.value?.hud ?? null);
const sonar = computed(() => view.value?.sonar ?? null);
const sonarOn = computed(() => sonar.value?.enabled ?? true);
@@ -29,6 +37,7 @@ const detectButton = computed(() => detection.value?.button ?? null);
const allocation = computed(() => detection.value?.allocation ?? 'ELASTIC');
const scopeOn = computed(() => view.value?.scope.enabled ?? true);
const scopeFeather = computed(() => view.value?.scope.featherPct ?? 100);
const scopeOpacity = computed(() => view.value?.scope.opacityPct ?? 100);
const celestial = computed(() => view.value?.celestial ?? null);
const celestialTitle = computed(() =>
celestial.value && !celestial.value.supported
@@ -51,15 +60,18 @@ const allocationOptions = computed(() => [
<!-- Post-processing toggles (top-right) -->
<div id="pp-toggles" v-panel-state="'pp-toggles'" class="panel-collapsible collapsed ui-scroll" data-panel-id="pp-toggles">
<div class="compact pp-header-row">
<span class="pp-header-label">{{ t('DISPLAY') }}</span>
<span class="pp-header-label">{{ t('VISUAL', { context: 'panel' }) }}</span>
<span class="panel-divider" aria-hidden="true"></span>
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('DISPLAY')" sideways start-collapsed />
<UiPanelEdge />
<PanelCollapseButton class="pp-collapse-btn" target="pp-toggles" :name="t('VISUAL', { context: 'panel' })" sideways start-collapsed />
</div>
<div class="pp-panel-body ui-scroll" data-rail-scroller>
<div class="pp-panel-body ui-scroll" data-rail-scroller :style="pillWords">
<VisualPresets />
<!-- Cockpit shows these four groups in its Display popover: each teleports to the slot the Cockpit display portal publishes. While disabled a Teleport stays in place; its 'body' target only keeps server rendering from dropping it. -->
<p class="pp-section-heading">{{ t('Overlay') }}</p>
<Teleport :to="cockpitPortal?.hud ?? 'body'" :disabled="!cockpitPortal?.hud">
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" id="hud-toggle" @click="act('toggleHud')" :title="t('Intelligence HUD (H)')">
<button class="pp-toggle-btn" :class="{ active: hud?.visible }" :aria-pressed="String(Boolean(hud?.visible))" id="hud-toggle" @click="act('toggleHud')" :title="t('Intelligence HUD (H)')">
<span class="pp-icon">&#x25CE;</span>
<span class="pp-label" translate="no">HUD</span>
</button>
@@ -168,19 +180,25 @@ const allocationOptions = computed(() => [
<UiSlider class="pp-slider" id="scope-feather-slider" @update:model-value="act('setScopeFeather', $event)" min="0" max="200" :model-value="scopeFeather" :disabled="!scopeOn" :aria-label="t('Scope edge feather')" :title="t('Scope edge feather as a percentage of the keyhole radius')" />
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
</div>
<div class="pp-slider-row visible" id="scope-opacity-row">
<span class="pp-slider-mini-label">{{ t('Opacity') }}</span>
<UiSlider class="pp-slider" id="scope-opacity-slider" @update:model-value="act('setScopeOpacity', $event)" min="50" max="100" :model-value="scopeOpacity" :disabled="!scopeOn" :aria-label="t('Scope opacity')" :title="t('How dark the scope shades the view outside the keyhole')" />
<span class="pp-slider-value" id="scope-opacity-value">{{ pct(scopeOpacity) }}</span>
</div>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" :title="t('Hide UI chrome')">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label">{{ t('Clean UI') }}</span>
</button>
<p class="pp-section-heading">{{ t('Effects') }}</p>
<div class="pp-toggle-group">
<UiButton class="pp-toggle-btn" :pressed="celestial?.active ?? false" pressed-class="active" id="celestial-toggle" @click="act('toggleCelestial')" :title="celestialTitle" :disabled="celestial ? !celestial.supported : undefined" :aria-disabled="celestial ? String(!celestial.supported) : undefined">
<span class="pp-icon material-symbols-outlined" aria-hidden="true">flare</span>
<span class="pp-label">{{ t('Celestial') }}</span>
</UiButton>
</div>
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" :title="t('Hide UI chrome')">
<span class="pp-icon">&#x25A1;</span>
<span class="pp-label">{{ t('Clean UI') }}</span>
</button>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" id="bloom-toggle" @click="act('toggleBloom')" :title="t('Bloom / Glow')">
<button class="pp-toggle-btn" :class="{ active: view?.bloom.active }" :aria-pressed="String(Boolean(view?.bloom.active))" id="bloom-toggle" @click="act('toggleBloom')" :title="t('Bloom / Glow')">
<span class="pp-icon">&#x2728;</span>
<span class="pp-label">{{ t('Bloom') }}</span>
</button>
@@ -190,7 +208,7 @@ const allocationOptions = computed(() => [
</div>
</div>
<div class="pp-toggle-group">
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" id="sharpen-toggle" @click="act('toggleSharpen')" :title="t('Sharpening')">
<button class="pp-toggle-btn" :class="{ active: view?.sharpen.active }" :aria-pressed="String(Boolean(view?.sharpen.active))" id="sharpen-toggle" @click="act('toggleSharpen')" :title="t('Sharpening')">
<span class="pp-icon">&#x1F50D;</span>
<span class="pp-label">{{ t('Sharpen') }}</span>
</button>
@@ -199,6 +217,9 @@ const allocationOptions = computed(() => [
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
</div>
</div>
<button type="button" class="pp-reset-btn" :title="t('Reset Visual to its defaults: Normal look, HUD, scope, labels and effects')" @click="act('resetVisual')">
<span class="material-symbols-outlined" aria-hidden="true">restart_alt</span>{{ t('Reset to default') }}
</button>
</div>
</div>
</template>
+103
View File
@@ -0,0 +1,103 @@
<script setup>
import { storeToRefs } from 'pinia';
import { msg, t } from '../../../i18n/index.js';
import UiButton from '../../kit/UiButton.vue';
import { useDisplayStore } from '../../stores/display.js';
// The visual presets and map source at the top of the Visual panel. The same
// choices the command dock's tray offers, through the same Display store
// actions, so either surface stays in step with the other.
const display = useDisplayStore();
const { style, mapSource, mapSourceControls } = storeToRefs(display);
const STYLES = [
{
name: 'normal',
icon: '◯',
label: msg('Normal'),
key: '1',
title: msg('Show the globe without a visual filter.'),
},
{
name: 'retro',
icon: '▦',
label: msg('CRT'),
key: '2',
title: msg('Emulate a green phosphor CRT with scanlines and screen curvature.'),
},
{
name: 'surveillance',
icon: '🌙',
label: msg('NVG'),
key: '3',
title: msg('Simulate night-vision goggles with green intensification and a tube vignette.'),
},
{
name: 'thermal',
icon: '🌡️',
label: msg('FLIR'),
key: '4',
title: msg('Simulate FLIR-style thermal contrast. Turn up Ironbow for color.'),
},
{
name: 'anime',
icon: '✦',
label: msg('Anime'),
key: '5',
title: msg('Apply bright cel-shaded color and illustrated outlines.'),
},
{
name: 'noir',
icon: '◐',
label: msg('Noir'),
key: '6',
title: msg('Apply high-contrast monochrome film-noir grading.'),
},
];
function selectMapSource(chip) {
if (!chip.available) return;
void mapSourceControls.value?.select(chip.id).catch(() => {});
}
</script>
<template>
<section class="visual-presets" :aria-label="t('VISUAL PRESETS')">
<p class="visual-presets__heading">{{ t('Look') }}</p>
<div class="visual-presets__grid">
<UiButton
v-for="preset in STYLES"
:key="preset.name"
class="visual-presets__style"
:pressed="(style?.name ?? 'normal') === preset.name"
pressed-class="active"
:data-style="preset.name"
:title="t(preset.title)"
@click="display.act('setStyle', preset.name)"
>
<span class="visual-presets__icon" aria-hidden="true">{{ preset.icon }}</span>
<span class="visual-presets__label">{{ t(preset.label) }}</span>
<kbd class="visual-presets__key">{{ preset.key }}</kbd>
</UiButton>
</div>
<div class="visual-presets__heading visual-presets__heading--row">
<span>{{ t('MAP SOURCE') }}</span>
<span class="visual-presets__status" :class="{ warn: !!mapSource?.status.warn }">{{ mapSource ? mapSource.status.text : '3D' }}</span>
</div>
<div class="visual-presets__chips" role="group" :aria-label="t('Map source')">
<UiButton
v-for="chip in mapSource?.chips"
:key="chip.id"
class="visual-presets__chip"
:class="{ unavailable: !chip.available }"
:pressed="Boolean(chip.active)"
pressed-class="active"
:title="chip.title"
:aria-disabled="String(!chip.available)"
:aria-label="chip.ariaLabel"
@click="selectMapSource(chip)"
>{{ chip.label }}<small v-if="chip.requirement">{{ chip.requirement }}</small></UiButton
>
</div>
</section>
</template>
+47 -4
View File
@@ -46,6 +46,13 @@ import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
/** Own visual preferences, detection overrides and display-control state. */
/**
* The faintest the Scope mask goes. Below about half, the view reads as if
* Scope were off while labels still fade outside the keyhole and the HUD gets
* no backplates, so the slider stops here.
*/
export const SCOPE_OPACITY_MIN_PCT = 50;
export class VisualSettings {
async restoreShareState(state) {
const { setScopeTerminusOverride, clampScopeTerminusPct } = this.services;
@@ -66,6 +73,7 @@ export class VisualSettings {
celestialRing,
scopeEnabled,
scopeFeatherPct,
scopeOpacityPct,
scopeTerminusPct,
mapStack,
panelState,
@@ -133,7 +141,12 @@ export class VisualSettings {
focus: false,
});
}
this._setScope({ enabled: scopeEnabled, featherPct: scopeFeatherPct });
this._setScope({
enabled: scopeEnabled,
featherPct: scopeFeatherPct,
opacityPct:
typeof scopeOpacityPct === 'number' ? scopeOpacityPct : undefined,
});
// null restores the altitude-adaptive ramp; a number pins the terminus
// (clamped to the supported 94..100 band, same as the `sce` hash key).
if (scopeTerminusPct === null) setScopeTerminusOverride(null);
@@ -219,6 +232,7 @@ export class VisualSettings {
// What the Scope and Celestial controls show; null until first set.
this._scopeEnabledShown = null;
this._scopeFeatherShown = null;
this._scopeOpacityShown = null;
this._celestialShown = null;
this.activeStyle = 'normal';
this._preCyberStyle = null;
@@ -348,6 +362,20 @@ export class VisualSettings {
this._syncShareState();
}
/**
* While Contacts holds detection, make the detection shown now the state
* leaving Contacts puts back (and a share link carries): the user chose it,
* as with Reset in Visual. Nothing changes outside Contacts.
*/
adoptDetectionAsRestore() {
if (!this._contactsDetectionRestore) return;
const state = this.getDetectionState();
this._contactsDetectionRestore = {
mode: state.detectionMode,
densityPct: state.densityPct,
};
}
_setCockpitVision(mode, active, { revealParameters = false } = {}) {
const next = active ? normalizeCockpitVisionMode(mode) : 'optical';
if (!this.stages) return;
@@ -479,6 +507,7 @@ export class VisualSettings {
scope: {
enabled: this._scopeEnabledShown,
featherPct: this._scopeFeatherShown,
opacityPct: this._scopeOpacityShown ?? 100,
},
celestial: this._celestialShown,
};
@@ -488,7 +517,15 @@ export class VisualSettings {
* Apply the Scope mask and show it on its controls; a field that is not
* given stays as it is. `apply: false` only shows the mask's state.
*/
_setScope({ enabled, featherPct } = {}, { apply = true } = {}) {
_setScope({ enabled, featherPct, opacityPct } = {}, { apply = true } = {}) {
if (Number.isFinite(opacityPct)) {
const pct = Math.max(
SCOPE_OPACITY_MIN_PCT,
Math.min(100, Math.round(opacityPct)),
);
if (apply) this.services.setScopeMaskOpacity?.(pct / 100);
this._scopeOpacityShown = pct;
}
if (typeof enabled === 'boolean') {
if (apply) this.services.setScopeMaskEnabled(enabled);
this._scopeEnabledShown = enabled;
@@ -819,7 +856,11 @@ export class VisualSettings {
if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase());
}
_applyGlobalPostDefaults() {
/**
* @param {{keepHudVariant?: boolean}} [options] `keepHudVariant` leaves the
* HUD layout to the style, as choosing a style does (Visual's reset).
*/
_applyGlobalPostDefaults({ keepHudVariant = false } = {}) {
const defaults = GLOBAL_POST_DEFAULTS;
if (typeof defaults.bloom?.intensity === 'number') {
this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), {
@@ -838,7 +879,7 @@ export class VisualSettings {
this._setSharpenEnabled(defaults.sharpen.enabled);
}
if (defaults.hudVariant) {
if (defaults.hudVariant && !keepHudVariant) {
this._setHudVariant(defaults.hudVariant);
}
if (typeof defaults.hudVisible === 'boolean') {
@@ -883,6 +924,7 @@ export class VisualSettings {
getDetectionTuning,
isScopeMaskEnabled,
getScopeMaskFeather,
getScopeMaskOpacity,
getScopeTerminusOverride,
} = this.services;
const detection = this._shareableDetectionState();
@@ -903,6 +945,7 @@ export class VisualSettings {
celestialRingEnabled: this.celestialRingEnabled,
scopeEnabled: isScopeMaskEnabled(),
scopeFeatherPct: Math.round(getScopeMaskFeather() * 100),
scopeOpacityPct: Math.round((getScopeMaskOpacity?.() ?? 1) * 100),
// null when adaptive — the share layer omits `sce` entirely in that case.
scopeTerminusPct:
getScopeTerminusOverride() == null
+2
View File
@@ -49,5 +49,7 @@
@import './src/ui/styles/rail-split.css';
@import './src/ui/styles/left-tools.css';
@import './src/ui/styles/panel-edge.css';
@import './src/ui/styles/visual-panel.css';
@import './src/ui/styles/ai-chat.css';
@import './src/ui/styles/scope-off-shadows.css';
@import './src/ui/styles/cyber.css';