hive placement planner
Планировщик размещения ульев — техническая документация
🎯 Обзор
Интерактивный инструмент планирования на основе холста для оптимального размещения ульев на пасеках. Включает симуляцию солнца в реальном времени, рендеринг многоугольных теней и комплексное манипулирование объектами с функцией автоматического сохранения. Создан с использованием хуков HTML5 Canvas и React.
🏗️ Архитектура
Компоненты
- HivePlacement: основной компонент React (
/web-app/src/page/apiaryEdit/hivePlacement/index.tsx). - Рендеринг на холсте: пользовательские функции рисования ульев, препятствий, теней и элементов пользовательского интерфейса.
- Управление состоянием: перехватчики React с размещением на основе карты для поиска O(1).
Услуги
- swarm-api: размещение улья CRUD, управление препятствиями, GraphQL API
- web-app: приложение Frontend React с рендерингом на холсте.
- mysql: сохранение данных о местах размещения и препятствиях.
📋 Технические характеристики
Схема базы данных
Миграция: 20251208190000_add_hive_placement.sql
erDiagram
users ||--o{ hive_placements : owns
apiaries ||--o{ hive_placements : contains
hives ||--o{ hive_placements : has
users ||--o{ apiary_obstacles : owns
apiaries ||--o{ apiary_obstacles : contains
users {
INT_UNSIGNED id PK
}
apiaries {
INT_UNSIGNED id PK
}
hives {
INT_UNSIGNED id PK
}
hive_placements {
INT_UNSIGNED id PK
INT_UNSIGNED user_id FK "NOT NULL"
INT_UNSIGNED apiary_id FK "NOT NULL"
INT_UNSIGNED hive_id FK "NOT NULL, UNIQUE(apiary_id,hive_id,user_id)"
FLOAT x "NOT NULL"
FLOAT y "NOT NULL"
FLOAT rotation "NOT NULL, DEFAULT 180"
TIMESTAMP created_at
TIMESTAMP updated_at
}
apiary_obstacles {
INT_UNSIGNED id PK
INT_UNSIGNED user_id FK "NOT NULL"
INT_UNSIGNED apiary_id FK "NOT NULL"
ENUM type "NOT NULL, CIRCLE|RECTANGLE"
FLOAT x "NOT NULL"
FLOAT y "NOT NULL"
FLOAT width "NULL"
FLOAT height "NULL"
FLOAT radius "NULL"
FLOAT rotation "NOT NULL, DEFAULT 0"
VARCHAR label "NULL, max 100"
TIMESTAMP created_at
TIMESTAMP updated_at
}
Индексы:
hive_placements:idx_apiary_user (apiary_id, user_id)apiary_obstacles:idx_apiary_user (apiary_id, user_id)
Ограничения:
- Все внешние ключи:
ON DELETE CASCADE. hive_placements: уникальное ограничение для(apiary_id, hive_id, user_id).
GraphQL API
type HivePlacement {
id: ID!
apiaryId: ID!
hiveId: ID!
x: Float!
y: Float!
rotation: Float!
}
type ApiaryObstacle {
id: ID!
apiaryId: ID!
type: ObstacleType!
x: Float!
y: Float!
width: Float
height: Float
radius: Float
rotation: Float!
label: String
}
enum ObstacleType {
CIRCLE
RECTANGLE
}
input ApiaryObstacleInput {
type: ObstacleType!
x: Float!
y: Float!
width: Float
height: Float
radius: Float
rotation: Float!
label: String
}
# Queries
hivePlacements(apiaryId: ID!): [HivePlacement!]!
apiaryObstacles(apiaryId: ID!): [ApiaryObstacle!]!
# Mutations
updateHivePlacement(
apiaryId: ID!
hiveId: ID!
x: Float!
y: Float!
rotation: Float!
): HivePlacement!
addApiaryObstacle(
apiaryId: ID!
obstacle: ApiaryObstacleInput!
): ApiaryObstacle!
updateApiaryObstacle(
id: ID!
obstacle: ApiaryObstacleInput!
): ApiaryObstacle!
deleteApiaryObstacle(id: ID!): Boolean!
🔧 Детали реализации
Архитектура frontend
Управление государством
// Placements stored as Map for O(1) access
const [placements, setPlacements] = useState<Map<string, HivePlacement>>(new Map())
const [obstacles, setObstacles] = useState<Obstacle[]>([])
// Interaction states
const [selectedHive, setSelectedHive] = useState<string | null>(null)
const [selectedObstacle, setSelectedObstacle] = useState<string | null>(null)
const [isDragging, setIsDragging] = useState(false)
const [isDraggingRotation, setIsDraggingRotation] = useState(false)
const [isDraggingObstacle, setIsDraggingObstacle] = useState(false)
const [isResizingObstacle, setIsResizingObstacle] = useState(false)
const [isDraggingObstacleRotation, setIsDraggingObstacleRotation] = useState(false)
// Sun simulation
const [sunAngle, setSunAngle] = useState(90) // Start at East
const [autoRotate, setAutoRotate] = useState(false)
Размеры холста
const HIVE_SIZE = 30 // pixels
const CANVAS_HEIGHT = 600 // fixed height
const [canvasWidth, setCanvasWidth] = useState(800) // dynamic, responsive
// ResizeObserver for width tracking
useEffect(() => {
const updateCanvasWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth
setCanvasWidth(Math.max(600, width))
}
}
const observer = new ResizeObserver(updateCanvasWidth)
if (containerRef.current) observer.observe(containerRef.current)
return () => observer.disconnect()
}, [])
Конвейер рендеринга
const drawCanvas = () => {
const ctx = canvas.getContext('2d')
// 1. Clear canvas
ctx.clearRect(0, 0, canvasWidth, CANVAS_HEIGHT)
// 2. Draw background
ctx.fillStyle = '#e8f5e9'
ctx.fillRect(0, 0, canvasWidth, CANVAS_HEIGHT)
// 3. Draw compass with sun
drawCompass(ctx)
// 4. Draw shadows (obstacles + hives)
drawShadows(ctx)
// 5. Draw obstacles with handles
drawObstacles(ctx)
// 6. Draw hives with handles
drawHives(ctx)
}
Теневой алгоритм (критический)
// Shadow offset calculation (away from sun)
const angleRad = (sunAngle - 90) * (Math.PI / 180)
const shadowLength = 80
const shadowOffsetX = -Math.cos(angleRad) * shadowLength // Negative = away from sun
const shadowOffsetY = -Math.sin(angleRad) * shadowLength
// Polygonal shadow for rectangles
const drawRectangleShadow = (obstacle) => {
const rotRad = (obstacle.rotation || 0) * (Math.PI / 180)
// 1. Get rotated corners
const corners = [
{ x: -width/2, y: -height/2 },
{ x: width/2, y: -height/2 },
{ x: width/2, y: height/2 },
{ x: -width/2, y: height/2 }
]
const rotatedCorners = corners.map(c => ({
x: obs.x + c.x * Math.cos(rotRad) - c.y * Math.sin(rotRad),
y: obs.y + c.x * Math.sin(rotRad) + c.y * Math.cos(rotRad)
}))
// 2. Draw continuous path: object → shadow → close
ctx.beginPath()
// Draw object outline
rotatedCorners.forEach((c, i) => {
if (i === 0) ctx.moveTo(c.x, c.y)
else ctx.lineTo(c.x, c.y)
})
// Draw to shadow projections (reverse order)
for (let i = rotatedCorners.length - 1; i >= 0; i--) {
const c = rotatedCorners[i]
ctx.lineTo(c.x + shadowOffsetX, c.y + shadowOffsetY)
}
ctx.closePath()
ctx.fill()
}
Стратегия инициализации (шаблон слияния)
useEffect(() => {
if (loading || !hives.length) return
const map = new Map()
// 1. Load saved placements from backend
if (data?.hivePlacements) {
data.hivePlacements.forEach(p => map.set(p.hiveId, p))
}
// 2. Add defaults for hives without placements
hives.forEach((hive, index) => {
if (!map.has(hive.id)) {
map.set(hive.id, {
hiveId: hive.id,
x: 150 + (index % 5) * 120,
y: 150 + Math.floor(index / 5) * 100,
rotation: 180 // South-facing by default
})
}
})
setPlacements(map)
setInitializedPlacements(true)
}, [data, hives.length, loading])
Бэкэнд (Go)
Модели
// graph/model/hive_placement.go
type HivePlacement struct {
ID uint `json:"id"`
UserID uint `json:"userId"`
ApiaryID uint `json:"apiaryId"`
HiveID uint `json:"hiveId"`
X float64 `json:"x"`
Y float64 `json:"y"`
Rotation float64 `json:"rotation"`
}
func (hp *HivePlacement) Upsert(apiaryId, hiveId string, x, y, rotation float64) (*HivePlacement, error) {
// Check for existing placement
var existingID uint
err := hp.Db.QueryRow(`
SELECT id FROM hive_placements
WHERE apiary_id=? AND hive_id=? AND user_id=?
`, apiaryId, hiveId, hp.UserID).Scan(&existingID)
if err == sql.ErrNoRows {
// Insert new
result, err := hp.Db.Exec(`
INSERT INTO hive_placements (user_id, apiary_id, hive_id, x, y, rotation)
VALUES (?, ?, ?, ?, ?, ?)
`, hp.UserID, apiaryId, hiveId, x, y, rotation)
// ... handle result
} else {
// Update existing
_, err = hp.Db.Exec(`
UPDATE hive_placements
SET x=?, y=?, rotation=?
WHERE id=? AND user_id=?
`, x, y, rotation, existingID, hp.UserID)
// ... handle result
}
}
Резолверы
// graph/schema.resolvers.go
func (r *queryResolver) HivePlacements(ctx context.Context, apiaryID string) ([]*model.HivePlacement, error) {
uid := ctx.Value("userID").(string)
return (&model.HivePlacement{
Db: r.Resolver.Db,
UserID: uid,
}).List(apiaryID)
}
func (r *mutationResolver) UpdateHivePlacement(ctx context.Context,
apiaryID string, hiveID string, x float64, y float64, rotation float64) (*model.HivePlacement, error) {
uid := ctx.Value("userID").(string)
return (&model.HivePlacement{
Db: r.Resolver.Db,
UserID: uid,
}).Upsert(apiaryID, hiveID, x, y, rotation)
}
🎨 Шаблоны взаимодействия
Обработка событий мыши
handleCanvasMouseDown(e) {
const { x, y } = getMousePos(e)
// Priority 1: Check selected object handles
if (selectedObstacle) {
if (isOverResizeHandle(x, y)) {
setIsResizingObstacle(true)
return
}
if (isOverRotationHandle(x, y)) {
setIsDraggingObstacleRotation(true)
return
}
if (isInsideObstacle(x, y)) {
setIsDraggingObstacle(true)
return
}
}
// Priority 2: Check hive handles
if (selectedHive) {
if (isOverHiveRotationHandle(x, y)) {
setIsDraggingRotation(true)
return
}
if (isInsideHive(x, y)) {
setIsDragging(true)
return
}
}
// Priority 3: Select new object
for (const obstacle of obstacles) {
if (isInsideObstacle(x, y, obstacle)) {
setSelectedObstacle(obstacle.id)
setSelectedHive(null)
setIsDraggingObstacle(true)
return
}
}
for (const hive of hives) {
if (isInsideHive(x, y, hive)) {
setSelectedHive(hive.id)
setSelectedObstacle(null)
setIsDragging(true)
return
}
}
}
Стратегия автосохранения
handleCanvasMouseUp() {
// Only save on mouse up, not on every move
if ((isDraggingObstacle || isResizingObstacle || isDraggingObstacleRotation)
&& selectedObstacle) {
const obs = obstacles.find(o => o.id === selectedObstacle)
if (obs) {
updateObstacle({
id: selectedObstacle,
obstacle: {
type: obs.type,
x: obs.x,
y: obs.y,
width: obs.width,
height: obs.height,
radius: obs.radius,
rotation: obs.rotation
}
})
}
}
if ((isDragging || isDraggingRotation) && selectedHive) {
const placement = placements.get(selectedHive)
if (placement) {
updatePlacement({
apiaryId,
hiveId: selectedHive,
x: placement.x,
y: placement.y,
rotation: placement.rotation
})
}
}
// Reset all dragging states
setIsDragging(false)
setIsDraggingRotation(false)
setIsDraggingObstacle(false)
setIsDraggingObstacleRotation(false)
setIsResizingObstacle(false)
}
📊 Вопросы производительности
Оптимизация рендеринга
— Холст перерисовывается при изменении состояния (приемлемо для объектов <100).
- Расчет теней: O(n), где n = препятствия + ульи.
– Места размещения на основе карты: поиск O(1). - ResizeObserver регулируется браузером (~ 16 мс)
Управление памятью
- Один элемент холста (без утечек памяти)
— Прослушиватели событий правильно очищены в useEffect. - Обновления карты создают новую карту (неизменяемый шаблон).
- Нет роста памяти во время длительных сеансов
Оптимизация сети
- Отменены сохранения (только при поднятии мыши)
- мутации GraphQL, пакетно распространяемые браузером.
- Нет опроса (обновления на основе push через мутации)
- Запросы, кэшированные клиентом Apollo.
🔒 Безопасность
Авторизация
- Идентификатор пользователя из контекста JWT.
- Все запросы фильтруются по user_id
- Ограничения внешнего ключа предотвращают потерю данных.
- ON DELETE CASCADE для очистки данных.
Проверка ввода
- Система типа Go проверяет входные данные GraphQL.
- Ограничения базы данных обеспечивают целостность данных.
- Интерфейс предотвращает размещение за пределами поля
- Установлены ограничения минимального/максимального размера.
Изоляция данных
- Каждый пользователь видит только свои места размещения
- Уникальные ограничения предотвращают дублирование мест размещения.
- Изоляция на уровне пасеки в запросах
🐛 Известные проблемы и ограничения
- Поддержка браузера: ResizeObserver недоступен в IE11 (приемлемо).
- Touch Events: базовая поддержка, не оптимизирована для мобильных устройств.
- Отменить/Повторить: не реализовано (будущее улучшение).
- Навигация с помощью клавиатуры: не реализовано (пробел в доступности).
- Средства чтения с экрана: ограниченная поддержка (интерфейс на основе холста).
🚀 Будущие улучшения
Этап 2
- Инструмент измерения расстояния
- Наложение визуализации траектории полета
- Индикатор направления ветра
- Печать/экспорт в PDF
Этап 3
- Импортировать препятствия со спутниковых снимков.
- Клонировать макет на другие пасеки
- Сезонные настройки угла солнца
- Оптимизация мобильных сенсорных жестов
Этап 4
- Совместное планирование (многопользовательское)
- Историческое управление версиями макета
- Библиотека шаблонов макетов
- AR-визуализация (наложение мобильной камеры)
📚 Сопутствующая документация
🧪 Тестирование
Подробные сценарии тестирования и критерии приемки см. в HIVE_PLACEMENT_TESTING.md.