mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-15 15:52:21 +00:00
Merge pull request #1738 from bilawalsidhu/manjunath/visual-panel
Visual panel: scope opacity, a reset, and grouped controls
This commit is contained in:
45 files changed
+1224
-50
No files matched your search
@@ -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).
|
||||||
@@ -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
|
`utilities-panel` or by "utilities", "draw", "drawing" or "import". On a phone
|
||||||
it sits collapsed in the Layers sheet under Data Layers.
|
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
|
## Side rails and the screen edge — November 14, 2026
|
||||||
|
|
||||||
Visual (`#pp-toggles`, the display settings, rail item `palette`) is the last
|
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
|
from 0–200%. OFF disables the slider while retaining its value. HUD layout
|
||||||
changes preserve both settings; share links and scenes restore them. The
|
changes preserve both settings; share links and scenes restore them. The
|
||||||
historical missing-`scf` fallback remains 35%; explicit 0% keeps the hard edge.
|
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
|
With Scope OFF, Tactical and Minimal add text-sized black backplates
|
||||||
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
|
at 70% opacity behind telemetry, vertical metadata and scene chrome. Original
|
||||||
|
|||||||
@@ -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 |
|
| 27 | `styleParams` | LEN | style | packed `(token, value × 100)` pairs |
|
||||||
| 28 | `worldTime` | epoch | none (live) | zigzag seconds since 2026-01-01T00:00:00Z |
|
| 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 |
|
| 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,
|
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.
|
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
|
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.
|
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
|
Flag bits (field 9): 0 `bloom`, 1 `sharpen`, 2 `hudVisible`, 3
|
||||||
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
`celestialRing` (each defaulting by style), 4 `scopeEnabled` (default on).
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
with the sources: src/materialSymbolsSubset.test.mjs fails when a source
|
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
|
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. -->
|
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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
<div class="gev-view" data-gev-view="scene-chrome"></div>
|
||||||
|
|||||||
@@ -1459,5 +1459,7 @@
|
|||||||
"src/ui/styles/left-tools.css",
|
"src/ui/styles/left-tools.css",
|
||||||
"src/ui/styles/panel-edge.css",
|
"src/ui/styles/panel-edge.css",
|
||||||
"src/data/placementProvider.test.mjs",
|
"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"
|
||||||
]
|
]
|
||||||
@@ -102,6 +102,7 @@
|
|||||||
"src/ui/stores/streetLevel.js",
|
"src/ui/stores/streetLevel.js",
|
||||||
"src/ui/rail/RailChips.vue",
|
"src/ui/rail/RailChips.vue",
|
||||||
"src/ui/views/panels/DisplayPanel.vue",
|
"src/ui/views/panels/DisplayPanel.vue",
|
||||||
|
"src/ui/views/panels/VisualPresets.vue",
|
||||||
"src/ui/views/panels/GeoImport.vue",
|
"src/ui/views/panels/GeoImport.vue",
|
||||||
"src/ui/views/panels/DrawControls.vue",
|
"src/ui/views/panels/DrawControls.vue",
|
||||||
"src/ui/views/panels/DrawMarkList.vue",
|
"src/ui/views/panels/DrawMarkList.vue",
|
||||||
|
|||||||
@@ -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"/);
|
const display = html.match(/id="pp-toggles"[\s\S]*?id="clean-view-exit"/);
|
||||||
assert.ok(display, 'Display markup is missing');
|
assert.ok(display, 'Display markup is missing');
|
||||||
assert.match(
|
assert.match(
|
||||||
display[0],
|
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(
|
assert.equal(
|
||||||
(html.match(/id="param-slider-panel"/g) || []).length,
|
(html.match(/id="param-slider-panel"/g) || []).length,
|
||||||
|
|||||||
@@ -3969,5 +3969,13 @@
|
|||||||
"Funicular": "قطار جبلي مائل",
|
"Funicular": "قطار جبلي مائل",
|
||||||
"Gondola": "تلفريك",
|
"Gondola": "تلفريك",
|
||||||
"Move panels back beside the rail": "إعادة اللوحات إلى جانب الشريط",
|
"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 والمنظار والتسميات والمؤثرات"
|
||||||
}
|
}
|
||||||
@@ -3621,5 +3621,13 @@
|
|||||||
"Funicular": "Standseilbahn",
|
"Funicular": "Standseilbahn",
|
||||||
"Gondola": "Gondelbahn",
|
"Gondola": "Gondelbahn",
|
||||||
"Move panels back beside the rail": "Bedienfelder zurück neben die Leiste",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3708,5 +3708,13 @@
|
|||||||
"Funicular": "Funicular",
|
"Funicular": "Funicular",
|
||||||
"Gondola": "Góndola",
|
"Gondola": "Góndola",
|
||||||
"Move panels back beside the rail": "Devolver los paneles junto a la barra",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3708,5 +3708,13 @@
|
|||||||
"Funicular": "Funiculaire",
|
"Funicular": "Funiculaire",
|
||||||
"Gondola": "Télécabine",
|
"Gondola": "Télécabine",
|
||||||
"Move panels back beside the rail": "Remettre les panneaux à côté de la barre",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3708,5 +3708,13 @@
|
|||||||
"Funicular": "פוניקולר",
|
"Funicular": "פוניקולר",
|
||||||
"Gondola": "רכבל",
|
"Gondola": "רכבל",
|
||||||
"Move panels back beside the rail": "החזרת הלוחות לצד הסרגל",
|
"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, סקופ, תוויות ואפקטים"
|
||||||
}
|
}
|
||||||
@@ -3708,5 +3708,13 @@
|
|||||||
"Funicular": "Funicolare",
|
"Funicular": "Funicolare",
|
||||||
"Gondola": "Cabinovia",
|
"Gondola": "Cabinovia",
|
||||||
"Move panels back beside the rail": "Riporta i pannelli accanto alla barra",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3534,5 +3534,13 @@
|
|||||||
"Funicular": "ケーブルカー",
|
"Funicular": "ケーブルカー",
|
||||||
"Gondola": "ゴンドラ",
|
"Gondola": "ゴンドラ",
|
||||||
"Move panels back beside the rail": "パネルをレールの横に戻す",
|
"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、スコープ、ラベル、エフェクト"
|
||||||
}
|
}
|
||||||
@@ -3708,5 +3708,13 @@
|
|||||||
"Funicular": "Funicular",
|
"Funicular": "Funicular",
|
||||||
"Gondola": "Teleférico",
|
"Gondola": "Teleférico",
|
||||||
"Move panels back beside the rail": "Voltar os painéis para junto da barra",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3795,5 +3795,13 @@
|
|||||||
"Funicular": "Фуникулёр",
|
"Funicular": "Фуникулёр",
|
||||||
"Gondola": "Канатная дорога",
|
"Gondola": "Канатная дорога",
|
||||||
"Move panels back beside the rail": "Вернуть панели к боковой панели",
|
"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, прицел, подписи и эффекты"
|
||||||
}
|
}
|
||||||
@@ -3621,5 +3621,13 @@
|
|||||||
"Funicular": "Bergbana",
|
"Funicular": "Bergbana",
|
||||||
"Gondola": "Kabinbana",
|
"Gondola": "Kabinbana",
|
||||||
"Move panels back beside the rail": "Flytta tillbaka panelerna bredvid listen",
|
"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"
|
||||||
}
|
}
|
||||||
@@ -3795,5 +3795,13 @@
|
|||||||
"Funicular": "Фунікулер",
|
"Funicular": "Фунікулер",
|
||||||
"Gondola": "Канатна дорога",
|
"Gondola": "Канатна дорога",
|
||||||
"Move panels back beside the rail": "Повернути панелі до бічної смуги",
|
"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, приціл, підписи та ефекти"
|
||||||
}
|
}
|
||||||
@@ -3534,5 +3534,13 @@
|
|||||||
"Funicular": "缆索铁路",
|
"Funicular": "缆索铁路",
|
||||||
"Gondola": "缆车",
|
"Gondola": "缆车",
|
||||||
"Move panels back beside the rail": "将面板移回侧栏旁",
|
"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、视域、标签和效果"
|
||||||
}
|
}
|
||||||
@@ -3534,5 +3534,13 @@
|
|||||||
"Funicular": "纜索鐵路",
|
"Funicular": "纜索鐵路",
|
||||||
"Gondola": "纜車",
|
"Gondola": "纜車",
|
||||||
"Move panels back beside the rail": "將面板移回側欄旁",
|
"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、視域、標籤和效果"
|
||||||
}
|
}
|
||||||
@@ -113,6 +113,7 @@
|
|||||||
"Layout",
|
"Layout",
|
||||||
"Live",
|
"Live",
|
||||||
"London",
|
"London",
|
||||||
|
"Look",
|
||||||
"Los Angeles",
|
"Los Angeles",
|
||||||
"MIL",
|
"MIL",
|
||||||
"MINI",
|
"MINI",
|
||||||
@@ -140,6 +141,7 @@
|
|||||||
"Oldies",
|
"Oldies",
|
||||||
"OpenStreetMap + optional Google Maps Places",
|
"OpenStreetMap + optional Google Maps Places",
|
||||||
"Operator",
|
"Operator",
|
||||||
|
"Overlay",
|
||||||
"PAUSE",
|
"PAUSE",
|
||||||
"PLAY",
|
"PLAY",
|
||||||
"POS: LIVE",
|
"POS: LIVE",
|
||||||
@@ -383,6 +385,7 @@
|
|||||||
"mph",
|
"mph",
|
||||||
"nominal",
|
"nominal",
|
||||||
"panel",
|
"panel",
|
||||||
|
"panel|VISUAL",
|
||||||
"view band|GLOBAL",
|
"view band|GLOBAL",
|
||||||
"voice|ERROR",
|
"voice|ERROR",
|
||||||
"{hours} h",
|
"{hours} h",
|
||||||
@@ -732,6 +735,7 @@
|
|||||||
"ON",
|
"ON",
|
||||||
"ON/OFF",
|
"ON/OFF",
|
||||||
"Oldies",
|
"Oldies",
|
||||||
|
"Overlay",
|
||||||
"PAYLOAD",
|
"PAYLOAD",
|
||||||
"PLAY",
|
"PLAY",
|
||||||
"POS: LIVE",
|
"POS: LIVE",
|
||||||
@@ -1047,6 +1051,7 @@
|
|||||||
"mini",
|
"mini",
|
||||||
"mph",
|
"mph",
|
||||||
"nominal",
|
"nominal",
|
||||||
|
"panel|VISUAL",
|
||||||
"via {source}",
|
"via {source}",
|
||||||
"view band|GLOBAL",
|
"view band|GLOBAL",
|
||||||
"{hours} h",
|
"{hours} h",
|
||||||
|
|||||||
@@ -286,7 +286,7 @@ test('expanded right panels highlight the title divider without changing collaps
|
|||||||
|
|
||||||
assert.match(
|
assert.match(
|
||||||
html,
|
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(
|
assert.match(
|
||||||
css,
|
css,
|
||||||
|
|||||||
@@ -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
|
// 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
|
// fresh session would silently lose the style auto-enable behaviour — a
|
||||||
// separate landed feature, taken out by an unrelated change.
|
// 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\)/,
|
assert.match(applyDefaults, /this\._setDetectionMode\(defaults\.detectionMode\)/,
|
||||||
'the baseline still goes through the real detection path');
|
'the baseline still goes through the real detection path');
|
||||||
assert.doesNotMatch(applyDefaults, /_detectionUserOverridden/,
|
assert.doesNotMatch(applyDefaults, /_detectionUserOverridden/,
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ let _container = null;
|
|||||||
let _viewer = null;
|
let _viewer = null;
|
||||||
let _enabled = true;
|
let _enabled = true;
|
||||||
let _featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
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 _resizeObserver = null;
|
||||||
let _dprQuery = null;
|
let _dprQuery = null;
|
||||||
let _dprListener = null;
|
let _dprListener = null;
|
||||||
@@ -433,6 +439,7 @@ export function installScopeMask(viewer) {
|
|||||||
_canvas = document.createElement('canvas');
|
_canvas = document.createElement('canvas');
|
||||||
_canvas.id = 'scope-mask';
|
_canvas.id = 'scope-mask';
|
||||||
_canvas.setAttribute('aria-hidden', 'true');
|
_canvas.setAttribute('aria-hidden', 'true');
|
||||||
|
_canvas.style.opacity = String(_maskOpacity);
|
||||||
_container.appendChild(_canvas);
|
_container.appendChild(_canvas);
|
||||||
_resizeObserver = new ResizeObserver(() => draw());
|
_resizeObserver = new ResizeObserver(() => draw());
|
||||||
_resizeObserver.observe(_container);
|
_resizeObserver.observe(_container);
|
||||||
@@ -537,6 +544,23 @@ export function getScopeMaskFeather() {
|
|||||||
return _featherRatio;
|
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.
|
* Tear the mask down (canvas + observer). Reinstall with installScopeMask.
|
||||||
* @returns {void}
|
* @returns {void}
|
||||||
@@ -567,6 +591,7 @@ export function _resetScopeMaskForTest() {
|
|||||||
_viewer = null;
|
_viewer = null;
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
_featherRatio = SCOPE_FEATHER_RATIO_DEFAULT;
|
||||||
|
_maskOpacity = 1;
|
||||||
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
_terminusAlpha = SCOPE_OUTSIDE_ALPHA;
|
||||||
_terminusOverride = null;
|
_terminusOverride = null;
|
||||||
_terminusRepaints = 0;
|
_terminusRepaints = 0;
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
SCOPE_TERMINUS_MAX_PCT,
|
SCOPE_TERMINUS_MAX_PCT,
|
||||||
SCOPE_FEATHER_RATIO_DEFAULT,
|
SCOPE_FEATHER_RATIO_DEFAULT,
|
||||||
getScopeMaskFeather,
|
getScopeMaskFeather,
|
||||||
|
setScopeMaskOpacity,
|
||||||
|
getScopeMaskOpacity,
|
||||||
SCOPE_OUTSIDE_ALPHA,
|
SCOPE_OUTSIDE_ALPHA,
|
||||||
SCOPE_TERMINUS_ALPHA_NEAR,
|
SCOPE_TERMINUS_ALPHA_NEAR,
|
||||||
SCOPE_TERMINUS_FAR_M,
|
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', () => {
|
test('a DPR change with no resize still repaints the backing store', () => {
|
||||||
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
const dom = stubScopeMaskDom({ width: 1000, height: 800, dpr: 1 });
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const LEGACY_SHARE_STATE_KEYS = Object.freeze([
|
|||||||
'celestialRing',
|
'celestialRing',
|
||||||
'scopeEnabled',
|
'scopeEnabled',
|
||||||
'scopeFeatherPct',
|
'scopeFeatherPct',
|
||||||
|
'scopeOpacityPct',
|
||||||
'scopeTerminusPct',
|
'scopeTerminusPct',
|
||||||
'mapStack',
|
'mapStack',
|
||||||
'layerState',
|
'layerState',
|
||||||
|
|||||||
@@ -393,6 +393,7 @@ export const SHARE_VIEW_FIELDS = freezeAll([
|
|||||||
// Data Library entries switched on, as their two-character ledger tokens
|
// Data Library entries switched on, as their two-character ledger tokens
|
||||||
// concatenated (src/library/ledger.json). Absent means none.
|
// concatenated (src/library/ledger.json). Absent means none.
|
||||||
{ number: 29, key: 'library', type: 'bytes', default: null },
|
{ 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 FIELD_BY_KEY = new Map(SHARE_VIEW_FIELDS.map((f) => [f.key, f]));
|
||||||
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
const fieldNumber = (key) => FIELD_BY_KEY.get(key).number;
|
||||||
@@ -780,6 +781,12 @@ export function normalizeShareViewState(input = {}) {
|
|||||||
state.scopeFeatherPct = finite(source.scopeFeatherPct)
|
state.scopeFeatherPct = finite(source.scopeFeatherPct)
|
||||||
? clampScopeFeatherPct(Number(source.scopeFeatherPct))
|
? clampScopeFeatherPct(Number(source.scopeFeatherPct))
|
||||||
: d.scopeFeatherPct;
|
: d.scopeFeatherPct;
|
||||||
|
state.scopeOpacityPct = intOr(
|
||||||
|
source.scopeOpacityPct,
|
||||||
|
d.scopeOpacityPct,
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
);
|
||||||
state.scopeTerminusPct = intOr(
|
state.scopeTerminusPct = intOr(
|
||||||
source.scopeTerminusPct,
|
source.scopeTerminusPct,
|
||||||
null,
|
null,
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ function randomState(random) {
|
|||||||
detectionFadePct: maybe(0.5, int(0, 40)),
|
detectionFadePct: maybe(0.5, int(0, 40)),
|
||||||
detectionOutsideOpacityPct: maybe(0.5, int(0, 100)),
|
detectionOutsideOpacityPct: maybe(0.5, int(0, 100)),
|
||||||
scopeFeatherPct: maybe(0.5, int(0, 200)),
|
scopeFeatherPct: maybe(0.5, int(0, 200)),
|
||||||
|
scopeOpacityPct: maybe(0.5, int(0, 100)),
|
||||||
scopeTerminusPct: maybe(
|
scopeTerminusPct: maybe(
|
||||||
0.3,
|
0.3,
|
||||||
int(SCOPE_TERMINUS_MIN_PCT, SCOPE_TERMINUS_MAX_PCT),
|
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,
|
detectionFadePct: 8,
|
||||||
detectionOutsideOpacityPct: 2,
|
detectionOutsideOpacityPct: 2,
|
||||||
scopeFeatherPct: 99,
|
scopeFeatherPct: 99,
|
||||||
|
scopeOpacityPct: 40,
|
||||||
scopeTerminusPct: 95,
|
scopeTerminusPct: 95,
|
||||||
};
|
};
|
||||||
const defaults = createDefaultShareViewState();
|
const defaults = createDefaultShareViewState();
|
||||||
@@ -355,6 +357,7 @@ test('every registered field round-trips at once', () => {
|
|||||||
celestialRing: true,
|
celestialRing: true,
|
||||||
scopeEnabled: false,
|
scopeEnabled: false,
|
||||||
scopeFeatherPct: 35,
|
scopeFeatherPct: 35,
|
||||||
|
scopeOpacityPct: 60,
|
||||||
scopeTerminusPct: 97,
|
scopeTerminusPct: 97,
|
||||||
mapStack: 'esri-imagery',
|
mapStack: 'esri-imagery',
|
||||||
layerState,
|
layerState,
|
||||||
@@ -509,6 +512,7 @@ test('the field registry is well formed and permanent', () => {
|
|||||||
[27, 'styleParams', BY_STYLE],
|
[27, 'styleParams', BY_STYLE],
|
||||||
[28, 'worldTime', null],
|
[28, 'worldTime', null],
|
||||||
[29, 'library', null],
|
[29, 'library', null],
|
||||||
|
[30, 'scopeOpacityPct', 100],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
@@ -1586,3 +1590,50 @@ test('Data Library tokens round-trip and malformed ones are skipped', () => {
|
|||||||
assert.equal(normalizeLibraryTokens(null), null);
|
assert.equal(normalizeLibraryTokens(null), null);
|
||||||
assert.deepEqual(normalizeLibraryTokens('x'.repeat(500)), []);
|
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,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -121,6 +121,29 @@ test('scope feather above 100% survives share parsing and serialization', () =>
|
|||||||
clearTimeout(manager._debounceTimer);
|
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', () => {
|
test('share-link serialization emits the current celestial state', () => {
|
||||||
const manager = makeManager();
|
const manager = makeManager();
|
||||||
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
manager.onToggleChange(false, false, { celestialRingEnabled: false });
|
||||||
|
|||||||
@@ -104,6 +104,9 @@ export class ShareLinkManager {
|
|||||||
this._celestialRingEnabled = false;
|
this._celestialRingEnabled = false;
|
||||||
this._scopeEnabled = true;
|
this._scopeEnabled = true;
|
||||||
this._scopeFeatherPct = 100;
|
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
|
// null = the altitude-adaptive terminus (the default). A number pins the
|
||||||
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
|
// outside-fill opacity as a percent, 94..100. (`sce`, 2026-08-17)
|
||||||
this._scopeTerminusPct = null;
|
this._scopeTerminusPct = null;
|
||||||
@@ -286,6 +289,10 @@ export class ShareLinkManager {
|
|||||||
// mirrors live state for the link this session generates, so it must match
|
// mirrors live state for the link this session generates, so it must match
|
||||||
// the mask, not the archive.)
|
// the mask, not the archive.)
|
||||||
scopeFeatherPct: clampScopeFeatherPct(parseOr(params.get('scf'), 35)),
|
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;
|
// Absent (or non-numeric) `sce` = adaptive (null), the default behavior;
|
||||||
// a value pins the terminus opacity percent, clamped into the SUPPORTED
|
// 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
|
// 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,
|
celestialRing: visualCurrent ? state.celestialRing : undefined,
|
||||||
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
|
scopeEnabled: visualCurrent ? state.scopeEnabled : undefined,
|
||||||
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
|
scopeFeatherPct: visualCurrent ? state.scopeFeatherPct : undefined,
|
||||||
|
scopeOpacityPct: visualCurrent ? state.scopeOpacityPct : undefined,
|
||||||
scopeTerminusPct: visualCurrent ? state.scopeTerminusPct : undefined,
|
scopeTerminusPct: visualCurrent ? state.scopeTerminusPct : undefined,
|
||||||
mapStack: mapCurrent ? state.mapStack : undefined,
|
mapStack: mapCurrent ? state.mapStack : undefined,
|
||||||
panelState,
|
panelState,
|
||||||
@@ -625,6 +633,12 @@ export class ShareLinkManager {
|
|||||||
if (typeof extras.scopeFeatherPct === 'number') {
|
if (typeof extras.scopeFeatherPct === 'number') {
|
||||||
this._scopeFeatherPct = clampScopeFeatherPct(extras.scopeFeatherPct);
|
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;
|
if (extras.scopeTerminusPct === null) this._scopeTerminusPct = null;
|
||||||
else if (typeof extras.scopeTerminusPct === 'number') {
|
else if (typeof extras.scopeTerminusPct === 'number') {
|
||||||
this._scopeTerminusPct = clampScopeTerminusPct(extras.scopeTerminusPct);
|
this._scopeTerminusPct = clampScopeTerminusPct(extras.scopeTerminusPct);
|
||||||
@@ -740,6 +754,8 @@ export class ShareLinkManager {
|
|||||||
params.set('cr', this._celestialRingEnabled ? '1' : '0');
|
params.set('cr', this._celestialRingEnabled ? '1' : '0');
|
||||||
params.set('sc', this._scopeEnabled ? '1' : '0');
|
params.set('sc', this._scopeEnabled ? '1' : '0');
|
||||||
params.set('scf', Math.round(this._scopeFeatherPct).toString());
|
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
|
// 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
|
// 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
|
// same 94..100 clamp applies on the way OUT, so a link can never carry an
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ const items = computed(() =>
|
|||||||
id: panel.id,
|
id: panel.id,
|
||||||
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
|
icon: item?.icon ?? PANEL_RAIL_FALLBACK_ICON,
|
||||||
// Static markup is translated at boot, so a host's title already is.
|
// 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,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -226,6 +226,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
getDetectionTuning: services.getDetectionTuning,
|
getDetectionTuning: services.getDetectionTuning,
|
||||||
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
|
getKeyholeFadeTuning: services.getKeyholeFadeTuning,
|
||||||
getScopeMaskFeather: services.getScopeMaskFeather,
|
getScopeMaskFeather: services.getScopeMaskFeather,
|
||||||
|
getScopeMaskOpacity: services.getScopeMaskOpacity,
|
||||||
getScopeTerminusOverride: services.getScopeTerminusOverride,
|
getScopeTerminusOverride: services.getScopeTerminusOverride,
|
||||||
governorRequestRender: services.governorRequestRender,
|
governorRequestRender: services.governorRequestRender,
|
||||||
holdContinuousRender: services.holdContinuousRender,
|
holdContinuousRender: services.holdContinuousRender,
|
||||||
@@ -239,6 +240,7 @@ export class StyleManager extends ShellFacade {
|
|||||||
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
|
setKeyholeFadeTuning: services.setKeyholeFadeTuning,
|
||||||
setScopeMaskEnabled: services.setScopeMaskEnabled,
|
setScopeMaskEnabled: services.setScopeMaskEnabled,
|
||||||
setScopeMaskFeather: services.setScopeMaskFeather,
|
setScopeMaskFeather: services.setScopeMaskFeather,
|
||||||
|
setScopeMaskOpacity: services.setScopeMaskOpacity,
|
||||||
},
|
},
|
||||||
elements: {
|
elements: {
|
||||||
_ppToggles: this._ppToggles,
|
_ppToggles: this._ppToggles,
|
||||||
@@ -594,6 +596,10 @@ export class StyleManager extends ShellFacade {
|
|||||||
this._setDetectionAllocation(...args),
|
this._setDetectionAllocation(...args),
|
||||||
_setDetectionFade: (...args) => this._setDetectionFade(...args),
|
_setDetectionFade: (...args) => this._setDetectionFade(...args),
|
||||||
_setScope: (...args) => this._visualSettings._setScope(...args),
|
_setScope: (...args) => this._visualSettings._setScope(...args),
|
||||||
|
_applyGlobalPostDefaults: (...args) =>
|
||||||
|
this._applyGlobalPostDefaults(...args),
|
||||||
|
_adoptDetectionAsRestore: () =>
|
||||||
|
this._visualSettings.adoptDetectionAsRestore(),
|
||||||
setCelestialRingEnabled: (...args) =>
|
setCelestialRingEnabled: (...args) =>
|
||||||
this.setCelestialRingEnabled(...args),
|
this.setCelestialRingEnabled(...args),
|
||||||
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
|
_setModels3dEnabled: (...args) => this._setModels3dEnabled(...args),
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ import {
|
|||||||
isScopeMaskEnabled,
|
isScopeMaskEnabled,
|
||||||
setScopeMaskFeather,
|
setScopeMaskFeather,
|
||||||
getScopeMaskFeather,
|
getScopeMaskFeather,
|
||||||
|
setScopeMaskOpacity,
|
||||||
|
getScopeMaskOpacity,
|
||||||
setScopeTerminusOverride,
|
setScopeTerminusOverride,
|
||||||
getScopeTerminusOverride,
|
getScopeTerminusOverride,
|
||||||
clampScopeTerminusPct,
|
clampScopeTerminusPct,
|
||||||
@@ -112,6 +114,8 @@ export class StyleManager extends ApplicationShell {
|
|||||||
isScopeMaskEnabled,
|
isScopeMaskEnabled,
|
||||||
setScopeMaskFeather,
|
setScopeMaskFeather,
|
||||||
getScopeMaskFeather,
|
getScopeMaskFeather,
|
||||||
|
setScopeMaskOpacity,
|
||||||
|
getScopeMaskOpacity,
|
||||||
setScopeTerminusOverride,
|
setScopeTerminusOverride,
|
||||||
getScopeTerminusOverride,
|
getScopeTerminusOverride,
|
||||||
clampScopeTerminusPct,
|
clampScopeTerminusPct,
|
||||||
|
|||||||
@@ -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 assert from 'node:assert/strict';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import test from 'node:test';
|
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;
|
let shown = null;
|
||||||
owner.store = { publish: (view) => (shown = view.scope) };
|
owner.store = { publish: (view) => (shown = view.scope) };
|
||||||
await owner.restoreShareState({ scopeEnabled: false, scopeFeatherPct: 200 });
|
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']) {
|
for (const layout of ['minimal', 'cyber', 'tactical']) {
|
||||||
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
owner._setHudVariant(layout, { applyVisualDefaults: true });
|
||||||
assert.equal(isScopeMaskEnabled(), false, layout);
|
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 } });
|
await owner.applyVisualState({ scope: { enabled: true, featherPct: 175 } });
|
||||||
assert.equal(isScopeMaskEnabled(), true);
|
assert.equal(isScopeMaskEnabled(), true);
|
||||||
assert.equal(getScopeMaskFeather(), 1.75);
|
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 } });
|
await owner.applyVisualState({ scope: { featherPct: 500 } });
|
||||||
assert.equal(getScopeMaskFeather(), 2);
|
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) => {
|
test('a scene shot saved with the retired Snow style plays on Normal', async (t) => {
|
||||||
|
|||||||
@@ -105,6 +105,11 @@ export class DisplayBindings {
|
|||||||
this._setScope({ featherPct: value });
|
this._setScope({ featherPct: value });
|
||||||
this._syncShareState();
|
this._syncShareState();
|
||||||
},
|
},
|
||||||
|
setScopeOpacity: (value) => {
|
||||||
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
|
this._setScope({ opacityPct: Number(value) });
|
||||||
|
this._syncShareState();
|
||||||
|
},
|
||||||
setSharpenIntensity: (pct) => {
|
setSharpenIntensity: (pct) => {
|
||||||
this.shareLinkManager?.claimRestoreLane?.('visual');
|
this.shareLinkManager?.claimRestoreLane?.('visual');
|
||||||
this._setSharpenIntensity(pct);
|
this._setSharpenIntensity(pct);
|
||||||
@@ -168,6 +173,28 @@ export class DisplayBindings {
|
|||||||
this._syncModels3dModeRow();
|
this._syncModels3dModeRow();
|
||||||
},
|
},
|
||||||
setModelsMode: (mode) => this._setModels3dMode(mode),
|
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);
|
this._detachDisplayControls = () => store.detach(token);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,7 +108,11 @@ test('bloom and sharpen render their state, slider and readout', async (t) => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal($('bloom-toggle').classList.contains('active'), false);
|
assert.equal($('bloom-toggle').classList.contains('active'), false);
|
||||||
assert.equal($('bloom-slider-row').classList.contains('visible'), 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) => {
|
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');
|
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) => {
|
test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
||||||
const { store, $ } = fixture(t);
|
const { store, $ } = fixture(t);
|
||||||
const params = [];
|
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(coreClasses(button), 'pp-toggle-btn active');
|
||||||
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
assert.equal(button.getAttribute('aria-pressed'), 'true');
|
||||||
assert.equal(row.classList.contains('visible'), 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.
|
// The button leaves the row to its own sync.
|
||||||
owner._setModels3dEnabled(false);
|
owner._setModels3dEnabled(false);
|
||||||
@@ -347,7 +421,10 @@ test('the 3D aircraft group renders what AircraftDisplay shows', async (t) => {
|
|||||||
await nextTick();
|
await nextTick();
|
||||||
assert.equal(all.classList.contains('active'), true);
|
assert.equal(all.classList.contains('active'), true);
|
||||||
assert.equal(all.getAttribute('aria-checked'), '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' }]);
|
assert.deepEqual(params.at(-1), ['military', { models3dMode: 'all' }]);
|
||||||
|
|
||||||
owner._syncModels3dFromLayerState({
|
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').getAttribute('aria-label'), 'Area');
|
||||||
assert.equal(segment('area').title, 'Area');
|
assert.equal(segment('area').title, 'Area');
|
||||||
assert.equal(segment('area').textContent, 'pentagon');
|
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('primary').getAttribute('aria-checked'), 'true');
|
||||||
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
|
assert.equal(swatch('amber').getAttribute('aria-label'), 'Amber');
|
||||||
assert.equal($('draw-hint').textContent, '');
|
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();
|
await nextTick();
|
||||||
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
|
assert.equal(swatch('amber').getAttribute('aria-checked'), 'true');
|
||||||
assert.equal(swatch('primary').classList.contains('active'), false);
|
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(document.activeElement === button(first, 'toggle'), true);
|
||||||
assert.equal(button(first, 'toggle').getAttribute('aria-pressed'), '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, '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'));
|
await press(button(first, 'remove'));
|
||||||
assert.equal(rowsNow().length, 1);
|
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(store.parameters, null, 'the sliders go with their owner');
|
||||||
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
assert.equal($('param-slider-panel').classList.contains('active'), false);
|
||||||
assert.equal(store.act('setStyleParameter', 'grainAmt', 0.2), undefined);
|
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.
|
// An older owner's stop leaves a newer owner's rows.
|
||||||
const newer = { style: 'noir', rows: [] };
|
const newer = { style: 'noir', rows: [] };
|
||||||
owner._parameters = { 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) => {
|
test('the four Cockpit groups teleport to the published slots and back to their places', async (t) => {
|
||||||
const { store, $ } = fixture(t);
|
const { store, $ } = fixture(t);
|
||||||
const groupOf = (id) => $(id).closest('.pp-toggle-group') ?? $(id);
|
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 body = $('pp-toggles').querySelector('.pp-panel-body');
|
||||||
const order = () =>
|
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);
|
.map((el) => el.id || el.querySelector('[id]')?.id);
|
||||||
const home = order();
|
const home = order();
|
||||||
const popover = document.body.appendChild(document.createElement('div'));
|
const popover = document.body.appendChild(document.createElement('div'));
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { createApp, h, nextTick } from 'vue';
|
|||||||
import { createPinia } from 'pinia';
|
import { createPinia } from 'pinia';
|
||||||
import { useDisplayStore } from './stores/display.js';
|
import { useDisplayStore } from './stores/display.js';
|
||||||
import { DisplayBindings } from './displayBindings.js';
|
import { DisplayBindings } from './displayBindings.js';
|
||||||
|
import { VisualSettings } from './visualSettings.js';
|
||||||
import CommandDockView from './views/CommandDockView.vue';
|
import CommandDockView from './views/CommandDockView.vue';
|
||||||
import DisplayControlsView from './views/DisplayControlsView.vue';
|
import DisplayControlsView from './views/DisplayControlsView.vue';
|
||||||
import DrawControls from './views/panels/DrawControls.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'],
|
['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);
|
||||||
|
});
|
||||||
@@ -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.
|
* 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
|
* `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({
|
export const PANEL_RAIL_ITEMS = Object.freeze({
|
||||||
'data-panel': {
|
'data-panel': {
|
||||||
@@ -19,7 +19,9 @@ export const PANEL_RAIL_ITEMS = Object.freeze({
|
|||||||
},
|
},
|
||||||
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
|
'utilities-panel': { icon: 'handyman', label: msg('UTILITIES') },
|
||||||
'scene-panel': { icon: 'movie', label: msg('AUTHORING') },
|
'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') },
|
'cctv-panel': { icon: 'videocam', label: msg('CCTV') },
|
||||||
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
|
'weather-panel': { icon: 'partly_cloudy_day', label: msg('WEATHER') },
|
||||||
'news-panel': { icon: 'public', label: msg('NEWS') },
|
'news-panel': { icon: 'public', label: msg('NEWS') },
|
||||||
|
|||||||
@@ -599,7 +599,10 @@ html:not([lang='en']):not(:lang(zh)):not(:lang(ja))
|
|||||||
|
|
||||||
#pp-toggles.collapsed .pp-toggle-group,
|
#pp-toggles.collapsed .pp-toggle-group,
|
||||||
#pp-toggles.collapsed #clean-view-toggle,
|
#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;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -5,12 +5,14 @@ import { t } from '../../../i18n/index.js';
|
|||||||
import { vPanelState } from '../../rail/panelState.js';
|
import { vPanelState } from '../../rail/panelState.js';
|
||||||
import { vClassToggle } from '../../rail/classToggle.js';
|
import { vClassToggle } from '../../rail/classToggle.js';
|
||||||
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
import PanelCollapseButton from '../../rail/PanelCollapseButton.vue';
|
||||||
|
import UiPanelEdge from '../../kit/UiPanelEdge.vue';
|
||||||
import UiSegmented from '../../kit/UiSegmented.vue';
|
import UiSegmented from '../../kit/UiSegmented.vue';
|
||||||
import UiSlider from '../../kit/UiSlider.js';
|
import UiSlider from '../../kit/UiSlider.js';
|
||||||
import { useDisplayStore } from '../../stores/display.js';
|
import { useDisplayStore } from '../../stores/display.js';
|
||||||
import UiButton from '../../kit/UiButton.vue';
|
import UiButton from '../../kit/UiButton.vue';
|
||||||
import UiSelect from '../../kit/UiSelect.js';
|
import UiSelect from '../../kit/UiSelect.js';
|
||||||
import { StyleParameterRows } from '../../styleParameters.js';
|
import { StyleParameterRows } from '../../styleParameters.js';
|
||||||
|
import VisualPresets from './VisualPresets.vue';
|
||||||
|
|
||||||
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
// The Display panel. VisualSettings presents the HUD, Sonar, Scope,
|
||||||
// Celestial, detection, bloom and sharpen and AircraftDisplay the 3D group,
|
// 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).
|
// A control's change goes to its owner (DisplayBindings).
|
||||||
const act = (name, ...args) => display.act(name, ...args);
|
const act = (name, ...args) => display.act(name, ...args);
|
||||||
const pct = (value) => `${value}%`;
|
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 hud = computed(() => view.value?.hud ?? null);
|
||||||
const sonar = computed(() => view.value?.sonar ?? null);
|
const sonar = computed(() => view.value?.sonar ?? null);
|
||||||
const sonarOn = computed(() => sonar.value?.enabled ?? true);
|
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 allocation = computed(() => detection.value?.allocation ?? 'ELASTIC');
|
||||||
const scopeOn = computed(() => view.value?.scope.enabled ?? true);
|
const scopeOn = computed(() => view.value?.scope.enabled ?? true);
|
||||||
const scopeFeather = computed(() => view.value?.scope.featherPct ?? 100);
|
const scopeFeather = computed(() => view.value?.scope.featherPct ?? 100);
|
||||||
|
const scopeOpacity = computed(() => view.value?.scope.opacityPct ?? 100);
|
||||||
const celestial = computed(() => view.value?.celestial ?? null);
|
const celestial = computed(() => view.value?.celestial ?? null);
|
||||||
const celestialTitle = computed(() =>
|
const celestialTitle = computed(() =>
|
||||||
celestial.value && !celestial.value.supported
|
celestial.value && !celestial.value.supported
|
||||||
@@ -51,15 +60,18 @@ const allocationOptions = computed(() => [
|
|||||||
<!-- Post-processing toggles (top-right) -->
|
<!-- 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 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">
|
<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>
|
<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>
|
||||||
<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. -->
|
<!-- 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">
|
<Teleport :to="cockpitPortal?.hud ?? 'body'" :disabled="!cockpitPortal?.hud">
|
||||||
<div class="pp-toggle-group">
|
<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">◎</span>
|
<span class="pp-icon">◎</span>
|
||||||
<span class="pp-label" translate="no">HUD</span>
|
<span class="pp-label" translate="no">HUD</span>
|
||||||
</button>
|
</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')" />
|
<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>
|
<span class="pp-slider-value" id="scope-feather-value">{{ pct(scopeFeather) }}</span>
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" :title="t('Hide UI chrome')">
|
||||||
|
<span class="pp-icon">□</span>
|
||||||
|
<span class="pp-label">{{ t('Clean UI') }}</span>
|
||||||
|
</button>
|
||||||
|
<p class="pp-section-heading">{{ t('Effects') }}</p>
|
||||||
<div class="pp-toggle-group">
|
<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">
|
<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-icon material-symbols-outlined" aria-hidden="true">flare</span>
|
||||||
<span class="pp-label">{{ t('Celestial') }}</span>
|
<span class="pp-label">{{ t('Celestial') }}</span>
|
||||||
</UiButton>
|
</UiButton>
|
||||||
</div>
|
</div>
|
||||||
<button class="pp-toggle-btn" id="clean-view-toggle" @click="act('toggleCleanView')" :title="t('Hide UI chrome')">
|
|
||||||
<span class="pp-icon">□</span>
|
|
||||||
<span class="pp-label">{{ t('Clean UI') }}</span>
|
|
||||||
</button>
|
|
||||||
<div class="pp-toggle-group">
|
<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">✨</span>
|
<span class="pp-icon">✨</span>
|
||||||
<span class="pp-label">{{ t('Bloom') }}</span>
|
<span class="pp-label">{{ t('Bloom') }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -190,7 +208,7 @@ const allocationOptions = computed(() => [
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="pp-toggle-group">
|
<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">🔍</span>
|
<span class="pp-icon">🔍</span>
|
||||||
<span class="pp-label">{{ t('Sharpen') }}</span>
|
<span class="pp-label">{{ t('Sharpen') }}</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -199,6 +217,9 @@ const allocationOptions = computed(() => [
|
|||||||
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
|
<span class="pp-slider-value" id="sharpen-intensity-value">{{ pct(view?.sharpen.intensity ?? 49) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -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>
|
||||||
@@ -46,6 +46,13 @@ import { cyberVisualDefaultsForHudTransition } from '../hudLayouts.js';
|
|||||||
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
|
const DETECTION_ALLOCATION_STORAGE_KEY = 'gev:detection-allocation:v1';
|
||||||
|
|
||||||
/** Own visual preferences, detection overrides and display-control state. */
|
/** 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 {
|
export class VisualSettings {
|
||||||
async restoreShareState(state) {
|
async restoreShareState(state) {
|
||||||
const { setScopeTerminusOverride, clampScopeTerminusPct } = this.services;
|
const { setScopeTerminusOverride, clampScopeTerminusPct } = this.services;
|
||||||
@@ -66,6 +73,7 @@ export class VisualSettings {
|
|||||||
celestialRing,
|
celestialRing,
|
||||||
scopeEnabled,
|
scopeEnabled,
|
||||||
scopeFeatherPct,
|
scopeFeatherPct,
|
||||||
|
scopeOpacityPct,
|
||||||
scopeTerminusPct,
|
scopeTerminusPct,
|
||||||
mapStack,
|
mapStack,
|
||||||
panelState,
|
panelState,
|
||||||
@@ -133,7 +141,12 @@ export class VisualSettings {
|
|||||||
focus: false,
|
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
|
// null restores the altitude-adaptive ramp; a number pins the terminus
|
||||||
// (clamped to the supported 94..100 band, same as the `sce` hash key).
|
// (clamped to the supported 94..100 band, same as the `sce` hash key).
|
||||||
if (scopeTerminusPct === null) setScopeTerminusOverride(null);
|
if (scopeTerminusPct === null) setScopeTerminusOverride(null);
|
||||||
@@ -219,6 +232,7 @@ export class VisualSettings {
|
|||||||
// What the Scope and Celestial controls show; null until first set.
|
// What the Scope and Celestial controls show; null until first set.
|
||||||
this._scopeEnabledShown = null;
|
this._scopeEnabledShown = null;
|
||||||
this._scopeFeatherShown = null;
|
this._scopeFeatherShown = null;
|
||||||
|
this._scopeOpacityShown = null;
|
||||||
this._celestialShown = null;
|
this._celestialShown = null;
|
||||||
this.activeStyle = 'normal';
|
this.activeStyle = 'normal';
|
||||||
this._preCyberStyle = null;
|
this._preCyberStyle = null;
|
||||||
@@ -348,6 +362,20 @@ export class VisualSettings {
|
|||||||
this._syncShareState();
|
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 } = {}) {
|
_setCockpitVision(mode, active, { revealParameters = false } = {}) {
|
||||||
const next = active ? normalizeCockpitVisionMode(mode) : 'optical';
|
const next = active ? normalizeCockpitVisionMode(mode) : 'optical';
|
||||||
if (!this.stages) return;
|
if (!this.stages) return;
|
||||||
@@ -479,6 +507,7 @@ export class VisualSettings {
|
|||||||
scope: {
|
scope: {
|
||||||
enabled: this._scopeEnabledShown,
|
enabled: this._scopeEnabledShown,
|
||||||
featherPct: this._scopeFeatherShown,
|
featherPct: this._scopeFeatherShown,
|
||||||
|
opacityPct: this._scopeOpacityShown ?? 100,
|
||||||
},
|
},
|
||||||
celestial: this._celestialShown,
|
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
|
* 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.
|
* 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 (typeof enabled === 'boolean') {
|
||||||
if (apply) this.services.setScopeMaskEnabled(enabled);
|
if (apply) this.services.setScopeMaskEnabled(enabled);
|
||||||
this._scopeEnabledShown = enabled;
|
this._scopeEnabledShown = enabled;
|
||||||
@@ -819,7 +856,11 @@ export class VisualSettings {
|
|||||||
if (det.mode) this._setDetectionMode(String(det.mode).toUpperCase());
|
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;
|
const defaults = GLOBAL_POST_DEFAULTS;
|
||||||
if (typeof defaults.bloom?.intensity === 'number') {
|
if (typeof defaults.bloom?.intensity === 'number') {
|
||||||
this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), {
|
this._setBloomIntensity(clampBloomIntensity(defaults.bloom.intensity), {
|
||||||
@@ -838,7 +879,7 @@ export class VisualSettings {
|
|||||||
this._setSharpenEnabled(defaults.sharpen.enabled);
|
this._setSharpenEnabled(defaults.sharpen.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (defaults.hudVariant) {
|
if (defaults.hudVariant && !keepHudVariant) {
|
||||||
this._setHudVariant(defaults.hudVariant);
|
this._setHudVariant(defaults.hudVariant);
|
||||||
}
|
}
|
||||||
if (typeof defaults.hudVisible === 'boolean') {
|
if (typeof defaults.hudVisible === 'boolean') {
|
||||||
@@ -883,6 +924,7 @@ export class VisualSettings {
|
|||||||
getDetectionTuning,
|
getDetectionTuning,
|
||||||
isScopeMaskEnabled,
|
isScopeMaskEnabled,
|
||||||
getScopeMaskFeather,
|
getScopeMaskFeather,
|
||||||
|
getScopeMaskOpacity,
|
||||||
getScopeTerminusOverride,
|
getScopeTerminusOverride,
|
||||||
} = this.services;
|
} = this.services;
|
||||||
const detection = this._shareableDetectionState();
|
const detection = this._shareableDetectionState();
|
||||||
@@ -903,6 +945,7 @@ export class VisualSettings {
|
|||||||
celestialRingEnabled: this.celestialRingEnabled,
|
celestialRingEnabled: this.celestialRingEnabled,
|
||||||
scopeEnabled: isScopeMaskEnabled(),
|
scopeEnabled: isScopeMaskEnabled(),
|
||||||
scopeFeatherPct: Math.round(getScopeMaskFeather() * 100),
|
scopeFeatherPct: Math.round(getScopeMaskFeather() * 100),
|
||||||
|
scopeOpacityPct: Math.round((getScopeMaskOpacity?.() ?? 1) * 100),
|
||||||
// null when adaptive — the share layer omits `sce` entirely in that case.
|
// null when adaptive — the share layer omits `sce` entirely in that case.
|
||||||
scopeTerminusPct:
|
scopeTerminusPct:
|
||||||
getScopeTerminusOverride() == null
|
getScopeTerminusOverride() == null
|
||||||
|
|||||||
@@ -49,5 +49,7 @@
|
|||||||
@import './src/ui/styles/rail-split.css';
|
@import './src/ui/styles/rail-split.css';
|
||||||
@import './src/ui/styles/left-tools.css';
|
@import './src/ui/styles/left-tools.css';
|
||||||
@import './src/ui/styles/panel-edge.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/ai-chat.css';
|
||||||
|
@import './src/ui/styles/scope-off-shadows.css';
|
||||||
@import './src/ui/styles/cyber.css';
|
@import './src/ui/styles/cyber.css';
|
||||||
Reference in new issue
Block a user