// GreenhouseMap.jsx — data-driven greenhouse floorplan from selected map payload function toPercent(value) { const v = Number(value); if (!Number.isFinite(v)) return "0%"; return `${Math.max(0, Math.min(1, v)) * 100}%`; } function normalizeId(value) { if (value === null || value === undefined) return null; return String(value); } function statusToTri(state) { const v = String(state || "").toLowerCase(); if (v === "critical" || v === "crit" || v === "error" || v === "failed") return "cri"; if (v === "offline" || v === "off") return "off"; if (v === "warning" || v === "warn" || v === "alert") return "att"; if (v === "active" || v === "act") return "act"; if (v === "ok" || v === "normal" || v === "online") return "thr"; return "thr"; } const STATUS_META = [ { key: "thr", label: "Норма", color: "#7FB539", zoneFill: "rgba(127,181,57,.14)" }, { key: "act", label: "Активный", color: "#E8B84A", zoneFill: "rgba(232,184,74,.16)" }, { key: "att", label: "Внимание", color: "#FF7A00", zoneFill: "rgba(255,122,0,.16)" }, { key: "cri", label: "Авария", color: "#E5342B", zoneFill: "rgba(229,52,43,.14)" }, { key: "off", label: "Офлайн", color: "#C2CCD8", zoneFill: "rgba(194,204,216,.32)" }, ]; const STATUS_META_BY_KEY = STATUS_META.reduce((acc, item) => { acc[item.key] = item; return acc; }, {}); function statusMetaFromKey(key) { return STATUS_META_BY_KEY[key] || STATUS_META_BY_KEY.thr; } const MAP_EMPTY_PAYLOAD = { greenhouse_id: null, blueprint_url: null, zones: [], positions: [], device_placements: [] }; const MAP_REFRESH_MS = 60 * 1000; const MAP_STALE_AFTER_MS = 20 * 60 * 1000; function safeDate(value) { if (!value || typeof value !== "string") return null; const isoLike = value.includes("T") || value.includes("-"); if (!isoLike) return null; const d = new Date(value); return Number.isNaN(d.getTime()) ? null : d; } function formatClock(value) { const d = safeDate(value); if (!d) return "—"; return d.toLocaleTimeString("ru-RU", { hour12: false }); } function formatAge(value) { const d = safeDate(value); if (!d) return "n/a"; const diffSec = Math.max(0, Math.floor((Date.now() - d.getTime()) / 1000)); if (diffSec < 60) return `${diffSec} сек`; const min = Math.floor(diffSec / 60); if (min < 60) return `${min} мин`; const hr = Math.floor(min / 60); return `${hr} ч`; } function isStaleTimestamp(value) { const d = safeDate(value); if (!d) return false; return (Date.now() - d.getTime()) > MAP_STALE_AFTER_MS; } function pickLatestMapTimestamp(mapPayload, hives, lastAttemptAt) { const candidates = []; (mapPayload?.zones || []).forEach((zone) => { if (zone && typeof zone.updated_at === "string") candidates.push(zone.updated_at); }); (hives || []).forEach((hive) => { if (!hive || typeof hive !== "object") return; if (typeof hive.updated_at === "string") candidates.push(hive.updated_at); if (typeof hive.last_updated_at === "string") candidates.push(hive.last_updated_at); }); if (typeof lastAttemptAt === "string") candidates.push(lastAttemptAt); const withDates = candidates .map((item) => ({ value: item, date: safeDate(item) })) .filter((item) => !!item.date) .sort((a, b) => b.date.getTime() - a.date.getTime()); return withDates.length ? withDates[0].value : null; } function buildFallbackPositions(zones) { if (!Array.isArray(zones) || zones.length === 0) return []; const cols = Math.max(1, Math.ceil(Math.sqrt(zones.length))); const rows = Math.max(1, Math.ceil(zones.length / cols)); const cellWidth = 1 / cols; const cellHeight = 1 / rows; return zones.map((zone, index) => { const col = index % cols; const row = Math.floor(index / cols); return { zone_id: zone.id, x: Number((col * cellWidth).toFixed(6)), y: Number((row * cellHeight).toFixed(6)), width: Number(cellWidth.toFixed(6)), height: Number(cellHeight.toFixed(6)), }; }); } function buildHivePlacements(positions, hivesByZone) { const result = []; (positions || []).forEach((pos) => { const zoneId = normalizeId(pos.zone_id || pos.id); const hives = (zoneId && hivesByZone.get(zoneId)) || []; if (!hives.length) return; const width = Number(pos.width) || 0; const height = Number(pos.height) || 0; const x0 = Number(pos.x) || 0; const y0 = Number(pos.y) || 0; if (width <= 0 || height <= 0) return; const cols = Math.max(1, Math.ceil(Math.sqrt(hives.length))); const rows = Math.max(1, Math.ceil(hives.length / cols)); const spacingX = width / cols; const spacingY = height / rows; hives.forEach((hive, index) => { const col = index % cols; const row = Math.floor(index / cols); const x = x0 + spacingX * (col + 0.5); const y = y0 + spacingY * (row + 0.5); result.push({ id: normalizeId(hive.id), num: normalizeId(hive.id), zoneId, triState: statusToTri(hive.state || hive.status), x, y, }); }); }); return result; } function buildDevicePlacements(rawPlacements) { return (rawPlacements || []) .map((item) => { const id = normalizeId(item.device_id || item.id); if (!id) return null; return { id, zoneId: normalizeId(item.zone_id || item.zoneId), x: Number(item.x) || 0, y: Number(item.y) || 0, kind: item.kind || item.type || "sensor", triState: statusToTri(item.status || item.state), }; }) .filter(Boolean); } function Zone({ zone, rowCount, isSelected, onClick }) { const hiveCount = Number(zone.hive_count || 0); const deviceCount = Number(zone.device_count || 0); const label = zone.name || zone.row || zone.id || "Зона"; const statusMeta = statusMetaFromKey(zone.triState); return (
onClick && onClick(zone)} role={onClick ? "button" : undefined} tabIndex={onClick ? 0 : undefined} onKeyDown={(e) => { if (!onClick) return; if (e.key === "Enter" || e.key === " ") { e.preventDefault(); onClick(zone); } }} style={{ left: toPercent(zone.x), top: toPercent(zone.y), width: toPercent(zone.width), height: toPercent(zone.height), borderColor: isSelected ? "#007BFB" : statusMeta.color, boxShadow: isSelected ? "inset 0 0 0 1px rgba(0,123,251,.2)" : `inset 0 0 0 1px ${statusMeta.color}22`, background: isSelected ? undefined : statusMeta.zoneFill, }} > {label} {hiveCount} ульев · {deviceCount} датч.
{Array.from({ length: rowCount }).map((_, i) =>
)}
); } function GreenhouseMap({ selectedHive, onSelectHive, onDrillZone, onDrillHive, onDrillDevice, greenhouseId = "gh-1", selectedGreenhouseId, selectedZoneId, }) { const RecommendationsPanel = window.RecommendationsPanel; const [mapPayload, setMapPayload] = React.useState(MAP_EMPTY_PAYLOAD); const [hives, setHives] = React.useState([]); const [mapLifecycle, setMapLifecycle] = React.useState({ status: "loading", // "loading" | "ready" | "empty" | "stale" | "error" message: "", lastSuccessAt: null, dataTimestamp: null, }); const requestSeqRef = React.useRef(0); const lastSnapshotRef = React.useRef(null); const resolvedGreenhouseId = selectedGreenhouseId || greenhouseId || "gh-1"; const greenhouse = Object.values(DATA?.greenhouses || {}) .flat() .find((item) => String(item.id) === String(resolvedGreenhouseId)); const zone = (mapPayload.zones || []).find((item) => String(item.id) === String(selectedZoneId)); const loadMapData = React.useCallback(async (opts = {}) => { const silent = Boolean(opts.silent); const seq = ++requestSeqRef.current; if (!resolvedGreenhouseId) { setMapPayload(MAP_EMPTY_PAYLOAD); setHives([]); setMapLifecycle({ status: "empty", message: "", lastSuccessAt: null, dataTimestamp: null, }); return; } if (!silent) { setMapLifecycle((prev) => Object.assign({}, prev, { status: "loading", message: "" })); } const [mapResult, hivesResult] = await Promise.allSettled([ Keeper.api.greenhouseMap(resolvedGreenhouseId), Keeper.api.hives(resolvedGreenhouseId), ]); if (seq !== requestSeqRef.current) return; const fallbackHives = (DATA?.hives?.[resolvedGreenhouseId] || []); const mapValue = mapResult.status === "fulfilled" && mapResult.value && typeof mapResult.value === "object" ? mapResult.value : MAP_EMPTY_PAYLOAD; const safeHives = hivesResult.status === "fulfilled" && Array.isArray(hivesResult.value) ? hivesResult.value : fallbackHives; const normalizedMap = { greenhouse_id: mapValue.greenhouse_id || resolvedGreenhouseId, blueprint_url: mapValue.blueprint_url || null, zones: Array.isArray(mapValue.zones) ? mapValue.zones : [], positions: Array.isArray(mapValue.positions) ? mapValue.positions : [], device_placements: Array.isArray(mapValue.device_placements) ? mapValue.device_placements : [], }; const hasRenderableData = normalizedMap.zones.length > 0 || normalizedMap.positions.length > 0 || normalizedMap.device_placements.length > 0 || safeHives.length > 0; const firstError = mapResult.status === "rejected" ? mapResult.reason : (hivesResult.status === "rejected" ? hivesResult.reason : null); if (firstError && lastSnapshotRef.current) { setMapPayload(lastSnapshotRef.current.mapPayload); setHives(lastSnapshotRef.current.hives); setMapLifecycle({ status: "stale", message: "Не удалось обновить карту. Показаны последние загруженные данные.", lastSuccessAt: lastSnapshotRef.current.lastSuccessAt || null, dataTimestamp: lastSnapshotRef.current.dataTimestamp || null, }); return; } if (firstError && !hasRenderableData) { const fallbackMessage = "Не удалось загрузить карту теплицы. Повторите попытку."; const formatted = typeof Keeper.formatApiFailure === "function" ? Keeper.formatApiFailure(firstError, { fallbackMessage }) : fallbackMessage; setMapPayload(MAP_EMPTY_PAYLOAD); setHives([]); setMapLifecycle({ status: "error", message: formatted, lastSuccessAt: null, dataTimestamp: null, }); return; } const refreshedAt = new Date().toISOString(); const latestDataTs = pickLatestMapTimestamp(normalizedMap, safeHives, refreshedAt); const staleByAge = hasRenderableData && isStaleTimestamp(latestDataTs); const status = hasRenderableData ? (staleByAge || !!firstError ? "stale" : "ready") : "empty"; const message = staleByAge ? `Данные карты могли устареть (${formatAge(latestDataTs)} назад).` : (firstError ? "Часть данных недоступна. Показан резервный срез." : ""); setMapPayload(normalizedMap); setHives(safeHives); setMapLifecycle({ status, message, lastSuccessAt: refreshedAt, dataTimestamp: latestDataTs, }); if (status !== "empty") { lastSnapshotRef.current = { mapPayload: normalizedMap, hives: safeHives, lastSuccessAt: refreshedAt, dataTimestamp: latestDataTs, }; } }, [resolvedGreenhouseId]); React.useEffect(() => { loadMapData(); }, [loadMapData]); React.useEffect(() => { if (!resolvedGreenhouseId) return undefined; const timer = setInterval(() => { loadMapData({ silent: true }); }, MAP_REFRESH_MS); return () => clearInterval(timer); }, [resolvedGreenhouseId, loadMapData]); const zones = Array.isArray(mapPayload.zones) ? mapPayload.zones : []; const positions = (Array.isArray(mapPayload.positions) && mapPayload.positions.length) ? mapPayload.positions : buildFallbackPositions(zones); const zoneById = new Map(zones.map((item) => [normalizeId(item.id), item])); const positionedZones = positions .map((position) => { const zoneId = normalizeId(position.zone_id); if (!zoneId) return null; const zoneItem = zoneById.get(zoneId) || { id: zoneId, name: zoneId, hive_count: 0, device_count: 0, status: "ok" }; return { id: zoneId, x: Number(position.x) || 0, y: Number(position.y) || 0, width: Number(position.width) || 1, height: Number(position.height) || 1, name: zoneItem.name || zoneItem.row || zoneItem.row_label || zoneId, row: zoneItem.row || zoneItem.row_label || null, status: zoneItem.status || "ok", triState: statusToTri(zoneItem.status || zoneItem.state || "ok"), hive_count: Number(zoneItem.hive_count ?? zoneItem.hives_count ?? 0), device_count: Number(zoneItem.device_count ?? zoneItem.devices_count ?? 0), }; }) .filter(Boolean); const hivesByZone = hives.reduce((acc, hive) => { const zoneId = normalizeId(hive.zone_id || hive.zoneId); if (!zoneId) return acc; if (!acc.has(zoneId)) acc.set(zoneId, []); acc.get(zoneId).push(hive); return acc; }, new Map()); const hivePlacements = buildHivePlacements(positions, hivesByZone); const devicePlacements = buildDevicePlacements(mapPayload.device_placements); const totalDevices = positionedZones.reduce((sum, item) => sum + (item.device_count || 0), 0); const totalHives = hives.length || positionedZones.reduce((sum, item) => sum + (item.hive_count || 0), 0); const mapTitle = greenhouse?.name || "Теплица № 1 «Север»"; const mapSubtitle = zone ? `${zone.id || "Зона"} · ${zone.name || zone.row || zone.row_label || "контекст из маршрута"}` : `${totalHives} ульев · ${totalDevices} датчиков · ${positionedZones.length} зон`; const isLoading = mapLifecycle.status === "loading"; const isError = mapLifecycle.status === "error"; const isStale = mapLifecycle.status === "stale"; const showEmpty = mapLifecycle.status === "empty" || (!isLoading && !isError && positionedZones.length === 0); const hasMapVisuals = positionedZones.length > 0 || hivePlacements.length > 0 || devicePlacements.length > 0; return (
{mapTitle}
{mapSubtitle}
{isStale && hasMapVisuals && (
{mapLifecycle.message || "Показаны последние известные данные карты"} {(mapLifecycle.dataTimestamp || mapLifecycle.lastSuccessAt) && ( Обновлено: {formatClock(mapLifecycle.dataTimestamp || mapLifecycle.lastSuccessAt)} )}
)}
{mapPayload.blueprint_url && ( Схема теплицы )}
{positionedZones.map((z) => { const rowCount = Math.max(6, Math.min(42, Math.round((z.width * 44) + (z.height * 28)))); return ( onDrillZone({ greenhouseId: resolvedGreenhouseId, zoneId: zoneItem.id, }) : null} /> ); })} {hivePlacements.map((h) => (
{ e.stopPropagation(); if (onDrillHive) { onDrillHive({ greenhouseId: resolvedGreenhouseId, zoneId: h.zoneId, hiveId: h.id, }); return; } if (onSelectHive) onSelectHive(h.id); }} title={`Улей ${h.num}`} > {h.num}
))} {devicePlacements.map((d) => (
{ e.stopPropagation(); if (onDrillDevice) { onDrillDevice({ greenhouseId: resolvedGreenhouseId, zoneId: d.zoneId, deviceId: d.id, }); } }} title={`Устройство ${d.id}`} >
))}
С
0 ────────── 20 м
{isLoading && (
Загрузка карты…
)} {!isLoading && isError && (
{mapLifecycle.message || "Не удалось загрузить карту теплицы"}
)} {!isLoading && !isError && showEmpty && (
Для выбранной теплицы нет данных карты
)}
{STATUS_META.map((item) => ( {item.label} ))}
{isLoading && "Загрузка карты…"} {!isLoading && isError && "Ошибка загрузки карты"} {!isLoading && !isError && showEmpty && "Карта загружена, но данных для отображения пока нет"} {!isLoading && !isError && !showEmpty && isStale && "Показаны устаревшие/резервные данные карты"} {!isLoading && !isError && !showEmpty && !isStale && "Легенда статусов общая для зон, устройств и ульев"}
); } window.GreenhouseMap = GreenhouseMap;