Skip to content

Commit a1f534f

Browse files
committed
feat: enhance UI components and add safety requirements form with hotkey support
1 parent d6af603 commit a1f534f

14 files changed

Lines changed: 172 additions & 56 deletions

File tree

rpo-app/src/chrome/HotkeyLegend.tsx

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useShallow } from 'zustand/react/shallow';
22

3-
import { type ChromeRegion, type ChromeState, useUI } from '@/stores/ui';
3+
import { type ChromeRegion, type ChromeState, selectDockOffsetPx, useUI } from '@/stores/ui';
44
import { Caps } from '@/ui/Caps';
55
import { withBlur } from '@/utils/blur';
66

7+
const LEGEND_GAP_PX = 16;
8+
79
type Region = {
810
key: 'S' | 'H' | 'D';
911
label: ChromeRegion;
@@ -34,15 +36,21 @@ export function HotkeyLegend() {
3436
cycleDock: s.cycleDock,
3537
})),
3638
);
39+
const dockOffsetPx = useUI(selectDockOffsetPx);
3740

3841
const regions: readonly Region[] = [
3942
{ key: 'S', label: 'sidebar', state: sidebar, onCycle: cycleSidebar },
4043
{ key: 'H', label: 'hud', state: hud, onCycle: cycleHud },
4144
{ key: 'D', label: 'dock', state: dock, onCycle: cycleDock },
4245
];
4346

47+
const bottomPx = dockOffsetPx + LEGEND_GAP_PX;
48+
4449
return (
45-
<div className="pointer-events-auto fixed right-4 bottom-4 z-40 flex items-center gap-3 rounded-xs border border-border bg-surface-1/85 px-2.5 py-1.5 backdrop-blur-sm">
50+
<div
51+
style={{ bottom: bottomPx }}
52+
className="pointer-events-auto fixed right-4 z-40 flex items-center gap-3 rounded-xs border border-border bg-surface-1/85 px-2.5 py-1.5 backdrop-blur-sm"
53+
>
4654
{regions.map((r) => (
4755
<button
4856
key={r.key}

rpo-app/src/chrome/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,6 @@ export const HUD_COLLAPSED_WIDTH_CLASS = 'w-56';
66
export const DOCK_MIN_HEIGHT_PX = 160;
77
export const DOCK_MAX_HEIGHT_PX = 720;
88
export const DOCK_DEFAULT_HEIGHT_PX = 300;
9+
// Keep DOCK_COLLAPSED_HEIGHT_PX in sync with the Tailwind class below (h-7 = 1.75rem = 28px).
10+
export const DOCK_COLLAPSED_HEIGHT_PX = 28;
911
export const DOCK_COLLAPSED_HEIGHT_CLASS = 'h-7';

rpo-app/src/chrome/sidebar/MissionHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { Pencil, Plus, X } from 'lucide-react';
33
import { useShallow } from 'zustand/react/shallow';
44

55
import { EditConfigButton } from '@/chrome/EditConfigButton';
6-
import { SafetyRequirementsForm } from '@/features/safety-requirements/SafetyRequirementsForm';
6+
import { SafetyRequirementsForm } from '@/chrome/sidebar/SafetyRequirementsForm';
77
import {
88
ALIGNMENT_LABELS,
99
type AlignmentValue,

rpo-app/src/features/safety-requirements/SafetyRequirementsForm.tsx renamed to rpo-app/src/chrome/sidebar/SafetyRequirementsForm.tsx

File renamed without changes.

rpo-app/src/hooks/useHotkey.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import { useEffect } from 'react';
22

3-
export function useHotkey(key: string, callback: () => void): void {
3+
type HotkeyOptions = {
4+
shift?: boolean;
5+
};
6+
7+
export function useHotkey(key: string, callback: () => void, options?: HotkeyOptions): void {
8+
const requireShift = !!options?.shift;
49
useEffect(() => {
510
const handleKeyDown = (e: KeyboardEvent) => {
611
if (
@@ -11,11 +16,11 @@ export function useHotkey(key: string, callback: () => void): void {
1116
return;
1217
}
1318
if (e.key.toLowerCase() !== key.toLowerCase()) return;
14-
if (e.shiftKey) return;
19+
if (e.shiftKey !== requireShift) return;
1520
callback();
1621
};
1722

1823
window.addEventListener('keydown', handleKeyDown);
1924
return () => window.removeEventListener('keydown', handleKeyDown);
20-
}, [key, callback]);
25+
}, [key, callback, requireShift]);
2126
}

rpo-app/src/stores/ui.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
import { create } from 'zustand';
22
import { devtools } from 'zustand/middleware';
33

4-
import { DOCK_DEFAULT_HEIGHT_PX } from '@/chrome/constants';
4+
import { DOCK_COLLAPSED_HEIGHT_PX, DOCK_DEFAULT_HEIGHT_PX } from '@/chrome/constants';
55

66
export type ChromeState = 'expanded' | 'collapsed' | 'hidden';
77
export type ChromeRegion = 'sidebar' | 'hud' | 'dock';
88
export type HudReadoutFrame = 'ric' | 'roe';
99

10+
type ChromeSnapshot = {
11+
sidebar: ChromeState;
12+
hud: ChromeState;
13+
dock: ChromeState;
14+
};
15+
1016
type UIState = {
1117
sidebar: ChromeState;
1218
hud: ChromeState;
1319
dock: ChromeState;
20+
previousChrome: ChromeSnapshot | null;
1421
dockHeight: number;
1522
hudReadoutFrame: HudReadoutFrame;
1623
cycleSidebar: () => void;
1724
cycleHud: () => void;
1825
cycleDock: () => void;
1926
expand: (region: ChromeRegion) => void;
2027
hide: (region: ChromeRegion) => void;
28+
toggleHideAll: () => void;
2129
setDockHeight: (height: number) => void;
2230
setHudReadoutFrame: (frame: HudReadoutFrame) => void;
2331
};
@@ -28,12 +36,22 @@ const NEXT_STATE: Record<ChromeState, ChromeState> = {
2836
hidden: 'expanded',
2937
};
3038

39+
// Pixel height the dock currently occupies from the viewport bottom. Used by
40+
// elements that anchor to the bottom edge (HotkeyLegend, RICAxes gizmo) so
41+
// they lift above the dock instead of overlapping it.
42+
export const selectDockOffsetPx = (s: UIState): number => {
43+
if (s.dock === 'expanded') return s.dockHeight;
44+
if (s.dock === 'collapsed') return DOCK_COLLAPSED_HEIGHT_PX;
45+
return 0;
46+
};
47+
3148
export const useUI = create<UIState>()(
3249
devtools(
3350
(set) => ({
3451
sidebar: 'expanded',
3552
hud: 'expanded',
3653
dock: 'hidden',
54+
previousChrome: null,
3755
dockHeight: DOCK_DEFAULT_HEIGHT_PX,
3856
hudReadoutFrame: 'ric',
3957
cycleSidebar: () => set((s) => ({ sidebar: NEXT_STATE[s.sidebar] }), false, 'cycleSidebar'),
@@ -47,6 +65,32 @@ export const useUI = create<UIState>()(
4765
),
4866
hide: (region) =>
4967
set((s) => (s[region] === 'hidden' ? s : { [region]: 'hidden' }), false, `hide/${region}`),
68+
toggleHideAll: () =>
69+
set(
70+
(s) => {
71+
const allHidden = s.sidebar === 'hidden' && s.hud === 'hidden' && s.dock === 'hidden';
72+
if (allHidden) {
73+
if (s.previousChrome === null) {
74+
// No snapshot (e.g., user manually hid each region one-by-one). Fall back to all expanded.
75+
return {
76+
sidebar: 'expanded',
77+
hud: 'expanded',
78+
dock: 'expanded',
79+
previousChrome: null,
80+
};
81+
}
82+
return { ...s.previousChrome, previousChrome: null };
83+
}
84+
return {
85+
sidebar: 'hidden',
86+
hud: 'hidden',
87+
dock: 'hidden',
88+
previousChrome: { sidebar: s.sidebar, hud: s.hud, dock: s.dock },
89+
};
90+
},
91+
false,
92+
'toggleHideAll',
93+
),
5094
setDockHeight: (height) =>
5195
set((s) => (s.dockHeight === height ? s : { dockHeight: height }), false, 'setDockHeight'),
5296
setHudReadoutFrame: (frame) => set({ hudReadoutFrame: frame }, false, 'setHudReadoutFrame'),

rpo-app/src/utils/math.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export function clamp(v: number, lo: number, hi: number): number {
2+
return Math.min(Math.max(v, lo), hi);
3+
}

rpo-app/src/viewport3d/proximity/CelestialIndicator.tsx

Lines changed: 35 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@ import { useMemo } from 'react';
22
import { Html, Line } from '@react-three/drei';
33
import { Earth, Moon, Sun } from 'lucide-react';
44

5+
import { clamp } from '@/utils/math';
56
import type { Vec3 } from '@/viewport3d/types';
67

78
import { CELESTIAL_INDICATOR } from './constants';
89
import { ricToPosition } from './coordinates';
10+
import type { CelestialBodyDir } from './scene';
911

1012
type CelestialBody = 'earth' | 'sun' | 'moon';
1113

@@ -16,23 +18,43 @@ const ICON_BY_BODY = {
1618
} as const;
1719

1820
type Props = {
19-
unitRic: Vec3;
20-
edgeM: number;
21+
dir: CelestialBodyDir;
22+
gridExtentM: number;
2123
body: CelestialBody;
2224
color: string;
2325
};
2426

25-
export default function CelestialIndicator({ unitRic, edgeM, body, color }: Props) {
26-
const { tip, iconPos } = useMemo(() => {
27-
const tipRic: Vec3 = [unitRic[0] * edgeM, unitRic[1] * edgeM, unitRic[2] * edgeM];
28-
const offset = edgeM * CELESTIAL_INDICATOR.labelOffsetRatio;
27+
export default function CelestialIndicator({ dir, gridExtentM, body, color }: Props) {
28+
const { tip, iconPos, opacity, iconOpacity } = useMemo(() => {
29+
const { unitRic, distanceKm } = dir;
30+
const log = Math.log10(Math.max(distanceKm, 1));
31+
const span = CELESTIAL_INDICATOR.logKmMax - CELESTIAL_INDICATOR.logKmMin;
32+
const t = clamp((log - CELESTIAL_INDICATOR.logKmMin) / span, 0, 1);
33+
34+
const lengthRatio =
35+
CELESTIAL_INDICATOR.lengthRatioNear +
36+
t * (CELESTIAL_INDICATOR.lengthRatioFar - CELESTIAL_INDICATOR.lengthRatioNear);
37+
const lengthM = gridExtentM * lengthRatio;
38+
39+
const opacity =
40+
CELESTIAL_INDICATOR.opacityNear +
41+
t * (CELESTIAL_INDICATOR.opacityFar - CELESTIAL_INDICATOR.opacityNear);
42+
const iconOpacity = Math.min(1, opacity + CELESTIAL_INDICATOR.iconOpacityBias);
43+
44+
const tipRic: Vec3 = [unitRic[0] * lengthM, unitRic[1] * lengthM, unitRic[2] * lengthM];
45+
const offset = lengthM * CELESTIAL_INDICATOR.labelOffsetRatio;
2946
const iconRic: Vec3 = [
30-
unitRic[0] * (edgeM + offset),
31-
unitRic[1] * (edgeM + offset),
32-
unitRic[2] * (edgeM + offset),
47+
unitRic[0] * (lengthM + offset),
48+
unitRic[1] * (lengthM + offset),
49+
unitRic[2] * (lengthM + offset),
3350
];
34-
return { tip: ricToPosition(tipRic), iconPos: ricToPosition(iconRic) };
35-
}, [unitRic, edgeM]);
51+
return {
52+
tip: ricToPosition(tipRic),
53+
iconPos: ricToPosition(iconRic),
54+
opacity,
55+
iconOpacity,
56+
};
57+
}, [dir, gridExtentM]);
3658

3759
const Icon = ICON_BY_BODY[body];
3860

@@ -42,15 +64,15 @@ export default function CelestialIndicator({ unitRic, edgeM, body, color }: Prop
4264
points={[[0, 0, 0], tip]}
4365
color={color}
4466
lineWidth={CELESTIAL_INDICATOR.lineWidth}
45-
opacity={CELESTIAL_INDICATOR.lineOpacity}
67+
opacity={opacity}
4668
transparent
4769
depthWrite={false}
4870
/>
4971
<Html
5072
position={iconPos}
5173
center
5274
zIndexRange={[20, 0]}
53-
style={{ pointerEvents: 'none', color }}
75+
style={{ pointerEvents: 'none', color, opacity: iconOpacity }}
5476
>
5577
<Icon
5678
size={CELESTIAL_INDICATOR.iconSizePx}

rpo-app/src/viewport3d/proximity/ProximityViewport.tsx

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { loadedVector, useConfig } from '@/stores/configuration';
88
import type { Vec3 } from '@/viewport3d/types';
99

1010
import CelestialIndicator from './CelestialIndicator';
11-
import { CELESTIAL_EDGE_RATIO, CELESTIAL_TINTS, ORBIT_CONTROLS } from './constants';
11+
import { CELESTIAL_TINTS, ORBIT_CONTROLS } from './constants';
1212
import Grid from './Grid';
1313
import RICAxes from './RICAxes';
1414
import { computeCelestialDirsRic, computeDeputyPositionRicM, computeScale } from './scene';
@@ -40,7 +40,6 @@ export default function ProximityViewport() {
4040
);
4141
const scale = useMemo(() => computeScale(deputyRicM), [deputyRicM]);
4242
const celestials = useMemo(() => computeCelestialDirsRic(chiefVector), [chiefVector]);
43-
const celestialEdgeM = scale.extentM * CELESTIAL_EDGE_RATIO;
4443

4544
return (
4645
<div className="h-full w-full">
@@ -84,24 +83,24 @@ export default function ProximityViewport() {
8483

8584
{celestials.earth && (
8685
<CelestialIndicator
87-
unitRic={celestials.earth}
88-
edgeM={celestialEdgeM}
86+
dir={celestials.earth}
87+
gridExtentM={scale.extentM}
8988
body="earth"
9089
color={CELESTIAL_TINTS.earth}
9190
/>
9291
)}
9392
{celestials.sun && (
9493
<CelestialIndicator
95-
unitRic={celestials.sun}
96-
edgeM={celestialEdgeM}
94+
dir={celestials.sun}
95+
gridExtentM={scale.extentM}
9796
body="sun"
9897
color={CELESTIAL_TINTS.sun}
9998
/>
10099
)}
101100
{celestials.moon && (
102101
<CelestialIndicator
103-
unitRic={celestials.moon}
104-
edgeM={celestialEdgeM}
102+
dir={celestials.moon}
103+
gridExtentM={scale.extentM}
105104
body="moon"
106105
color={CELESTIAL_TINTS.moon}
107106
/>

rpo-app/src/viewport3d/proximity/RICAxes.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1+
import { useMemo } from 'react';
12
import { Billboard, GizmoHelper, Line, Sphere, Text } from '@react-three/drei';
23

4+
import { selectDockOffsetPx, useUI } from '@/stores/ui';
35
import type { Vec3 } from '@/viewport3d/types';
46

57
import { AXES, AXES_GIZMO } from './constants';
@@ -44,8 +46,17 @@ function Axis({ direction, color, label }: AxisProps) {
4446

4547
// R = +Y (up), I = +X (right), C = +Z (depth) — matches ricToPosition mapping.
4648
export default function RICAxes() {
49+
const dockOffsetPx = useUI(selectDockOffsetPx);
50+
// Lift by the dock's contribution to the chrome stack; the static base already
51+
// accounts for the legend's clearance in the dock-hidden ("fine") baseline.
52+
// Memoized so GizmoHelper doesn't see a fresh tuple reference per render.
53+
const margin = useMemo<[number, number]>(() => {
54+
const [mx, my] = AXES_GIZMO.margin;
55+
return [mx, my + dockOffsetPx];
56+
}, [dockOffsetPx]);
57+
4758
return (
48-
<GizmoHelper alignment="bottom-right" margin={[...AXES_GIZMO.margin]}>
59+
<GizmoHelper alignment="bottom-right" margin={margin}>
4960
<group scale={AXES_GIZMO.scale}>
5061
<Axis direction={[0, 1, 0]} color={AXES.R.color} label={AXES.R.label} />
5162
<Axis direction={[1, 0, 0]} color={AXES.I.color} label={AXES.I.label} />

0 commit comments

Comments
 (0)