import { useEffect, useMemo, useState, type CSSProperties } from 'react'; import { useQuery } from '@tanstack/react-query'; import { Activity, AlertTriangle, CircuitBoard, DatabaseZap, PlugZap, RefreshCw } from 'lucide-react'; import type { CurrentEvent, CurrentItem, CurrentResponse, } from '../api/cloud'; import { getCurrent, getCurrentEventsUrl } from '../api/cloud'; import { getPublishedDiagramPage, listDiagramPages, type DiagramDocument, type DiagramEdge, type DiagramNode, type DiagramPageSummary, type DiagramPoint, } from '../api/diagram'; import { formatDateTime, formatNumber } from '../utils/format'; type ElectricalSchematicsPageProps = { edgeId: string; }; type LiveValue = CurrentItem & { sourceKey: string; }; type BindingTag = { tagId: string; edgeId: string; sourceKey: string; sseUrl?: string; }; type LiveGroup = { sourceKey: string; edgeId: string; sseUrl?: string; tags: string[]; }; const DEFAULT_PAGE_PREFIXES = ['ELECTRICAL', 'MAIN', 'DIRECT']; /** Выбирает опубликованную страницу, отдавая приоритет электросхеме текущей установки. */ function pickDefaultPage(pages: DiagramPageSummary[], edgeId: string): DiagramPageSummary | undefined { const published = pages.filter((page) => page.publishedRevision); const byPriority = [...published].sort((a, b) => { const aRank = getPageRank(a.pageKey, edgeId); const bRank = getPageRank(b.pageKey, edgeId); return aRank - bRank || a.pageKey.localeCompare(b.pageKey); }); return byPriority[0]; } /** Чем меньше ранг, тем выше страница в списке выбора опубликованных схем. */ function getPageRank(pageKey: string, edgeId: string): number { const exactKeys = DEFAULT_PAGE_PREFIXES.map((prefix) => (prefix === 'DIRECT' ? edgeId : `${prefix}_${edgeId}`)); const exactIndex = exactKeys.indexOf(pageKey); if (exactIndex >= 0) { return exactIndex; } return pageKey.includes(edgeId) ? 10 : 20; } /** Собирает теги, которые надо читать по SSE, из виджетов и bindings у элементов схемы. */ function collectBindingTags(document: DiagramDocument): BindingTag[] { const tags: BindingTag[] = []; for (const node of document.nodes) { if (node.kind === 'tagWidget') { tags.push({ tagId: node.tagId, edgeId: node.edgeId || document.ownerEdgeId, sourceKey: `edge:${node.edgeId || document.ownerEdgeId}`, }); continue; } const bindings = node.bindings; if (!bindings) { continue; } const edgeId = bindings.sseEdgeId || bindings.edgeId || document.ownerEdgeId; const sourceKey = bindings.sseUrl || `edge:${edgeId}`; [bindings.stateTagId, bindings.alarmTagId].filter(Boolean).forEach((tagId) => { tags.push({ tagId: tagId!, edgeId, sourceKey, sseUrl: bindings.sseUrl, }); }); } const unique = new Map(); tags.forEach((tag) => unique.set(`${tag.sourceKey}:${tag.edgeId}:${tag.tagId}`, tag)); return Array.from(unique.values()); } /** Группирует подписки так, чтобы один EventSource читал сразу несколько тегов одного edge/source. */ function createLiveGroups(tags: BindingTag[]): LiveGroup[] { const groups = new Map(); tags.forEach((tag) => { const group = groups.get(tag.sourceKey) ?? { sourceKey: tag.sourceKey, edgeId: tag.edgeId, sseUrl: tag.sseUrl, tags: [], }; if (!group.tags.includes(tag.tagId)) { group.tags.push(tag.tagId); } groups.set(tag.sourceKey, group); }); return Array.from(groups.values()); } /** Добавляет tags-параметр к пользовательскому SSE URL, если он еще не задан в настройке. */ function buildCustomSseUrl(sseUrl: string, tags: string[]): string { const url = new URL(sseUrl, window.location.origin); if (tags.length && !url.searchParams.has('tags')) { url.searchParams.set('tags', tags.join(',')); } return url.toString(); } /** Возвращает live-значение с учетом edge, но оставляет fallback по tag для старых схем. */ function getLiveValue(values: Map, edgeId: string, tagId?: string): LiveValue | undefined { if (!tagId) { return undefined; } return values.get(`${edgeId}:${tagId}`) ?? values.get(`tag:${tagId}`); } /** Рассчитывает размеры полотна по опубликованным координатам элементов. */ function getCanvasBounds(document?: DiagramDocument) { const nodes = document?.nodes ?? []; const maxX = Math.max(...nodes.map((node) => node.position.x + getNodeSize(node).width), 960); const maxY = Math.max(...nodes.map((node) => node.position.y + getNodeSize(node).height), 560); return { width: Math.ceil(maxX + 80), height: Math.ceil(maxY + 80), }; } function getNodeSize(node: DiagramNode) { if (node.kind === 'decoration') { return node.size; } return node.size ?? { width: 190, height: 82 }; } function getNodeTitle(node: DiagramNode, value?: LiveValue) { if (node.kind === 'tagWidget') { return node.label || value?.name || node.tagId; } return String(node.data?.title || node.data?.text || node.decorationType); } function getNodeCenter(node: DiagramNode): DiagramPoint { const size = getNodeSize(node); return { x: node.position.x + size.width / 2, y: node.position.y + size.height / 2, }; } function getEdgePoints(edge: DiagramEdge, nodeMap: Map): DiagramPoint[] { const source = nodeMap.get(edge.sourceNodeId); const target = nodeMap.get(edge.targetNodeId); if (!source || !target) { return []; } return [getNodeCenter(source), ...(edge.waypoints ?? []), getNodeCenter(target)]; } function pointsToPolyline(points: DiagramPoint[]) { return points.map((point) => `${point.x},${point.y}`).join(' '); } /** Отображает опубликованную электросхему и подставляет live-значения через SSE cloud-v3. */ export function ElectricalSchematicsPage({ edgeId }: ElectricalSchematicsPageProps) { const [selectedPageKey, setSelectedPageKey] = useState(''); const [liveValues, setLiveValues] = useState(() => new Map()); const [connectedSources, setConnectedSources] = useState>(new Set()); const pages = useQuery({ queryKey: ['diagram-pages', edgeId], queryFn: () => listDiagramPages(edgeId), enabled: Boolean(edgeId), }); const publishedPages = useMemo(() => pages.data?.filter((page) => page.publishedRevision) ?? [], [pages.data]); const selectedPage = useMemo( () => publishedPages.find((page) => page.pageKey === selectedPageKey) ?? pickDefaultPage(publishedPages, edgeId), [edgeId, publishedPages, selectedPageKey], ); useEffect(() => { if (selectedPage && selectedPage.pageKey !== selectedPageKey) { setSelectedPageKey(selectedPage.pageKey); } }, [selectedPage, selectedPageKey]); const page = useQuery({ queryKey: ['published-diagram-page', selectedPage?.pageKey], queryFn: () => getPublishedDiagramPage(selectedPage!.pageKey), enabled: Boolean(selectedPage?.pageKey), }); const document = page.data?.document; const bindingTags = useMemo(() => (document ? collectBindingTags(document) : []), [document]); const liveGroups = useMemo(() => createLiveGroups(bindingTags), [bindingTags]); const nodeMap = useMemo(() => new Map((document?.nodes ?? []).map((node) => [node.id, node])), [document?.nodes]); const canvasBounds = useMemo(() => getCanvasBounds(document), [document]); useEffect(() => { setLiveValues(new Map()); setConnectedSources(new Set()); }, [selectedPage?.pageKey]); useEffect(() => { if (!liveGroups.length) { return undefined; } let closed = false; const eventSources: EventSource[] = []; const mergeResponse = (response: CurrentResponse, sourceKey: string) => { if (closed) { return; } setLiveValues((previous) => { const next = new Map(previous); response.items.forEach((item) => { const value = { ...item, sourceKey }; next.set(`${item.edge}:${item.tag}`, value); next.set(`tag:${item.tag}`, value); }); return next; }); }; liveGroups.forEach((group) => { if (!group.sseUrl) { void getCurrent(group.edgeId, group.tags).then((response) => mergeResponse(response, group.sourceKey)); } const url = group.sseUrl ? buildCustomSseUrl(group.sseUrl, group.tags) : getCurrentEventsUrl(group.edgeId, group.tags); const eventSource = new EventSource(url); eventSource.onopen = () => { setConnectedSources((previous) => new Set(previous).add(group.sourceKey)); }; eventSource.onerror = () => { setConnectedSources((previous) => { const next = new Set(previous); next.delete(group.sourceKey); return next; }); }; eventSource.onmessage = (message) => { const event = JSON.parse(message.data) as CurrentEvent; mergeResponse(event, group.sourceKey); }; eventSources.push(eventSource); }); return () => { closed = true; eventSources.forEach((eventSource) => eventSource.close()); }; }, [liveGroups]); const liveTagCount = liveValues.size ? Array.from(liveValues.keys()).filter((key) => key.startsWith('tag:')).length : 0; const connected = connectedSources.size > 0; return (
Электросхемы

{page.data?.title || document?.title || selectedPage?.pageKey || edgeId}

{publishedPages.length > 1 ? ( ) : null}
{connected ? `SSE · ${connectedSources.size}` : liveGroups.length ? 'подключение SSE' : 'нет привязок'}
{pages.isError ? (
Не удалось загрузить список опубликованных схем: {String(pages.error)}
) : null} {!pages.isPending && !publishedPages.length && !pages.isError ? (
Для {edgeId} пока нет опубликованных схем
) : null} {page.isError ? (
Не удалось загрузить опубликованную схему: {String(page.error)}
) : null} {document ? (
{document.background?.url ? ( ) : null} {(document.edges ?? []).map((edge) => { const points = getEdgePoints(edge, nodeMap); if (points.length < 2) { return null; } return ( ); })} {document.nodes.map((node) => ( ))}
) : page.isPending || pages.isPending ? (
Загрузка опубликованной схемы...
) : null}
); } function ElectricalNode({ node, ownerEdgeId, liveValues, }: { node: DiagramNode; ownerEdgeId: string; liveValues: Map; }) { const size = getNodeSize(node); const bindings = node.kind === 'decoration' ? node.bindings : undefined; const bindingEdgeId = bindings?.sseEdgeId || bindings?.edgeId || ownerEdgeId; const mainValue = node.kind === 'tagWidget' ? getLiveValue(liveValues, node.edgeId || ownerEdgeId, node.tagId) : getLiveValue(liveValues, bindingEdgeId, bindings?.stateTagId); const alarmValue = node.kind === 'decoration' ? getLiveValue(liveValues, bindingEdgeId, bindings?.alarmTagId) : undefined; const alarmActive = Boolean(alarmValue && Number(alarmValue.value) !== 0); const stateActive = Boolean(mainValue && Number(mainValue.value) !== 0); const title = getNodeTitle(node, mainValue); const style: CSSProperties = { left: node.position.x, top: node.position.y, width: size.width, height: size.height, zIndex: node.zIndex ?? 1, transform: node.kind === 'decoration' && node.rotation ? `rotate(${node.rotation}deg)` : undefined, }; return (
{alarmActive ? : node.kind === 'tagWidget' ? : } {title}
{mainValue ? ( {formatNumber(mainValue.value)} {mainValue.unitOfMeasurement ? {mainValue.unitOfMeasurement} : null} ) : node.kind === 'decoration' ? ( {String(node.data?.text || node.data?.title || node.decorationType)} ) : ( - )}
{node.kind === 'tagWidget' ? {node.tagId} : bindings?.stateTagId ? {bindings.stateTagId} : {node.decorationType}} {alarmValue ? alarm {formatNumber(alarmValue.value)} : null}
); }