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_PADDING = 50;
export const SITE_MIN_WIDTH = 500;
export const SITE_HEADER_HEIGHT = 56;
export const SITE_PADDING = 30;
export const SITE_MIN_WIDTH = 250;
export const SITE_MIN_HEIGHT = 200;
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_HEIGHT = 80;
export const DEVICE_HEADER_HEIGHT = 38;
export const DEVICE_BORDER_RADIUS = 6;
export const CARD_WIDTH = 100;
@ -19,6 +20,6 @@ export const CARD_HEIGHT = 40;
export const PORT_RADIUS = 6;
export const LAYER_GAP = 80;
export const DEVICE_GAP = 60;
export const LAYER_PADDING_X = 40;
export const LAYER_GAP = 60;
export const DEVICE_GAP = 100;
export const LAYER_PADDING_X = 60;

View File

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

View File

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

View File

@ -11,6 +11,7 @@ import {
SPLICE_SIZE,
DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT,
DEVICE_HEADER_HEIGHT,
CARD_WIDTH,
CARD_HEIGHT,
} from '../../../constants/sizes.ts';
@ -36,6 +37,7 @@ function getDeviceSize(
deviceName: string,
marking: string,
portCount: number,
cardCount: number,
): { width: number; height: number } {
if (
category === DeviceCategory.CrossOptical ||
@ -50,9 +52,11 @@ function getDeviceSize(
return { width: SPLICE_SIZE, height: SPLICE_SIZE };
}
const portHeight = Math.max(portCount * 22, 60);
const cardsHeight = cardCount > 0 ? cardCount * (CARD_HEIGHT + 6) + 8 : 0;
const bodyHeight = Math.max(portHeight, cardsHeight);
return {
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,
);
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 resolvedSide = portSideMap.get(port.id) ?? port.side;
@ -191,7 +196,7 @@ export function buildGraphData(
).indexOf(card);
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 resolvedSide = portSideMap.get(port.id) ?? port.side;

View File

@ -7,6 +7,8 @@ import {
SPLICE_SIZE,
DEVICE_MIN_WIDTH,
DEVICE_MIN_HEIGHT,
DEVICE_HEADER_HEIGHT,
CARD_HEIGHT,
SITE_HEADER_HEIGHT,
SITE_PADDING,
SITE_MIN_WIDTH,
@ -20,7 +22,7 @@ export interface LayoutResult {
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 (
device.category === DeviceCategory.CrossOptical ||
device.category === DeviceCategory.CrossCopper
@ -33,11 +35,13 @@ function getDeviceSize(device: Device, portCount: number): { width: number; heig
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 cardsHeight = cardCount > 0 ? cardCount * (CARD_HEIGHT + 6) + 8 : 0;
const bodyHeight = Math.max(portHeight, cardsHeight);
return {
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) {
const layer = getLayerForDevice(device);
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);
if (existing) {
existing.push({ device, size });
@ -91,7 +96,8 @@ function layoutDevicesInSite(
let currentY = startY;
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 layerMaxHeight = 0;
@ -106,12 +112,17 @@ function layoutDevicesInSite(
layerMaxHeight = Math.max(layerMaxHeight, size.height);
}
maxWidth = Math.max(maxWidth, currentX - startX);
currentY += layerMaxHeight + LAYER_GAP;
// Subtract trailing DEVICE_GAP from width calculation
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 totalWidth = Math.max(maxWidth + LAYER_PADDING_X, SITE_MIN_WIDTH);
const totalWidth = Math.max(maxWidth, SITE_MIN_WIDTH);
return { positions, totalWidth, totalHeight };
}
@ -124,17 +135,19 @@ export function autoLayout(data: SchemaData): LayoutResult {
const rootSites = data.sites.filter((s: Site) => !s.parentSiteId);
const childSites = data.sites.filter((s: Site) => s.parentSiteId);
const siteStartY = 50;
let siteX = 50;
for (const site of rootSites) {
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(
siteDevices,
data,
siteX + SITE_PADDING,
contentStartY + 50, // offset for site positioning
devicesStartY,
);
// Add device positions
@ -149,13 +162,13 @@ export function autoLayout(data: SchemaData): LayoutResult {
for (const childSite of siteChildren) {
const childDevices = getSiteDevices(childSite.id, data);
const childContentStartY = contentStartY + totalHeight + 50;
const childSiteY = devicesStartY + totalHeight + SITE_PADDING;
const childLayout = layoutDevicesInSite(
childDevices,
data,
childX + SITE_PADDING,
childContentStartY + SITE_HEADER_HEIGHT + SITE_PADDING,
childSiteY + SITE_HEADER_HEIGHT + SITE_PADDING,
);
for (const [deviceId, pos] of childLayout.positions.entries()) {
@ -167,7 +180,7 @@ export function autoLayout(data: SchemaData): LayoutResult {
sitePositions.set(childSite.id, {
x: childX,
y: childContentStartY,
y: childSiteY,
width: childSiteWidth,
height: childSiteHeight,
});
@ -177,16 +190,16 @@ export function autoLayout(data: SchemaData): LayoutResult {
}
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, {
x: siteX,
y: 50,
y: siteStartY,
width: siteWidth,
height: siteHeight,
});
siteX += siteWidth + 150;
siteX += siteWidth + 120;
}
return { nodePositions, sitePositions };

View File

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

View File

@ -1,6 +1,6 @@
import type { Node } from '@antv/x6';
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';
interface DeviceNodeData {
@ -30,11 +30,7 @@ export function DeviceNode({ node }: { node: Node }) {
background: colors.fill,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
textAlign: 'center',
padding: '6px 8px',
fontSize: 10,
lineHeight: '14px',
overflow: 'hidden',
@ -42,39 +38,65 @@ export function DeviceNode({ node }: { node: Node }) {
>
<div
style={{
fontWeight: 700,
fontSize: 11,
marginBottom: 2,
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
maxWidth: '100%',
height: DEVICE_HEADER_HEIGHT,
minHeight: DEVICE_HEADER_HEIGHT,
background: colors.border,
color: '#ffffff',
borderRadius: `${DEVICE_BORDER_RADIUS - 1}px ${DEVICE_BORDER_RADIUS - 1}px 0 0`,
display: 'flex',
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>
{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>
);
}

View File

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