feat: редизайн UI — приглушённые цвета, шапки устройств, компактные контейнеры
Some checks failed
continuous-integration/drone/push Build is failing

- Цвета статусов: приглушённые тона для печати, красный акцент для неисправных
- DeviceNode: название в цветной шапке, карты ниже без наложений
- Перенос слов вместо обрезки во всех нодах
- Лейаут: убран лишний gap после последнего слоя, SITE_MIN_WIDTH 250,
  корректный расчёт startY от шапки сайта
- Белый фон графа, чёрные линии по умолчанию

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-17 22:52:26 +03:00
parent e1160997b2
commit fd6373bcb0
8 changed files with 134 additions and 97 deletions

View File

@ -1,6 +1,6 @@
export const SITE_HEADER_HEIGHT = 80; export const SITE_HEADER_HEIGHT = 56;
export const SITE_PADDING = 50; export const SITE_PADDING = 30;
export const SITE_MIN_WIDTH = 500; export const SITE_MIN_WIDTH = 250;
export const SITE_MIN_HEIGHT = 200; export const SITE_MIN_HEIGHT = 200;
export const CROSS_WIDTH = 115; export const CROSS_WIDTH = 115;
@ -12,6 +12,7 @@ export const SPLICE_BORDER_RADIUS = 6;
export const DEVICE_MIN_WIDTH = 140; export const DEVICE_MIN_WIDTH = 140;
export const DEVICE_MIN_HEIGHT = 80; export const DEVICE_MIN_HEIGHT = 80;
export const DEVICE_HEADER_HEIGHT = 38;
export const DEVICE_BORDER_RADIUS = 6; export const DEVICE_BORDER_RADIUS = 6;
export const CARD_WIDTH = 100; export const CARD_WIDTH = 100;
@ -19,6 +20,6 @@ export const CARD_HEIGHT = 40;
export const PORT_RADIUS = 6; export const PORT_RADIUS = 6;
export const LAYER_GAP = 80; export const LAYER_GAP = 60;
export const DEVICE_GAP = 60; export const DEVICE_GAP = 100;
export const LAYER_PADDING_X = 40; export const LAYER_PADDING_X = 60;

View File

@ -8,39 +8,39 @@ export interface StatusColorSet {
export const STATUS_COLORS: Record<EntityStatus, StatusColorSet> = { export const STATUS_COLORS: Record<EntityStatus, StatusColorSet> = {
[EntityStatus.Active]: { [EntityStatus.Active]: {
border: '#52c41a', border: '#2d7a3a',
fill: '#f6ffed', fill: '#f4f9f4',
text: '#389e0d', text: '#2d7a3a',
}, },
[EntityStatus.Planned]: { [EntityStatus.Planned]: {
border: '#1890ff', border: '#3568a8',
fill: '#e6f7ff', fill: '#f0f5fb',
text: '#096dd9', text: '#3568a8',
}, },
[EntityStatus.UnderConstruction]: { [EntityStatus.UnderConstruction]: {
border: '#faad14', border: '#9a7520',
fill: '#fffbe6', fill: '#faf6ed',
text: '#d48806', text: '#7a5d18',
}, },
[EntityStatus.Reserved]: { [EntityStatus.Reserved]: {
border: '#722ed1', border: '#6b5b95',
fill: '#f9f0ff', fill: '#f5f3f9',
text: '#531dab', text: '#6b5b95',
}, },
[EntityStatus.Faulty]: { [EntityStatus.Faulty]: {
border: '#ff4d4f', border: '#cc0000',
fill: '#fff2f0', fill: '#fef0f0',
text: '#cf1322', text: '#cc0000',
}, },
[EntityStatus.Decommissioned]: { [EntityStatus.Decommissioned]: {
border: '#8c8c8c', border: '#aaaaaa',
fill: '#fafafa', fill: '#f5f5f5',
text: '#595959', text: '#888888',
}, },
[EntityStatus.Unknown]: { [EntityStatus.Unknown]: {
border: '#bfbfbf', border: '#c0c0c0',
fill: '#fafafa', fill: '#fafafa',
text: '#8c8c8c', text: '#999999',
}, },
}; };

View File

@ -14,12 +14,12 @@ export function initGraph(
const graph = new Graph({ const graph = new Graph({
container, container,
autoResize: true, autoResize: true,
background: { color: '#f8f9fa' }, background: { color: '#ffffff' },
grid: { grid: {
visible: true, visible: true,
size: 10, size: 10,
type: 'dot', type: 'dot',
args: { color: '#d9d9d9', thickness: 1 }, args: { color: '#e0e0e0', thickness: 1 },
}, },
panning: { enabled: true, eventTypes: ['rightMouseDown'] }, panning: { enabled: true, eventTypes: ['rightMouseDown'] },
mousewheel: { mousewheel: {
@ -47,7 +47,7 @@ export function initGraph(
return this.createEdge({ return this.createEdge({
attrs: { attrs: {
line: { line: {
stroke: '#52c41a', stroke: '#2d7a3a',
strokeWidth: 2, strokeWidth: 2,
targetMarker: null, targetMarker: null,
sourceMarker: null, sourceMarker: null,
@ -87,7 +87,7 @@ export function initGraph(
highlighting: { highlighting: {
magnetAvailable: { magnetAvailable: {
name: 'stroke', name: 'stroke',
args: { attrs: { fill: '#5F95FF', stroke: '#5F95FF' } }, args: { attrs: { fill: '#333333', stroke: '#333333' } },
}, },
}, },
}); });

View File

@ -11,6 +11,7 @@ import {
SPLICE_SIZE, SPLICE_SIZE,
DEVICE_MIN_WIDTH, DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT, DEVICE_MIN_HEIGHT,
DEVICE_HEADER_HEIGHT,
CARD_WIDTH, CARD_WIDTH,
CARD_HEIGHT, CARD_HEIGHT,
} from '../../../constants/sizes.ts'; } from '../../../constants/sizes.ts';
@ -36,6 +37,7 @@ function getDeviceSize(
deviceName: string, deviceName: string,
marking: string, marking: string,
portCount: number, portCount: number,
cardCount: number,
): { width: number; height: number } { ): { width: number; height: number } {
if ( if (
category === DeviceCategory.CrossOptical || category === DeviceCategory.CrossOptical ||
@ -50,9 +52,11 @@ function getDeviceSize(
return { width: SPLICE_SIZE, height: SPLICE_SIZE }; return { width: SPLICE_SIZE, height: SPLICE_SIZE };
} }
const portHeight = Math.max(portCount * 22, 60); const portHeight = Math.max(portCount * 22, 60);
const cardsHeight = cardCount > 0 ? cardCount * (CARD_HEIGHT + 6) + 8 : 0;
const bodyHeight = Math.max(portHeight, cardsHeight);
return { return {
width: DEVICE_MIN_WIDTH, width: DEVICE_MIN_WIDTH,
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30), height: Math.max(DEVICE_MIN_HEIGHT, DEVICE_HEADER_HEIGHT + bodyHeight + 10),
}; };
} }
@ -105,7 +109,8 @@ export function buildGraphData(
(p) => p.deviceId === device.id && !p.cardId, (p) => p.deviceId === device.id && !p.cardId,
); );
const shape = getDeviceShape(device.category, device.name, device.marking); const shape = getDeviceShape(device.category, device.name, device.marking);
const size = getDeviceSize(device.category, device.name, device.marking, devicePorts.length); const deviceCards = data.cards.filter((c) => c.deviceId === device.id && c.visible);
const size = getDeviceSize(device.category, device.name, device.marking, devicePorts.length, deviceCards.length);
const portItems = devicePorts.map((port) => { const portItems = devicePorts.map((port) => {
const resolvedSide = portSideMap.get(port.id) ?? port.side; const resolvedSide = portSideMap.get(port.id) ?? port.side;
@ -191,7 +196,7 @@ export function buildGraphData(
).indexOf(card); ).indexOf(card);
const cardX = parentPos.x + 10; const cardX = parentPos.x + 10;
const cardY = parentPos.y + 40 + cardIndex * (CARD_HEIGHT + 6); const cardY = parentPos.y + DEVICE_HEADER_HEIGHT + 8 + cardIndex * (CARD_HEIGHT + 6);
const portItems = cardPorts.map((port) => { const portItems = cardPorts.map((port) => {
const resolvedSide = portSideMap.get(port.id) ?? port.side; const resolvedSide = portSideMap.get(port.id) ?? port.side;

View File

@ -7,6 +7,8 @@ import {
SPLICE_SIZE, SPLICE_SIZE,
DEVICE_MIN_WIDTH, DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT, DEVICE_MIN_HEIGHT,
DEVICE_HEADER_HEIGHT,
CARD_HEIGHT,
SITE_HEADER_HEIGHT, SITE_HEADER_HEIGHT,
SITE_PADDING, SITE_PADDING,
SITE_MIN_WIDTH, SITE_MIN_WIDTH,
@ -20,7 +22,7 @@ export interface LayoutResult {
sitePositions: Map<string, { x: number; y: number; width: number; height: number }>; sitePositions: Map<string, { x: number; y: number; width: number; height: number }>;
} }
function getDeviceSize(device: Device, portCount: number): { width: number; height: number } { function getDeviceSize(device: Device, portCount: number, cardCount: number): { width: number; height: number } {
if ( if (
device.category === DeviceCategory.CrossOptical || device.category === DeviceCategory.CrossOptical ||
device.category === DeviceCategory.CrossCopper device.category === DeviceCategory.CrossCopper
@ -33,11 +35,13 @@ function getDeviceSize(device: Device, portCount: number): { width: number; heig
return { width: SPLICE_SIZE, height: SPLICE_SIZE }; return { width: SPLICE_SIZE, height: SPLICE_SIZE };
} }
// Dynamic height based on port count // Dynamic height based on port count + header + cards
const portHeight = Math.max(portCount * 22, 60); const portHeight = Math.max(portCount * 22, 60);
const cardsHeight = cardCount > 0 ? cardCount * (CARD_HEIGHT + 6) + 8 : 0;
const bodyHeight = Math.max(portHeight, cardsHeight);
return { return {
width: DEVICE_MIN_WIDTH, width: DEVICE_MIN_WIDTH,
height: Math.max(DEVICE_MIN_HEIGHT, portHeight + 30), height: Math.max(DEVICE_MIN_HEIGHT, DEVICE_HEADER_HEIGHT + bodyHeight + 10),
}; };
} }
@ -67,7 +71,8 @@ function layoutDevicesInSite(
for (const device of devices) { for (const device of devices) {
const layer = getLayerForDevice(device); const layer = getLayerForDevice(device);
const portCount = data.ports.filter((p) => p.deviceId === device.id && !p.cardId).length; const portCount = data.ports.filter((p) => p.deviceId === device.id && !p.cardId).length;
const size = getDeviceSize(device, portCount); const cardCount = data.cards.filter((c) => c.deviceId === device.id && c.visible).length;
const size = getDeviceSize(device, portCount, cardCount);
const existing = layers.get(layer); const existing = layers.get(layer);
if (existing) { if (existing) {
existing.push({ device, size }); existing.push({ device, size });
@ -91,7 +96,8 @@ function layoutDevicesInSite(
let currentY = startY; let currentY = startY;
let maxWidth = 0; let maxWidth = 0;
for (const [, layerDevices] of sortedLayers) { for (let i = 0; i < sortedLayers.length; i++) {
const [, layerDevices] = sortedLayers[i];
let currentX = startX + LAYER_PADDING_X; let currentX = startX + LAYER_PADDING_X;
let layerMaxHeight = 0; let layerMaxHeight = 0;
@ -106,12 +112,17 @@ function layoutDevicesInSite(
layerMaxHeight = Math.max(layerMaxHeight, size.height); layerMaxHeight = Math.max(layerMaxHeight, size.height);
} }
maxWidth = Math.max(maxWidth, currentX - startX); // Subtract trailing DEVICE_GAP from width calculation
currentY += layerMaxHeight + LAYER_GAP; maxWidth = Math.max(maxWidth, currentX - DEVICE_GAP - startX + LAYER_PADDING_X);
currentY += layerMaxHeight;
// Add gap only between layers, not after the last one
if (i < sortedLayers.length - 1) {
currentY += LAYER_GAP;
}
} }
const totalHeight = currentY - startY; const totalHeight = currentY - startY;
const totalWidth = Math.max(maxWidth + LAYER_PADDING_X, SITE_MIN_WIDTH); const totalWidth = Math.max(maxWidth, SITE_MIN_WIDTH);
return { positions, totalWidth, totalHeight }; return { positions, totalWidth, totalHeight };
} }
@ -124,17 +135,19 @@ export function autoLayout(data: SchemaData): LayoutResult {
const rootSites = data.sites.filter((s: Site) => !s.parentSiteId); const rootSites = data.sites.filter((s: Site) => !s.parentSiteId);
const childSites = data.sites.filter((s: Site) => s.parentSiteId); const childSites = data.sites.filter((s: Site) => s.parentSiteId);
const siteStartY = 50;
let siteX = 50; let siteX = 50;
for (const site of rootSites) { for (const site of rootSites) {
const siteDevices = getSiteDevices(site.id, data); const siteDevices = getSiteDevices(site.id, data);
const contentStartY = SITE_HEADER_HEIGHT + SITE_PADDING; // Absolute Y where devices start: site top + header + padding
const devicesStartY = siteStartY + SITE_HEADER_HEIGHT + SITE_PADDING;
const { positions, totalWidth, totalHeight } = layoutDevicesInSite( const { positions, totalWidth, totalHeight } = layoutDevicesInSite(
siteDevices, siteDevices,
data, data,
siteX + SITE_PADDING, siteX + SITE_PADDING,
contentStartY + 50, // offset for site positioning devicesStartY,
); );
// Add device positions // Add device positions
@ -149,13 +162,13 @@ export function autoLayout(data: SchemaData): LayoutResult {
for (const childSite of siteChildren) { for (const childSite of siteChildren) {
const childDevices = getSiteDevices(childSite.id, data); const childDevices = getSiteDevices(childSite.id, data);
const childContentStartY = contentStartY + totalHeight + 50; const childSiteY = devicesStartY + totalHeight + SITE_PADDING;
const childLayout = layoutDevicesInSite( const childLayout = layoutDevicesInSite(
childDevices, childDevices,
data, data,
childX + SITE_PADDING, childX + SITE_PADDING,
childContentStartY + SITE_HEADER_HEIGHT + SITE_PADDING, childSiteY + SITE_HEADER_HEIGHT + SITE_PADDING,
); );
for (const [deviceId, pos] of childLayout.positions.entries()) { for (const [deviceId, pos] of childLayout.positions.entries()) {
@ -167,7 +180,7 @@ export function autoLayout(data: SchemaData): LayoutResult {
sitePositions.set(childSite.id, { sitePositions.set(childSite.id, {
x: childX, x: childX,
y: childContentStartY, y: childSiteY,
width: childSiteWidth, width: childSiteWidth,
height: childSiteHeight, height: childSiteHeight,
}); });
@ -177,16 +190,16 @@ export function autoLayout(data: SchemaData): LayoutResult {
} }
const siteWidth = Math.max(totalWidth + SITE_PADDING * 2, childX - siteX); const siteWidth = Math.max(totalWidth + SITE_PADDING * 2, childX - siteX);
const siteHeight = totalHeight + contentStartY + childSiteExtraHeight + SITE_PADDING; const siteHeight = (devicesStartY - siteStartY) + totalHeight + childSiteExtraHeight + SITE_PADDING;
sitePositions.set(site.id, { sitePositions.set(site.id, {
x: siteX, x: siteX,
y: 50, y: siteStartY,
width: siteWidth, width: siteWidth,
height: siteHeight, height: siteHeight,
}); });
siteX += siteWidth + 150; siteX += siteWidth + 120;
} }
return { nodePositions, sitePositions }; return { nodePositions, sitePositions };

View File

@ -32,10 +32,9 @@ export function CardNode({ node }: { node: Node }) {
fontWeight: 600, fontWeight: 600,
color: colors.text, color: colors.text,
textAlign: 'center', textAlign: 'center',
overflow: 'hidden', wordBreak: 'break-word',
whiteSpace: 'nowrap', lineHeight: '12px',
textOverflow: 'ellipsis', padding: '2px 4px',
padding: '0 4px',
}} }}
> >
{data.slotName}:{data.networkName} {data.slotName}:{data.networkName}

View File

@ -1,6 +1,6 @@
import type { Node } from '@antv/x6'; import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts'; import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { DEVICE_BORDER_RADIUS } from '../../../constants/sizes.ts'; import { DEVICE_BORDER_RADIUS, DEVICE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
import { DeviceGroup, type EntityStatus } from '../../../types/index.ts'; import { DeviceGroup, type EntityStatus } from '../../../types/index.ts';
interface DeviceNodeData { interface DeviceNodeData {
@ -30,11 +30,7 @@ export function DeviceNode({ node }: { node: Node }) {
background: colors.fill, background: colors.fill,
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box', boxSizing: 'border-box',
textAlign: 'center',
padding: '6px 8px',
fontSize: 10, fontSize: 10,
lineHeight: '14px', lineHeight: '14px',
overflow: 'hidden', overflow: 'hidden',
@ -42,39 +38,65 @@ export function DeviceNode({ node }: { node: Node }) {
> >
<div <div
style={{ style={{
fontWeight: 700, height: DEVICE_HEADER_HEIGHT,
fontSize: 11, minHeight: DEVICE_HEADER_HEIGHT,
marginBottom: 2, background: colors.border,
whiteSpace: 'nowrap', color: '#ffffff',
overflow: 'hidden', borderRadius: `${DEVICE_BORDER_RADIUS - 1}px ${DEVICE_BORDER_RADIUS - 1}px 0 0`,
textOverflow: 'ellipsis', display: 'flex',
maxWidth: '100%', flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
textAlign: 'center',
}} }}
> >
{data.name} <div
style={{
fontWeight: 700,
fontSize: 11,
wordBreak: 'break-word',
lineHeight: '13px',
}}
>
{data.name}
</div>
{isActive ? (
data.networkName && (
<div style={{ opacity: 0.85, fontSize: 9 }}>{data.networkName}</div>
)
) : (
data.marking && (
<div style={{ opacity: 0.85, fontSize: 9 }}>{data.marking}</div>
)
)}
</div>
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
textAlign: 'center',
}}
>
{isActive ? (
data.ipAddress && (
<div style={{ color: '#555555', fontSize: 9 }}>{data.ipAddress}</div>
)
) : (
<>
{data.id1 && (
<div style={{ color: '#666666', fontSize: 9 }}>{data.id1}</div>
)}
{data.id2 && (
<div style={{ color: '#888888', fontSize: 9 }}>{data.id2}</div>
)}
</>
)}
</div> </div>
{isActive ? (
<>
{data.networkName && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
)}
{data.ipAddress && (
<div style={{ color: '#1890ff', fontSize: 9 }}>{data.ipAddress}</div>
)}
</>
) : (
<>
{data.marking && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
)}
{data.id1 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
)}
{data.id2 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id2}</div>
)}
</>
)}
</div> </div>
); );
} }

View File

@ -35,9 +35,9 @@ export function SiteNode({ node }: { node: Node }) {
height: SITE_HEADER_HEIGHT, height: SITE_HEADER_HEIGHT,
background: '#1a1a2e', background: '#1a1a2e',
color: '#ffffff', color: '#ffffff',
padding: '6px 10px', padding: '4px 10px',
fontSize: 11, fontSize: 10,
lineHeight: '16px', lineHeight: '14px',
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
justifyContent: 'center', justifyContent: 'center',
@ -45,14 +45,11 @@ export function SiteNode({ node }: { node: Node }) {
pointerEvents: 'auto', pointerEvents: 'auto',
}} }}
> >
<div style={{ fontWeight: 700, fontSize: 12, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div style={{ fontWeight: 700, fontSize: 11, wordBreak: 'break-word' }}>
{data.name} {data.name}
</div> </div>
<div style={{ opacity: 0.8, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}> <div style={{ opacity: 0.8, fontSize: 9, wordBreak: 'break-word' }}>
{data.address} {data.address} | ERP: {data.erpCode} | 1С: {data.code1C}
</div>
<div style={{ opacity: 0.7, fontSize: 10 }}>
ERP: {data.erpCode} | 1С: {data.code1C}
</div> </div>
</div> </div>
</div> </div>