6 Commits

Author SHA1 Message Date
Ilia Miheev 26a207d61e Поправил package и отображение картинок в продакшене 2026-06-06 08:44:45 +03:00
Ilia Miheev f43c22d636 Фикс бага с потерей маршрута
Если составить маршрут -> перейти в превью и вертуться обратно на главную, то весь маршрут сотрётся. Тоже сомое если посмотреть термины и вернуться.
2026-06-06 07:48:58 +03:00
Ilia Miheev 94dfe682c6 Сделал открытие ссылок в браузере по умолчанию 2026-06-06 00:58:21 +03:00
Ilia Miheev 9f599a564e Добавил анимацию как в MacOS 2026-06-06 00:52:14 +03:00
Ilia Miheev 7eb973ee99 Добавил описания к терминам, поправил стили 2026-06-06 00:45:30 +03:00
Ilia Miheev 725b4a7a6e Добавил картинок и подзаголовков для терминов 2026-06-06 00:00:59 +03:00
19 changed files with 192 additions and 95 deletions
+9 -4
View File
@@ -1,10 +1,15 @@
{ {
"name": "SkyVue", "name": "skyvue",
"productName": "SkyVue", "productName": "SkyVue",
"version": "1.0.0", "version": "1.0.0",
"description": "My Electron application description", "description": "Визуальное планирование полёта дрона и генерация Python-кода для Clover/ROS",
"main": ".vite/build/main.js", "main": ".vite/build/main.js",
"private": true, "private": true,
"homepage": "https://github.com/IliaMiheev/probody",
"repository": {
"type": "git",
"url": "https://github.com/IliaMiheev/probody.git"
},
"scripts": { "scripts": {
"start": "electron-forge start", "start": "electron-forge start",
"package": "electron-forge package", "package": "electron-forge package",
@@ -12,8 +17,8 @@
"publish": "electron-forge publish", "publish": "electron-forge publish",
"lint": "echo \"No linting configured\"" "lint": "echo \"No linting configured\""
}, },
"keywords": [], "keywords": ["drone", "clover", "ros", "electron", "flight-planning"],
"author": "Student", "author": "Clovers (https://github.com/IliaMiheev/probody)",
"license": "MIT", "license": "MIT",
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.11.1", "@electron-forge/cli": "^7.11.1",
Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+1 -17
View File
@@ -1,6 +1,6 @@
<template> <template>
<div class="navigation-wrapper"> <div class="navigation-wrapper">
<button class="burger-btn" @click="toggleMenu" :class="{ 'is-active': isOpen }" aria-label="Menu"> <button class="burger-btn" @click="toggleMenu" aria-label="Menu">
<span class="line"></span> <span class="line"></span>
<span class="line"></span> <span class="line"></span>
<span class="line"></span> <span class="line"></span>
@@ -156,10 +156,6 @@ h1 {
background-color: transparent !important; background-color: transparent !important;
} }
.menu-list-item.is-active {
background-color: transparent !important;
padding-bottom: 20px;
}
.menu-content button{ .menu-content button{
font-size: 1.2rem; font-size: 1.2rem;
@@ -172,18 +168,6 @@ h1 {
transition: all 0.3s ease; transition: all 0.3s ease;
} }
.is-active .line:nth-child(1) {
transform: translateY(6px) rotate(45deg);
}
.is-active .line:nth-child(2) {
opacity: 0;
}
.is-active .line:nth-child(3) {
transform: translateY(-6px) rotate(-45deg);
}
.side-panel { .side-panel {
position: fixed; position: fixed;
top: 0; top: 0;
+8 -5
View File
@@ -1,7 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { publicPath } from '@/shared/publicPath'
const APP_LOGO = '/images/drone.png' const APP_LOGO = 'images/drone.png'
const props = defineProps({ const props = defineProps({
title: { title: {
@@ -24,7 +25,7 @@ const props = defineProps({
defineEmits(['click']) defineEmits(['click'])
const imageSrc = computed(() => props.image || APP_LOGO) const imageSrc = computed(() => publicPath(props.image || APP_LOGO))
</script> </script>
<template> <template>
@@ -55,20 +56,22 @@ const imageSrc = computed(() => props.image || APP_LOGO)
align-items: center; align-items: center;
gap: 15px; gap: 15px;
padding: 15px; padding: 15px;
padding-left: 15px;
margin: 8px 12px; margin: 8px 12px;
border-radius: 12px; border-radius: 12px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: background 0.2s ease, padding-left 0.25s ease;
background: rgba(255, 255, 255, 0.05); background: rgba(255, 255, 255, 0.05);
box-sizing: border-box; box-sizing: border-box;
} }
.list-card:hover { .list-card:hover {
background: rgba(35, 170, 227, 0.2); background: rgb(31, 170, 230);
padding-left: 6px;
} }
.list-card--active { .list-card--active {
background: linear-gradient(135deg, #23aae3, #1a6d8f); background:rgb(29, 149, 201);
box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3); box-shadow: 0 4px 15px rgba(35, 170, 227, 0.3);
} }
+2 -7
View File
@@ -1,5 +1,3 @@
<script setup>
</script>
<template> <template>
<svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg"> <svg viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
<title/> <title/>
@@ -17,10 +15,7 @@
</template> </template>
<style scoped> <style scoped>
.cls-1{ .cls-1 {
fill:none; fill: none;
}
#Layer_2 {
color: black;
} }
</style> </style>
+33
View File
@@ -0,0 +1,33 @@
import { ref } from 'vue'
const numbers = ref([])
const clickCount = ref(1)
const listOfTrips = ref([])
function createEmptyGrid() {
return Array.from({ length: 100 }, (_, i) => ({
variable: [],
x: i % 10,
y: 9 - Math.floor(i / 10),
index: i,
isPlatform: false,
isQR: false,
BgImg: 'images/ArUco-marker.jpg',
}))
}
function ensureGridInitialized() {
if (numbers.value.length === 0) {
numbers.value = createEmptyGrid()
}
}
export function usePlannerState() {
ensureGridInitialized()
return {
numbers,
clickCount,
listOfTrips,
}
}
+47 -27
View File
@@ -1,92 +1,112 @@
const rawTerms = [ export const termsItems = [
{ {
id: 1,
title: 'ROS', title: 'ROS',
body: 'ROS (Robot Operating System) — экосистема для программирования роботов. В приложении через ROS дрон получает команды, передает телеметрию и данные с камеры.', subtitle: 'Экосистема',
image: '/images/terms/ROS.webp', image: '/images/terms/ROS.webp',
description: 'ROS (Robot Operating System) — экосистема для программирования роботов и дронов. Несмотря на слово «Operating System», это не операционная система, а набор библиотек, инструментов и правил, по которым разные программы робота обмениваются данными.\n\nВ приложении SkyVue сгенерированный Python-код работает внутри ROS: дрон получает команды полёта, передаёт телеметрию (координаты, высоту) и данные с камеры. Без ROS отдельные части системы — камера, контроллер полёта, ваш скрипт — не смогли бы «разговаривать» друг с другом.\n\nОсновные элементы ROS:\n• Ноды (Node) — отдельные программы, каждая выполняет свою задачу\n• Топики (Topic) — каналы для непрерывной передачи данных (изображение, телеметрия)\n• Сервисы (Service) — вызов функции с немедленным ответом (команда navigate, запрос get_telemetry)\n• Сообщения (Message) — формат данных, который передаётся в топиках и сервисах\n\nНа платформе Clover ROS уже настроен: камера, полётный контроллер и симулятор Gazebo подключены и готовы к работе. Вам остаётся написать логику в одной ноде — приложение делает это автоматически.',
hrefToDoc: 'https://www.ros.org/', hrefToDoc: 'https://www.ros.org/',
hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748', hrefToCourse: 'https://stepik.org/lesson/2260739/step/1?unit=2294748',
}, },
{ {
id: 2,
title: 'OpenCV', title: 'OpenCV',
body: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения. С её помощью дрон распознает цвета платформ и считывает QR-коды.', subtitle: 'Компьютерное зрение',
image: '/images/terms/openCV.webp', image: '/images/terms/openCV.webp',
description: 'OpenCV (Open Source Computer Vision Library) — библиотека компьютерного зрения с открытым исходным кодом. Она умеет обрабатывать изображения и видео: находить объекты, определять цвета, считывать QR-коды и ArUco-маркеры.\n\nЭто «глаза и мозг» для обработки картинки с камеры дрона. Камера сама по себе только передаёт пиксели — OpenCV превращает их в полезную информацию: «перед нами красная платформа» или «в QR-коде написано число 42».\n\nЧто OpenCV делает в заданиях с Clover:\n• Распознаёт цвет платформы (красный, зелёный, синий) по HSV-диапазону\n• Считывает QR-коды с помощью cv2.QRCodeDetector\n• Обрабатывает кадры с камеры в реальном времени через cv2.cvtColor, cv2.inRange и другие функции\n• Работает и в симуляторе Gazebo, и на реальном дроне — код один и тот же\n\nOpenCV — стандарт индустрии: её используют в робототехнике, автономных машинах и системах видеонаблюдения. В Python подключается одной строкой: import cv2.',
hrefToDoc: 'https://opencv.org/', hrefToDoc: 'https://opencv.org/',
hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893', hrefToCourse: 'https://stepik.org/lesson/2272700/step/1?unit=2306893',
}, },
{ {
id: 3,
title: 'navigate_wait', title: 'navigate_wait',
body: 'Navigate_wait — функция из библиотеки Clover. Отправляет дрон в точку и ждёт, пока он туда долетит, и только потом выполняет следующую команду. Это важно, потому что обычная команда navigate не ждет: дрон ещё летит, а скрипт уже бежит дальше — из-за этого следующие команды могут выполняться раньше времени.', subtitle: 'Clover',
image: '/images/terms/navigate_wait.jpg',
description: 'navigate_wait — функция из библиотеки Clover (clover.srv), которая отправляет дрон в заданную точку и ждёт, пока он туда долетит. Только после прилёта выполняется следующая строка кода.\n\nЭто «команда с терпением»: скрипт не бежит дальше, пока дрон не завершит перелёт.\n\nЧем отличается от обычного navigate:\n• navigate — отправляет команду и сразу идёт дальше. Дрон ещё летит, а скрипт уже выполняет следующие действия\n• navigate_wait — блокирует выполнение до прилёта. Следующая команда гарантированно выполнится в нужной точке\n\nКогда использовать navigate_wait:\n• Перед считыванием QR-кода или определением цвета платформы — дрон должен зависнуть над целью\n• При последовательных перемещениях, где порядок важен (сначала точка A, потом точка B)\n• Когда нужно дождаться завершения манёвра перед следующим действием\n\nПараметры: x, y, z (координаты), frame_id (система координат, например body или aruco_map), speed (скорость), auto_arm (автовооружение). В SkyVue эти параметры подставляются из блоков траектории на карте.',
hrefToDoc: 'https://clover.coex.tech/ru/snippets.html', hrefToDoc: 'https://clover.coex.tech/ru/snippets.html',
hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803', hrefToCourse: 'https://stepik.org/lesson/2264712/step/1?unit=2298803',
}, },
{ {
id: 4,
title: 'Нода (Node)', title: 'Нода (Node)',
body: 'Нода (Node/узел) в ROS - исполняемая программа, которая выполняет одну конкретную задачу. В сгенерированном коде вся логика полета и обработки камеры находится внутри одной ноды. Это позволяет запускать, останавливать и отлаживать программу как единое целое. ', subtitle: 'ROS',
image: '/images/terms/node.jpg',
description: 'Нода (Node, узел) в ROS — исполняемая программа, которая выполняет одну конкретную задачу и общается с другими нодами через топики и сервисы.\n\nЭто «отдельный работник» в команде робота: один следит за камерой, другой управляет полётом, третий обрабатывает изображение. Каждый работает независимо, но координируется через ROS.\n\nКак это выглядит в сгенерированном коде:\n• Вся логика полёта и обработки камеры находится внутри одной ноды\n• В начале файла — rospy.init_node(\'flight\'), который регистрирует программу в ROS\n• Внутри ноды создаются подписчики (на камеру, телеметрию) и вызываются сервисы (navigate, get_telemetry)\n\nЗачем одна нода, а не несколько:\n• Проще отлаживать — весь код в одном месте\n• Легче запускать: rosrun clover blockly\n• Удобно для учебных заданий, где логика невелика\n\nВ реальных проектах нод может быть много: отдельная нода для камеры, для навигации, для логирования. Но принцип тот же — каждая нода = одна программа с чёткой задачей.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758', hrefToCourse: 'https://stepik.org/lesson/2261765/step/1?unit=2295758',
}, },
{ {
id: 5,
title: 'Топик (Topic)', title: 'Топик (Topic)',
body: 'Топик (Topic) в ROS — канал обмена данными. Например, камера публикует изображение в топик, а ваш скрипт подписывается на него и получает кадры.', subtitle: 'ROS',
image: '/images/terms/topic.jpg',
description: 'Топик (Topic) в ROS — именованный канал для обмена данными между нодами. Один узел публикует сообщения в топик, другие подписываются и получают их — как радиостанция, на которую настроились несколько приёмников.\n\nЭто «радиоканал» робота: данные текут непрерывно, пока издатель их отправляет.\n\nПримеры топиков на Clover:\n• /main_camera/image/compressed — сжатые кадры с камеры дрона\n• /mavros/state — состояние полётного контроллера (вооружён/не вооружён)\n• /mavros/local_position/pose — текущие координаты дрона\n\nКак работает связка:\n1. Камера (нода image_publisher) постоянно публикует кадры в топик\n2. Ваш скрипт подписывается на этот топик через rospy.Subscriber\n3. Каждый новый кадр автоматически вызывает вашу функцию-обработчик\n\nТопики vs сервисы:\n• Топик — поток данных без ожидания ответа (видео, телеметрия)\n• Сервис — один запрос, один ответ (команда «лети сюда»)\n\nИмя топика всегда начинается с / и уникально в системе ROS.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866', hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
id: 6,
title: 'Издатель (Publisher)', title: 'Издатель (Publisher)',
body: 'Издатель (Publisher) в ROS — часть программы, которая отправляет данные в топик. Например, если нужно передать команду другому узлу или опубликовать координаты дрона — создается издатель, который публикует сообщения в нужный канал. Подписчики других узлов могут эти данные получать. ', subtitle: 'ROS',
image: '/images/terms/publisher.jpg',
description: 'Издатель (Publisher) в ROS — объект в программе, который отправляет (публикует) сообщения в топик. Любая нода может создать издатель и передавать данные всем подписчикам этого топика.\n\nЭто «передатчик» в системе ROS: вы говорите что-то в эфир, а кто настроился — тот слышит.\n\nКак создаётся издатель в Python:\n• pub = rospy.Publisher(\'/my_topic\', String, queue_size=10)\n• pub.publish(сообщение) — отправить данные в топик\n\nГде встречается на практике:\n• Нода камеры Clover публикует изображения — вы только подписываетесь\n• Если нужно передать результат своей обработки другой ноде — создаёте свой издатель\n• Логирование: публикация отладочных сообщений в топик /rosout\n\nПараметры при создании:\n• Имя топика — куда публиковать\n• Тип сообщения (String, Image, PoseStamped и др.) — формат данных\n• queue_size — сколько сообщений хранить в очереди, если подписчик не успевает\n\nВ учебных заданиях Clover чаще используются подписчики (получать данные с камеры), а не издатели. Но понимание издателя важно для полной картины ROS.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866', hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
id: 7,
title: 'Подписчик (Subscriber)', title: 'Подписчик (Subscriber)',
body: 'Подписчик (Subscriber) в ROS — часть программы, которая получает данные из топика. Например, чтобы дрон "видел" изображение с камеры или считывал свои координаты, создаётся подписчик на соответствующий топик. Как только в топике появляются новые данные, подписчик автоматически их обрабатывает.', subtitle: 'ROS',
image: '/images/terms/subscriber.jpg',
description: 'Подписчик (Subscriber) в ROS — объект в программе, который получает данные из топика. Когда в топике появляется новое сообщение, ROS автоматически вызывает вашу callback-функцию с этими данными.\n\nЭто «приёмник» робота: вы настраиваетесь на нужный канал и реагируете на каждое новое сообщение.\n\nКак это работает в коде Clover:\n• rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, image_callback)\n• Функция image_callback получает каждый кадр с камеры\n• Внутри callback можно обработать изображение через OpenCV: определить цвет, прочитать QR-код\n\nТипичный сценарий в заданиях:\n1. Подписаться на камеру\n2. В callback получить кадр, декодировать его\n3. Обработать через cv2 — найти цвет платформы или QR-код\n4. Вызвать unregister(), когда данные больше не нужны\n\nВажно:\n• Callback вызывается асинхронно — не блокирует основной поток\n• Не делайте в callback долгих операций — иначе пропустите кадры\n• Всегда отключайте подписчика через unregister(), когда он больше не нужен',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866', hrefToCourse: 'https://stepik.org/lesson/2262844/step/1?unit=2296866',
}, },
{ {
id: 8,
title: 'Сервис', title: 'Сервис',
body: 'Сервис в ROS - способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные идут постоянно, сервис работает по принципу "запрос — ответ". Например, команда navigate — это сервис: вы отправляете координаты, а робот отвечает "команда принята". Команда get_telemetry возвращает текущие координаты дрона.', subtitle: 'ROS',
image: '/images/terms/service.jpg',
description: 'Сервис (Service) в ROS — способ вызвать функцию робота и сразу получить ответ. В отличие от топика, где данные текут непрерывно, сервис работает по принципу «запрос — ответ»: один раз спросил, один раз получил результат.\n\nЭто «кнопка с обратной связью»: нажал — робот выполнил и сообщил, получилось или нет.\n\nОсновные сервисы Clover:\n• navigate / navigate_wait — отправить дрон в точку (x, y, z)\n• get_telemetry — получить текущие координаты, высоту, скорость\n• land — посадить дрон\n• arming — вооружить или разоружить моторы\n\nКак вызывается сервис в Python:\n• rospy.wait_for_service(\'navigate\') — дождаться, пока сервис станет доступен\n• navigate = rospy.ServiceProxy(\'navigate\', Navigate)\n• navigate(x=1, y=0, z=1.5, frame_id=\'body\', speed=0.5) — вызов с параметрами\n\nСервис vs топик:\n• Сервис — когда нужен конкретный результат прямо сейчас (координаты, команда полёта)\n• Топик — когда данные приходят постоянно (видеопоток, телеметрия в реальном времени)\n\nВ SkyVue блоки траектории генерируют вызовы navigate_wait — это как раз работа через сервис.',
hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html', hrefToDoc: 'https://docs.voltbro.ru/starting-ros/basics.html',
hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206', hrefToCourse: 'https://stepik.org/lesson/2263182/step/1?unit=2297206',
}, },
{ {
id: 9,
title: 'Системы координат', title: 'Системы координат',
body: 'Определяют, откуда отсчитывать координаты дрона. body — относительно самого дрона (вперед/назад/влево/вправо). aruco_map — относительно карты с ArUco-метками, где координаты привязаны к неподвижным маркерам. Выбор правильной системы координат позволяет дрону точно выполнять команды: лететь относительно себя для коротких маневров или по глобальной карте для точной навигации.', subtitle: 'Навигация',
image: '/images/terms/coordinate-system.jpg',
description: 'Системы координат (frames) определяют, откуда отсчитываются координаты дрона: относительно самого дрона, карты ArUco или глобальной системы. Без правильного frame_id команда «лети на x=1, y=0» может отправить дрон не туда.\n\nЭто «точка отсчёта» на карте: одни и те же числа x, y, z означают разное в зависимости от выбранной системы.\n\nОсновные системы на Clover:\n• body — координаты относительно дрона: x — вперёд/назад, y — влево/вправо, z — вверх/вниз. Удобно для коротких манёвров («пролети 2 метра вперёд»)\n• aruco_map — координаты относительно карты ArUco-меток на полу. Маркеры неподвижны, поэтому дрон знает своё абсолютное положение в комнате\n• map — глобальная карта (используется реже в учебных заданиях)\n\nКогда что использовать:\n• body — относительные перемещения от текущей позиции\n• aruco_map — полёт к конкретной точке на карте, навигация между платформами\n• При смене системы координаты пересчитываются автоматически через TF (Transform)\n\nВ SkyVue система координат задаётся в настройках траектории и подставляется в параметр frame_id при вызове navigate_wait.',
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407', hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
hrefToDoc: 'https://clover.coex.tech/ru/frames.html', hrefToDoc: 'https://clover.coex.tech/ru/frames.html',
}, },
{ {
id: 10,
title: 'ArUco-маркер', title: 'ArUco-маркер',
body: 'ArUco-маркер — квадратный визуальный маркер, который камера дрона может распознавать. Используется для навигации: по положению маркера дрон определяет свои координаты в пространстве.', subtitle: 'Навигация',
image: '/images/terms/ArUco-marker.webp', image: '/images/terms/ArUco-marker.webp',
description: 'ArUco-маркер — квадратный чёрно-белый визуальный маркер с уникальным ID внутри. Камера дрона распознаёт его через OpenCV и определяет положение маркера (и своё собственное) в пространстве.\n\nЭто «маяк на полу»: дрон смотрит на маркер и понимает, где он находится относительно карты.\n\nКак работает навигация по ArUco:\n• На полу или стенах размещаются маркеры с известными координатами\n• Камера дрона видит маркеры и вычисляет свою позицию в системе aruco_map\n• Дрон может лететь к точке с точностью до сантиметров — без GPS\n\nГде применяется:\n• Учебные полигоны и соревнования (RoboCup, олимпиады по робототехнике)\n• Полёты в помещении, где GPS не работает\n• Точная посадка на платформу или маркированную точку\n\nНа Clover:\n• Пакет aruco_detect распознаёт маркеры и публикует их позиции\n• Система координат aruco_map привязана к расположению маркеров на карте\n• В симуляторе Gazebo маркеры уже расставлены на сцене\n\nКаждый маркер имеет числовой ID (0, 1, 2...) — по нему система различает маркеры на одной карте.',
hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407', hrefToCourse: 'https://stepik.org/lesson/2264336/step/1?unit=2298407',
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html', hrefToDoc: 'https://klever-doc.tech/ROS1/ru/aruco.html',
}, },
{ {
id: 11,
title: 'Симулятор (Gazebo)', title: 'Симулятор (Gazebo)',
body: 'Симулятор (Gazebo) — виртуальная среда, где дрон ведет себя как в реальности. Позволяет безопасно тестировать полетные задания перед запуском на реальном дроне.', subtitle: 'Симуляция',
image: '/images/terms/gazebo.jpg',
description: 'Gazebo — физический симулятор роботов и дронов. В виртуальной среде дрон ведёт себя как в реальности: летит, реагирует на команды, камера передаёт изображение, ArUco-маркеры видны на полу.\n\nЭто «тренировочный полигон» без риска разбить дрон: ошибся в коде — перезапустил симуляцию, а не чинил пропеллер.\n\nЧто симулирует Gazebo на Clover:\n• Физику полёта: подъёмная сила, инерция, гравитация\n• Камеру дрона с тем же топиком /main_camera/image/compressed\n• ArUco-маркеры на виртуальной карте\n• Все ROS-сервисы: navigate, get_telemetry, land — работают так же, как на реальном дроне\n\nЗачем использовать симулятор:\n• Безопасное тестирование кода перед полётом на реальном Clover\n• Не нужен физический дрон — достаточно компьютера\n• Тот же Python-код запускается и в Gazebo, и на реальном роботе\n• Можно повторять задание сколько угодно раз\n\nКак запустить: roslaunch clover sim_clover.launch — поднимается Gazebo со сценой и дроном. Код из SkyVue можно сразу тестировать в симуляторе через rosrun.',
hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564', hrefToCourse: 'https://stepik.org/lesson/2278310/step/1?unit=2312564',
hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html', hrefToDoc: 'https://klever-doc.tech/ROS1/ru/simulation.html',
}, },
{ {
id: 12,
title: 'unregister()', title: 'unregister()',
body: 'unregister() — метод, который отключает подписчика от топика. Используется, чтобы перестать получать данные, когда они больше не нужны (например, после сканирования QR-кода).', subtitle: 'ROS',
image: '/images/terms/subscriber.jpg',
description: 'unregister() — метод объекта подписчика (Subscriber) в ROS, который отключает его от топика. После вызова программа перестаёт получать новые сообщения из этого канала.\n\nЭто «выключить приёмник»: данные больше не нужны — отписываемся, чтобы не тратить ресурсы и не вызывать callback зря.\n\nКогда использовать:\n• После успешного считывания QR-кода — камера больше не нужна для этой задачи\n• Когда определили цвет платформы и пора лететь дальше\n• Перед длительной операцией (navigate_wait), чтобы callback не мешал основному потоку\n• В конце программы — корректное завершение работы с ROS\n\nКак выглядит в коде:\n• sub = rospy.Subscriber(\'/main_camera/image/compressed\', CompressedImage, callback)\n• ... обработка кадров ...\n• sub.unregister() — отключение\n\nЧто будет, если не вызывать unregister():\n• Callback продолжит вызываться на каждый кадр — лишняя нагрузка на CPU\n• Возможны конфликты: callback срабатывает во время navigate_wait\n• При повторном создании подписчика на тот же топик — дублирование обработчиков\n\nПравило: создали подписчик — отключите его, когда задача выполнена.',
hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058', hrefToCourse: 'https://stepik.org/lesson/2272845/step/1?unit=2307058',
hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html', hrefToDoc: 'https://docs.ros.org/en/diamondback/api/rospy/html/rospy.topics.Subscriber-class.html',
}, },
] ]
function toSubtitle(text) { export default termsItems
if (!text) return ''
return text.length > 72 ? `${text.slice(0, 72)}` : text
}
export default rawTerms.map((item, index) => ({
id: index + 1,
title: item.title,
subtitle: toSubtitle(item.body),
image: item.image ?? '',
description: item.body,
hrefToDoc: item.hrefToDoc ?? null,
hrefToCourse: item.hrefToCourse ?? null,
}))
+34 -1
View File
@@ -1,4 +1,4 @@
import { app, BrowserWindow } from 'electron'; import { app, BrowserWindow, shell } from 'electron';
import path from 'node:path'; import path from 'node:path';
import started from 'electron-squirrel-startup'; import started from 'electron-squirrel-startup';
@@ -7,6 +7,38 @@ if (started) {
app.quit(); app.quit();
} }
function isAppNavigation(url) {
if (MAIN_WINDOW_VITE_DEV_SERVER_URL && url.startsWith(MAIN_WINDOW_VITE_DEV_SERVER_URL)) {
return true;
}
return url.startsWith('file://');
}
function openExternalLink(url) {
shell.openExternal(url).catch((error) => {
console.error('Failed to open external link:', url, error);
});
}
function configureExternalLinks(mainWindow) {
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
openExternalLink(url);
return { action: 'deny' };
});
mainWindow.webContents.on('will-navigate', (event, url) => {
if (isAppNavigation(url)) {
return;
}
if (url.startsWith('http://') || url.startsWith('https://')) {
event.preventDefault();
openExternalLink(url);
}
});
}
const createWindow = () => { const createWindow = () => {
const { screen } = require('electron'); const { screen } = require('electron');
const primaryDisplay = screen.getPrimaryDisplay(); const primaryDisplay = screen.getPrimaryDisplay();
@@ -22,6 +54,7 @@ const createWindow = () => {
}); });
mainWindow.setMenuBarVisibility(false); mainWindow.setMenuBarVisibility(false);
mainWindow.maximize(); mainWindow.maximize();
configureExternalLinks(mainWindow);
// and load the index.html of the app. // and load the index.html of the app.
if (MAIN_WINDOW_VITE_DEV_SERVER_URL) { if (MAIN_WINDOW_VITE_DEV_SERVER_URL) {
+8
View File
@@ -0,0 +1,8 @@
export function publicPath(relativePath) {
if (!relativePath) {
return relativePath
}
const path = relativePath.replace(/^\.\//, '').replace(/^\//, '')
return `${import.meta.env.BASE_URL}${path}`
}
+41 -31
View File
@@ -1,5 +1,5 @@
<script setup> <script setup>
import { ref, onMounted, h, inject, provide } from "vue"; import { ref, h, inject, provide } from "vue";
import { useRouter } from "vue-router"; import { useRouter } from "vue-router";
import izitoast from "@/shared/izitoast"; import izitoast from "@/shared/izitoast";
import { useEventListener } from "@vueuse/core"; import { useEventListener } from "@vueuse/core";
@@ -16,32 +16,18 @@ import Trash from "@/components/UI/svg/Trash.vue";
import Back from "@/components/UI/svg/Back.vue"; import Back from "@/components/UI/svg/Back.vue";
import { NTooltip } from 'naive-ui' import { NTooltip } from 'naive-ui'
import { useFlightCode } from '@/composables/useFlightCode' import { useFlightCode } from '@/composables/useFlightCode'
import { usePlannerState } from '@/composables/usePlannerState'
import { publicPath } from '@/shared/publicPath'
const router = useRouter() const router = useRouter()
const { finallyText } = useFlightCode() const { finallyText } = useFlightCode()
const { numbers, clickCount, listOfTrips } = usePlannerState()
const numbers = ref([]);
const clickCount = ref(1);
const listOfTrips = ref([]);
const isModalVisible = ref(false); const isModalVisible = ref(false);
const dropMenu = ref(null); const dropMenu = ref(null);
provide('dropMenu', dropMenu); provide('dropMenu', dropMenu);
onMounted(() => {
for (let i = 0; i < 100; i++) {
numbers.value.push({
variable: [],
x: i % 10,
y: 9 - Math.floor(i / 10),
index: i,
isPlatform: false,
isQR: false,
BgImg: 'images/ArUco-marker.jpg'
});
}
});
function setNumber(item) { function setNumber(item) {
let valueOfMarker = item.variable[0]; let valueOfMarker = item.variable[0];
if (valueOfMarker === "") { if (valueOfMarker === "") {
@@ -271,7 +257,7 @@ function deletePointMap(item) {
function createIcon(src) { function createIcon(src) {
return h('img', { return h('img', {
src: src, src: publicPath(src),
style: { style: {
width: '20px', width: '20px',
height: '20px', height: '20px',
@@ -343,7 +329,7 @@ function onContextMenu(e, item) {
<div class="wraper"> <div class="wraper">
<div class="grid"> <div class="grid">
<div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)" <div class="square" v-for="(item, index) in numbers" :data-key="index" :key="index" @click="setNumber(item)"
@contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${item.BgImg})` }"> @contextmenu.prevent="onContextMenu($event, item)" :style="{ backgroundImage: `url(${publicPath(item.BgImg)})` }">
{{ numbers[index].variable.join(", ") }} {{ numbers[index].variable.join(", ") }}
</div> </div>
</div> </div>
@@ -351,25 +337,33 @@ function onContextMenu(e, item) {
<div class="actions"> <div class="actions">
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<Save @click="saveResult" /> <span class="toolbar-item">
<Save @click="saveResult" />
</span>
</template> </template>
Сохранить Сохранить
</n-tooltip> </n-tooltip>
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<Trash @click="cleanResult" /> <span class="toolbar-item">
<Trash @click="cleanResult" />
</span>
</template> </template>
Удалить Удалить
</n-tooltip> </n-tooltip>
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<Back @click="cancel" /> <span class="toolbar-item">
<Back @click="cancel" />
</span>
</template> </template>
Отменить Отменить
</n-tooltip> </n-tooltip>
<n-tooltip trigger="hover"> <n-tooltip trigger="hover">
<template #trigger> <template #trigger>
<Preview @click="openPreview" /> <span class="toolbar-item">
<Preview @click="openPreview" />
</span>
</template> </template>
Превью Превью
</n-tooltip> </n-tooltip>
@@ -458,6 +452,7 @@ function onContextMenu(e, item) {
position: fixed; position: fixed;
bottom: 10px; bottom: 10px;
display: flex; display: flex;
align-items: flex-end;
gap: 10px; gap: 10px;
padding: 10px 20px; padding: 10px 20px;
border-radius: 10px; border-radius: 10px;
@@ -470,18 +465,33 @@ function onContextMenu(e, item) {
border: 2px solid rgba(255, 255, 255, 0.18); border: 2px solid rgba(255, 255, 255, 0.18);
} }
.actions svg { .toolbar-item {
width: var(--sizeActions); display: inline-flex;
height: var(--sizeActions); align-items: center;
transition: color 0.4s; justify-content: center;
cursor: pointer;
} }
.actions svg:hover { .toolbar-item svg {
width: var(--sizeActions);
height: var(--sizeActions);
transition:
transform 0.35s cubic-bezier(0.34, 1.56, 0.64, 1),
color 0.25s ease;
transform-origin: center bottom;
will-change: transform;
}
.toolbar-item:hover svg {
transform: scale(1.22) translateY(-6px);
color: rgba(20, 104, 140, 0.89); color: rgba(20, 104, 140, 0.89);
} }
.actions svg:active { .toolbar-item:active svg {
transition: color 0s; transition:
transform 0.12s ease,
color 0s;
transform: scale(1.08) translateY(-2px);
color: #23aae3e2; color: #23aae3e2;
} }
+8 -3
View File
@@ -1,9 +1,12 @@
<script setup> <script setup>
import { ref, watch, provide } from 'vue' import { ref, watch, provide, computed } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { resolveTopic } from '@/composables/useTopicResolver' import { resolveTopic } from '@/composables/useTopicResolver'
import MyDropMenu from "@/components/MyDropMenu.vue"; import MyDropMenu from "@/components/MyDropMenu.vue";
import CustomButton from '@/components/UI/CustomButton.vue' import CustomButton from '@/components/UI/CustomButton.vue'
import { publicPath } from '@/shared/publicPath'
const APP_LOGO = 'images/drone.png'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -11,6 +14,8 @@ const topic = ref(null)
const dropMenu = ref(null); const dropMenu = ref(null);
provide('dropMenu', dropMenu); provide('dropMenu', dropMenu);
const topicImage = computed(() => publicPath(topic.value?.image || APP_LOGO))
async function loadTopic() { async function loadTopic() {
topic.value = await resolveTopic(route.params.source, route.params.id) topic.value = await resolveTopic(route.params.source, route.params.id)
@@ -39,12 +44,12 @@ function goBack() {
</button> </button>
<div class="topic-view__image"> <div class="topic-view__image">
<img :src="topic.image" :alt="topic.title" /> <img :src="topicImage" :alt="topic.title" />
</div> </div>
<div class="topic-view__header"> <div class="topic-view__header">
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
<h1 class="topic-view__title">{{ topic.title }}</h1> <h1 class="topic-view__title">{{ topic.title }}</h1>
<p v-if="topic.subtitle" class="topic-view__subtitle">{{ topic.subtitle }}</p>
</div> </div>
<p class="topic-view__description">{{ topic.description }}</p> <p class="topic-view__description">{{ topic.description }}</p>
+1
View File
@@ -4,6 +4,7 @@ import vue from '@vitejs/plugin-vue';
// https://vitejs.dev/config // https://vitejs.dev/config
export default defineConfig({ export default defineConfig({
base: './',
plugins: [vue()], plugins: [vue()],
resolve: { resolve: {
alias: { alias: {