import {useState} from 'react'; import {createRoot} from 'react-dom/client'; import {Button,Inspector,InspectorSelectField,RangeControl,SettingsCard,StatusBadge} from '@nodedc/ui-react'; import '@nodedc/ui-core/styles.css'; import {defaultProfile,mix,parseProfile,type Axes,type ControlProfile} from '../../../../packages/rover-control/src/profile'; import {trace} from './trace'; import './preview.css'; const emptyAxes:Axes={leftY:0,rightY:0,leftX:0,rightX:0}; const percent=(n:number)=>`${Math.round(n*100)}%`; const states={'hold':'Остановка / ожидание нейтрали','rc-ready':'Пульт готов','rc-manual':'Ручное управление','core':'Mission Core'}; const storageKey='missioncore.rover-control-preview.v1'; function load():ControlProfile { try{const raw=localStorage.getItem(storageKey);return raw?parseProfile(JSON.parse(raw)):{...defaultProfile};} catch{return {...defaultProfile};} } function App(){ const [profile,setProfile]=useState(load); const [axes,setAxes]=useState(emptyAxes); const [open,setOpen]=useState(['settings','preview']); const [saved,setSaved]=useState(''); const [light,setLight]=useState(false); const change=(patch:Partial)=>{ setProfile(parseProfile({...profile,...patch,revision:profile.revision+1}));setAxes(emptyAxes);setSaved(''); }; const result=mix(profile,axes); const y=profile.mode==='tank'?'leftY':profile.stick==='right'?'rightY':'leftY'; const x=profile.mode==='tank'?'rightY':profile.stick==='right'?'rightX':'leftX'; const field=(key:keyof Axes,label:string)=>setAxes({...axes,[key]:value})}/>; function save(){ try{localStorage.setItem(storageKey,JSON.stringify(profile));setSaved('Черновик сохранён в этом браузере.');} catch{setSaved('Браузер не сохранил черновик. Используйте скачивание JSON.');} } function download(){ const url=URL.createObjectURL(new Blob([JSON.stringify(profile,null,2)+'\n'],{type:'application/json'})); const a=document.createElement('a');a.href=url;a.download='rover-control-draft.json';a.click(); setTimeout(()=>URL.revokeObjectURL(url),1000); } return

MISSION CORE · ПРОТОТИП

Настройки управления ровером

Интерактивный макет без подключения к роверу. Все значения ниже — расчёт на экране; кнопки не обращаются к борту и VESC.

change({mode})}/> {profile.mode==='arcade'&&change({stick})}/>}

{profile.mode==='tank'?'Левый рычаг управляет левой стороной, правый — правой.':'Вперёд/назад задаёт движение; влево/вправо — поворот, включая разворот на месте. При движении назад знак поворота корпуса сохраняется.'}

change({response})}/> change({deadband})}/> change({outputScale})}/>

Масштаб команды — относительный отклик рычага. Он не задаёт амперы, ватты или паспортный предел двигателя.

{saved&&

{saved}

} }, {id:'preview',label:'Проверка профиля на экране',content: {field(y,profile.mode==='tank'?'Левый рычаг · вперёд / назад':'Движение · вперёд / назад')} {field(x,profile.mode==='tank'?'Правый рычаг · вперёд / назад':'Поворот · влево / вправо')}
{(['left','right'] as const).map(side=>
{side==='left'?'Левая сторона':'Правая сторона'}{percent(result[side])}{result[side]===0?'Нейтраль':result[side]>0?'Вперёд':'Назад'}
)}

Команда стороны распространяется на все назначенные ей моторы: два, четыре, шесть и более. Связь — по UUID, а направление проверяется отдельно для каждого мотора.

}, {id:'takeover',label:'Перехват пультом · проверка сценария',content:
{trace(profile).map(({label,decision},i)=>
{i+1}. {label}{states[decision.state]}{percent(decision.demand.left)} / {percent(decision.demand.right)}
)}

Первый жест отменяет допуск и очередь Mission Core. Новый допуск требует явного действия: нейтраль и восстановление связи не возобновляют автономную задачу.

}, ]}/>
; } document.documentElement.dataset.nodedcTheme='dark'; createRoot(document.getElementById('root')!).render();