// 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 (
{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}`}
>
))}
С
{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;