feat: миграция frontend React 18 → Angular 19
All checks were successful
continuous-integration/drone/push Build is passing

Полная миграция фронтенда на Angular 19 + PrimeNG + NgRx SignalStore.

- React 18 + AntD 5 + Zustand + Vite → Angular 19 + PrimeNG 19 + NgRx SignalStore + Angular CLI
- Ноды X6: React-компоненты → чистые DOM-функции через Shape.HTML.register с effect: ['data']
- Все 5 типов нод (site, device, cross-device, splice, card) переписаны как рендереры
- Zustand store → NgRx signalStore (schema.store.ts)
- AntD компоненты → PrimeNG (p-tree, p-table, p-menu, p-toggleSwitch, p-slider, p-button)
- 13 файлов чистого TypeScript переиспользованы as-is (типы, константы, утилиты, мок, layout, ports, edges)
- Структура файлов переименована в kebab-case

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Alina
2026-02-18 08:52:31 +03:00
parent 323410ead7
commit f355caa9ad
66 changed files with 15508 additions and 4324 deletions

17
frontend/.editorconfig Normal file
View File

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

56
frontend/.gitignore vendored
View File

@ -1,24 +1,42 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
node_modules
dist
dist-ssr
*.local
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Editor directories and files
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.idea
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
Thumbs.db

View File

@ -1,73 +1,59 @@
# React + TypeScript + Vite
# Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 19.2.20.
Currently, two official plugins are available:
## Development server
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
To start a local development server, run:
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```bash
ng serve
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
## Code scaffolding
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

125
frontend/angular.json Normal file
View File

@ -0,0 +1,125 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:class": {
"skipTests": true
},
"@schematics/angular:component": {
"skipTests": true
},
"@schematics/angular:directive": {
"skipTests": true
},
"@schematics/angular:guard": {
"skipTests": true
},
"@schematics/angular:interceptor": {
"skipTests": true
},
"@schematics/angular:pipe": {
"skipTests": true
},
"@schematics/angular:resolver": {
"skipTests": true
},
"@schematics/angular:service": {
"skipTests": true
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"options": {
"outputPath": "../dist",
"index": "src/index.html",
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": [],
"allowedCommonJsDependencies": ["mousetrap"]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "2MB",
"maximumError": "5MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "16kB",
"maximumError": "32kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"src/styles.css"
],
"scripts": []
}
}
}
}
},
"cli": {
"analytics": false
}
}

View File

@ -1,23 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@ -1,13 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Схема связей устройств</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

15609
frontend/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,45 +1,50 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"typecheck": "tsc -b --noEmit",
"preview": "vite preview"
"ng": "ng",
"dev": "ng serve",
"build": "ng build",
"lint": "ng lint",
"typecheck": "tsc --noEmit"
},
"private": true,
"dependencies": {
"@ant-design/icons": "^6.1.0",
"@angular/cdk": "^19.2.19",
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@antv/x6": "^2.19.2",
"@antv/x6-plugin-clipboard": "^2.1.6",
"@antv/x6-plugin-export": "^2.1.6",
"@antv/x6-plugin-history": "^2.2.4",
"@antv/x6-plugin-keyboard": "^2.2.3",
"@antv/x6-plugin-minimap": "^2.0.7",
"@antv/x6-plugin-selection": "^2.2.2",
"@antv/x6-plugin-snapline": "^2.1.7",
"@antv/x6-plugin-transform": "^2.1.8",
"@antv/x6-react-shape": "^2.2.3",
"antd": "^6.3.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"@ngrx/signals": "^19.2.1",
"@primeng/themes": "^19.1.4",
"primeicons": "^7.0.0",
"primeng": "^19.1.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"xlsx": "^0.18.5",
"zustand": "^5.0.11"
"zone.js": "~0.15.0"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
"@angular-devkit/build-angular": "^19.2.20",
"@angular/cli": "^19.2.20",
"@angular/compiler-cli": "^19.2.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
}
}

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -1,24 +0,0 @@
import { ConfigProvider } from 'antd';
import ruRU from 'antd/locale/ru_RU';
import { AppLayout } from './components/AppLayout.tsx';
import { Toolbar } from './components/Toolbar/Toolbar.tsx';
import { LeftPanel } from './components/SidePanel/LeftPanel.tsx';
import { RightPanel } from './components/SidePanel/RightPanel.tsx';
import { ConnectionsPanel } from './components/ConnectionsPanel/ConnectionsPanel.tsx';
import { SchemaCanvas } from './features/schema/SchemaCanvas.tsx';
import { ContextMenu } from './features/schema/context-menu/ContextMenu.tsx';
export default function App() {
return (
<ConfigProvider locale={ruRU}>
<AppLayout
toolbar={<Toolbar />}
leftPanel={<LeftPanel />}
canvas={<SchemaCanvas />}
rightPanel={<RightPanel />}
bottomPanel={<ConnectionsPanel />}
/>
<ContextMenu />
</ConfigProvider>
);
}

View File

@ -0,0 +1,14 @@
import { Component } from '@angular/core';
import { ToastModule } from 'primeng/toast';
import { AppLayoutComponent } from './components/app-layout/app-layout.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ToastModule, AppLayoutComponent],
template: `
<p-toast />
<app-layout />
`,
})
export class AppComponent {}

View File

@ -0,0 +1,18 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
import { providePrimeNG } from 'primeng/config';
import { MessageService } from 'primeng/api';
import Aura from '@primeng/themes/aura';
export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideAnimationsAsync(),
providePrimeNG({
theme: {
preset: Aura,
},
}),
MessageService,
],
};

View File

@ -0,0 +1,95 @@
import { Component, signal } from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { TooltipModule } from 'primeng/tooltip';
import { ToolbarComponent } from '../toolbar/toolbar.component';
import { LeftPanelComponent } from '../left-panel/left-panel.component';
import { RightPanelComponent } from '../right-panel/right-panel.component';
import { ConnectionsPanelComponent } from '../connections-panel/connections-panel.component';
import { SchemaCanvasComponent } from '../../features/schema/schema-canvas/schema-canvas.component';
import { ContextMenuComponent } from '../../features/schema/context-menu/context-menu.component';
@Component({
selector: 'app-layout',
standalone: true,
imports: [
ButtonModule,
TooltipModule,
ToolbarComponent,
LeftPanelComponent,
RightPanelComponent,
ConnectionsPanelComponent,
SchemaCanvasComponent,
ContextMenuComponent,
],
template: `
<div style="display: flex; flex-direction: column; height: 100vh; overflow: hidden">
<!-- Toolbar -->
<app-toolbar />
<!-- Main content -->
<div style="flex: 1; display: flex; overflow: hidden">
<!-- Left panel -->
<div
[style.width.px]="leftCollapsed() ? 0 : 240"
style="transition: width 0.2s; overflow: hidden; flex-shrink: 0; position: relative"
>
@if (!leftCollapsed()) {
<app-left-panel />
}
</div>
<!-- Left toggle -->
<div
style="width: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; border-right: 1px solid #f0f0f0; background: #fafafa"
>
<p-button
[icon]="leftCollapsed() ? 'pi pi-chevron-right' : 'pi pi-chevron-left'"
[text]="true"
size="small"
[pTooltip]="leftCollapsed() ? 'Показать панель' : 'Скрыть панель'"
tooltipPosition="right"
(onClick)="leftCollapsed.set(!leftCollapsed())"
/>
</div>
<!-- Canvas + bottom panel -->
<div style="flex: 1; display: flex; flex-direction: column; overflow: hidden">
<div style="flex: 1; overflow: hidden">
<app-schema-canvas />
</div>
<app-connections-panel />
</div>
<!-- Right toggle -->
<div
style="width: 20px; display: flex; align-items: center; justify-content: center; flex-shrink: 0; border-left: 1px solid #f0f0f0; background: #fafafa"
>
<p-button
[icon]="rightCollapsed() ? 'pi pi-chevron-left' : 'pi pi-chevron-right'"
[text]="true"
size="small"
[pTooltip]="rightCollapsed() ? 'Показать панель' : 'Скрыть панель'"
tooltipPosition="left"
(onClick)="rightCollapsed.set(!rightCollapsed())"
/>
</div>
<!-- Right panel -->
<div
[style.width.px]="rightCollapsed() ? 0 : 280"
style="transition: width 0.2s; overflow: hidden; flex-shrink: 0"
>
@if (!rightCollapsed()) {
<app-right-panel />
}
</div>
</div>
</div>
<app-context-menu />
`,
})
export class AppLayoutComponent {
leftCollapsed = signal(false);
rightCollapsed = signal(false);
}

View File

@ -0,0 +1,174 @@
import { Component, inject, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TableModule } from 'primeng/table';
import { ButtonModule } from 'primeng/button';
import { InputTextModule } from 'primeng/inputtext';
import { IconFieldModule } from 'primeng/iconfield';
import { InputIconModule } from 'primeng/inputicon';
import { SchemaStore } from '../../store/schema.store';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/status-colors';
import { mockData } from '../../mock/schema-data';
import type { EntityStatus, Line } from '../../types/index';
interface ConnectionRow {
key: string;
lineName: string;
lineStatus: EntityStatus;
statusColor: string;
statusLabel: string;
deviceAName: string;
portAName: string;
deviceZName: string;
portZName: string;
}
@Component({
selector: 'app-connections-panel',
standalone: true,
imports: [TableModule, ButtonModule, InputTextModule, FormsModule, IconFieldModule, InputIconModule],
template: `
@if (store.connectionsPanelVisible() && store.connectionsPanelData()) {
<div
[style.height]="expanded() ? '60%' : '30%'"
style="
min-height: 150px;
border-top: 1px solid #f0f0f0;
background: #fff;
display: flex;
flex-direction: column;
flex-shrink: 0;
"
>
<!-- Header -->
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 12px;
border-bottom: 1px solid #f0f0f0;
"
>
<span style="font-weight: 600; font-size: 13px">Соединения</span>
<div style="display: flex; align-items: center; gap: 4px">
<p-iconfield>
<p-inputicon styleClass="pi pi-search" />
<input
type="text"
pInputText
placeholder="Поиск..."
[ngModel]="searchValue()"
(ngModelChange)="searchValue.set($event)"
style="width: 180px; font-size: 11px"
/>
</p-iconfield>
<p-button
[icon]="expanded() ? 'pi pi-window-minimize' : 'pi pi-window-maximize'"
size="small"
[text]="true"
(onClick)="expanded.set(!expanded())"
/>
<p-button
icon="pi pi-times"
size="small"
[text]="true"
(onClick)="store.setConnectionsPanelVisible(false)"
/>
</div>
</div>
<!-- Table -->
<div style="flex: 1; overflow: auto">
<p-table [value]="filteredRows()" [scrollable]="true" scrollHeight="flex" styleClass="p-datatable-sm p-datatable-striped">
<ng-template #header>
<tr>
<th>Линия</th>
<th>Устройство A</th>
<th>Устройство Z</th>
</tr>
</ng-template>
<ng-template #body let-row>
<tr>
<td>
<span
[style.color]="row.statusColor"
[style.border]="'1px solid ' + row.statusColor"
style="padding: 1px 6px; border-radius: 3px; font-size: 10px; margin-right: 4px"
>{{ row.statusLabel }}</span>
{{ row.lineName }}
</td>
<td style="font-size: 11px">{{ row.deviceAName }} ({{ row.portAName }})</td>
<td style="font-size: 11px">{{ row.deviceZName }} ({{ row.portZName }})</td>
</tr>
</ng-template>
</p-table>
</div>
</div>
}
`,
})
export class ConnectionsPanelComponent {
readonly store = inject(SchemaStore);
searchValue = signal('');
expanded = signal(false);
private readonly rows = computed((): ConnectionRow[] => {
const panelData = this.store.connectionsPanelData();
if (!panelData) return [];
const result: ConnectionRow[] = [];
if (panelData['line']) {
const line = panelData['line'] as Line;
const portA = panelData['portA'] as { name: string } | null;
const portZ = panelData['portZ'] as { name: string } | null;
const devA = panelData['deviceA'] as { name: string } | null;
const devZ = panelData['deviceZ'] as { name: string } | null;
const colors = STATUS_COLORS[line.status];
result.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
statusColor: colors.border,
statusLabel: STATUS_LABELS[line.status],
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
} else if (panelData['lines']) {
const lines = panelData['lines'] as Line[];
for (const line of lines) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
const colors = STATUS_COLORS[line.status];
result.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
statusColor: colors.border,
statusLabel: STATUS_LABELS[line.status],
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
}
}
return result;
});
filteredRows = computed(() => {
const search = this.searchValue().toLowerCase();
if (!search) return this.rows();
return this.rows().filter(
(r) =>
r.lineName.toLowerCase().includes(search) ||
r.deviceAName.toLowerCase().includes(search) ||
r.deviceZName.toLowerCase().includes(search),
);
});
}

View File

@ -0,0 +1,144 @@
import { Component, inject, signal, computed } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TreeModule } from 'primeng/tree';
import { InputTextModule } from 'primeng/inputtext';
import { IconFieldModule } from 'primeng/iconfield';
import { InputIconModule } from 'primeng/inputicon';
import type { TreeNode } from 'primeng/api';
import { mockData } from '../../mock/schema-data';
import { SchemaStore } from '../../store/schema.store';
@Component({
selector: 'app-left-panel',
standalone: true,
imports: [TreeModule, InputTextModule, FormsModule, IconFieldModule, InputIconModule],
template: `
<div
style="
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
background: #fff;
border-right: 1px solid #f0f0f0;
"
>
<div style="padding: 8px 12px; border-bottom: 1px solid #f0f0f0">
<p-iconfield>
<p-inputicon styleClass="pi pi-search" />
<input
type="text"
pInputText
placeholder="Поиск..."
[ngModel]="searchValue()"
(ngModelChange)="searchValue.set($event)"
style="width: 100%; font-size: 12px"
/>
</p-iconfield>
</div>
<div style="flex: 1; overflow: auto; padding: 4px 0">
<p-tree
[value]="filteredTreeData()"
selectionMode="single"
(onNodeSelect)="onNodeSelect($event)"
[style]="{ fontSize: '12px', border: 'none' }"
/>
</div>
</div>
`,
})
export class LeftPanelComponent {
private readonly store = inject(SchemaStore);
searchValue = signal('');
private readonly treeData = computed((): TreeNode[] => {
const sitesTree: TreeNode[] = mockData.sites
.filter((s) => !s.parentSiteId)
.map((site) => {
const children: TreeNode[] = [];
const siteDevices = mockData.devices.filter((d) => d.siteId === site.id);
for (const device of siteDevices) {
children.push({
key: device.id,
label: device.name,
icon: 'pi pi-server',
data: device,
});
}
const childSites = mockData.sites.filter((s) => s.parentSiteId === site.id);
for (const childSite of childSites) {
const childDevices = mockData.devices.filter((d) => d.siteId === childSite.id);
children.push({
key: childSite.id,
label: childSite.name,
icon: 'pi pi-building',
data: childSite,
children: childDevices.map((d) => ({
key: d.id,
label: d.name,
icon: 'pi pi-server',
data: d,
})),
});
}
return {
key: site.id,
label: site.name,
icon: 'pi pi-building',
expanded: true,
children,
};
});
return [
{
key: 'sites',
label: 'Сайты',
icon: 'pi pi-sitemap',
expanded: true,
children: sitesTree,
},
];
});
filteredTreeData = computed(() => {
const search = this.searchValue().toLowerCase();
if (!search) return this.treeData();
const filterTree = (nodes: TreeNode[]): TreeNode[] => {
const result: TreeNode[] = [];
for (const node of nodes) {
const label = node.label ?? '';
const match = label.toLowerCase().includes(search);
const filteredChildren = node.children ? filterTree(node.children) : [];
if (match || filteredChildren.length > 0) {
result.push({ ...node, children: filteredChildren, expanded: true });
}
}
return result;
};
return filterTree(this.treeData());
});
onNodeSelect(event: { node: TreeNode }) {
const key = event.node.key as string;
const graph = this.store.graph();
if (!key || !graph) return;
const cell = graph.getCellById(key);
if (cell) {
graph.centerCell(cell);
graph.select(cell);
const data = cell.getData() as Record<string, unknown> | undefined;
if (data) {
this.store.setRightPanelData(data);
}
}
}
}

View File

@ -0,0 +1,207 @@
import { Component, inject, computed } from '@angular/core';
import { TagModule } from 'primeng/tag';
import { SchemaStore } from '../../store/schema.store';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/status-colors';
import type { EntityStatus } from '../../types/index';
@Component({
selector: 'app-right-panel',
standalone: true,
imports: [TagModule],
template: `
<div
style="
height: 100%;
background: #fff;
border-left: 1px solid #f0f0f0;
overflow: auto;
"
>
@if (!store.rightPanelData()) {
<div
style="
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 13px;
"
>
Выберите объект
</div>
} @else {
<div style="padding: 12px">
@switch (entityType()) {
@case ('site') {
<h4 style="margin: 0 0 12px 0; font-size: 14px">Сайт</h4>
<table style="width: 100%; font-size: 11px; border-collapse: collapse">
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666; width: 110px">Название</td>
<td style="padding: 6px 8px">{{ data()['name'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Адрес</td>
<td style="padding: 6px 8px">{{ data()['address'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">ERP</td>
<td style="padding: 6px 8px">{{ data()['erpCode'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">1С</td>
<td style="padding: 6px 8px">{{ data()['code1C'] }}</td>
</tr>
<tr>
<td style="padding: 6px 8px; color: #666">Статус</td>
<td style="padding: 6px 8px">
<span
[style.color]="statusColor().text"
[style.background]="statusColor().fill"
[style.border]="'1px solid ' + statusColor().border"
style="padding: 2px 8px; border-radius: 4px; font-size: 10px"
>{{ statusLabel() }}</span>
</td>
</tr>
</table>
}
@case ('device') {
<h4 style="margin: 0 0 12px 0; font-size: 14px">Устройство</h4>
<table style="width: 100%; font-size: 11px; border-collapse: collapse">
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666; width: 110px">Название</td>
<td style="padding: 6px 8px">{{ data()['name'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Сетевое имя</td>
<td style="padding: 6px 8px">{{ data()['networkName'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">IP</td>
<td style="padding: 6px 8px">{{ data()['ipAddress'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Маркировка</td>
<td style="padding: 6px 8px">{{ data()['marking'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Группа</td>
<td style="padding: 6px 8px">{{ data()['group'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Категория</td>
<td style="padding: 6px 8px">{{ data()['category'] }}</td>
</tr>
<tr>
<td style="padding: 6px 8px; color: #666">Статус</td>
<td style="padding: 6px 8px">
<span
[style.color]="statusColor().text"
[style.background]="statusColor().fill"
[style.border]="'1px solid ' + statusColor().border"
style="padding: 2px 8px; border-radius: 4px; font-size: 10px"
>{{ statusLabel() }}</span>
</td>
</tr>
</table>
}
@case ('line') {
<h4 style="margin: 0 0 12px 0; font-size: 14px">Линия</h4>
<table style="width: 100%; font-size: 11px; border-collapse: collapse">
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666; width: 110px">Название</td>
<td style="padding: 6px 8px">{{ data()['name'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Среда</td>
<td style="padding: 6px 8px">{{ data()['medium'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Тип линии</td>
<td style="padding: 6px 8px">{{ data()['lineStyle'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Тип</td>
<td style="padding: 6px 8px">{{ data()['type'] }}</td>
</tr>
<tr>
<td style="padding: 6px 8px; color: #666">Статус</td>
<td style="padding: 6px 8px">
<span
[style.color]="statusColor().text"
[style.background]="statusColor().fill"
[style.border]="'1px solid ' + statusColor().border"
style="padding: 2px 8px; border-radius: 4px; font-size: 10px"
>{{ statusLabel() }}</span>
</td>
</tr>
</table>
}
@case ('card') {
<h4 style="margin: 0 0 12px 0; font-size: 14px">Карта</h4>
<table style="width: 100%; font-size: 11px; border-collapse: collapse">
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666; width: 110px">Слот</td>
<td style="padding: 6px 8px">{{ data()['slotName'] }}</td>
</tr>
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">Сетевое имя</td>
<td style="padding: 6px 8px">{{ data()['networkName'] }}</td>
</tr>
<tr>
<td style="padding: 6px 8px; color: #666">Статус</td>
<td style="padding: 6px 8px">
<span
[style.color]="statusColor().text"
[style.background]="statusColor().fill"
[style.border]="'1px solid ' + statusColor().border"
style="padding: 2px 8px; border-radius: 4px; font-size: 10px"
>{{ statusLabel() }}</span>
</td>
</tr>
</table>
}
@default {
<h4 style="margin: 0 0 12px 0; font-size: 14px">Объект</h4>
<table style="width: 100%; font-size: 11px; border-collapse: collapse">
@for (entry of dataEntries(); track entry[0]) {
<tr style="border-bottom: 1px solid #f0f0f0">
<td style="padding: 6px 8px; color: #666">{{ entry[0] }}</td>
<td style="padding: 6px 8px">{{ entry[1] }}</td>
</tr>
}
</table>
}
}
</div>
}
</div>
`,
})
export class RightPanelComponent {
readonly store = inject(SchemaStore);
data = computed(() => this.store.rightPanelData() ?? {});
entityType = computed(() => {
const d = this.store.rightPanelData();
return (d?.['entityType'] as string) ?? '';
});
statusColor = computed(() => {
const d = this.store.rightPanelData();
const status = (d?.['status'] as EntityStatus) ?? 'unknown';
return STATUS_COLORS[status] ?? STATUS_COLORS['unknown' as EntityStatus];
});
statusLabel = computed(() => {
const d = this.store.rightPanelData();
const status = (d?.['status'] as EntityStatus) ?? 'unknown';
return STATUS_LABELS[status] ?? 'Неизвестно';
});
dataEntries = computed(() => {
const d = this.store.rightPanelData();
return d ? Object.entries(d).map(([k, v]) => [k, String(v ?? '')]) : [];
});
}

View File

@ -0,0 +1,145 @@
import { Component, inject, computed } from '@angular/core';
import { ButtonModule } from 'primeng/button';
import { ToggleSwitchModule } from 'primeng/toggleswitch';
import { SliderModule } from 'primeng/slider';
import { TooltipModule } from 'primeng/tooltip';
import { FormsModule } from '@angular/forms';
import { MessageService } from 'primeng/api';
import { SchemaStore } from '../../store/schema.store';
@Component({
selector: 'app-toolbar',
standalone: true,
imports: [ButtonModule, ToggleSwitchModule, SliderModule, TooltipModule, FormsModule],
template: `
<div
style="
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 16px;
height: 48px;
border-bottom: 1px solid #f0f0f0;
background: #fff;
flex-shrink: 0;
"
>
<!-- Left: display settings -->
<div style="display: flex; align-items: center; gap: 16px">
<span pTooltip="Сетка" tooltipPosition="bottom" style="display: flex; align-items: center; gap: 4px">
<i class="pi pi-th-large" style="font-size: 12px; color: #666"></i>
<p-toggleSwitch
[ngModel]="store.displaySettings().showGrid"
(ngModelChange)="store.toggleGrid()"
/>
</span>
<span pTooltip="Мини-карта" tooltipPosition="bottom" style="display: flex; align-items: center; gap: 4px">
<i class="pi pi-map" style="font-size: 12px; color: #666"></i>
<p-toggleSwitch
[ngModel]="store.displaySettings().showMinimap"
(ngModelChange)="store.toggleMinimap()"
/>
</span>
<p-button
[icon]="'pi pi-share-alt'"
[severity]="store.displaySettings().lineType === 'manhattan' ? 'primary' : 'secondary'"
[outlined]="store.displaySettings().lineType !== 'manhattan'"
size="small"
pTooltip="{{ store.displaySettings().lineType === 'manhattan' ? 'Ломаные линии' : 'Прямые линии' }}"
tooltipPosition="bottom"
(onClick)="store.switchLineType()"
/>
<span pTooltip="Подписи" tooltipPosition="bottom" style="display: flex; align-items: center; gap: 4px">
<i class="pi pi-tag" style="font-size: 12px; color: #666"></i>
<p-toggleSwitch
[ngModel]="store.displaySettings().showLabels"
(ngModelChange)="store.toggleLabels()"
/>
</span>
</div>
<!-- Center: actions -->
<div style="display: flex; align-items: center; gap: 4px">
<p-button icon="pi pi-plus" size="small" [outlined]="true" pTooltip="Добавить объект" tooltipPosition="bottom" (onClick)="wip()" />
<p-button icon="pi pi-trash" size="small" [outlined]="true" pTooltip="Удалить" tooltipPosition="bottom" (onClick)="deleteSelected()" />
<p-button icon="pi pi-refresh" size="small" [outlined]="true" pTooltip="Обновить раскладку" tooltipPosition="bottom" (onClick)="wip()" />
<p-button
icon="pi pi-objects-column"
size="small"
[severity]="store.lassoActive() ? 'primary' : 'secondary'"
[outlined]="!store.lassoActive()"
pTooltip="Выделение лассо"
tooltipPosition="bottom"
(onClick)="store.toggleLasso()"
/>
<p-button icon="pi pi-image" size="small" [outlined]="true" pTooltip="Экспорт PNG" tooltipPosition="bottom" (onClick)="wip()" />
</div>
<!-- Right: zoom + mode -->
<div style="display: flex; align-items: center; gap: 12px">
<div style="display: flex; align-items: center; gap: 4px">
<p-button icon="pi pi-search-minus" size="small" [outlined]="true" pTooltip="Уменьшить" tooltipPosition="bottom" (onClick)="handleZoomOut()" />
<p-slider
[ngModel]="zoom()"
(ngModelChange)="handleZoomChange($event)"
[min]="10"
[max]="300"
[style]="{ width: '100px' }"
/>
<p-button icon="pi pi-search-plus" size="small" [outlined]="true" pTooltip="Увеличить" tooltipPosition="bottom" (onClick)="handleZoomIn()" />
<p-button icon="pi pi-arrows-alt" size="small" [outlined]="true" pTooltip="Уместить на экран" tooltipPosition="bottom" (onClick)="handleFit()" />
</div>
<p-button
[icon]="store.mode() === 'view' ? 'pi pi-eye' : 'pi pi-pencil'"
[severity]="store.mode() === 'edit' ? 'primary' : 'secondary'"
[outlined]="store.mode() !== 'edit'"
size="small"
[label]="store.mode() === 'view' ? 'Просмотр' : 'Редактирование'"
pTooltip="{{ store.mode() === 'view' ? 'Режим просмотра' : 'Режим редактирования' }}"
tooltipPosition="bottom"
(onClick)="store.setMode(store.mode() === 'view' ? 'edit' : 'view')"
/>
</div>
</div>
`,
})
export class ToolbarComponent {
readonly store = inject(SchemaStore);
private readonly messageService = inject(MessageService);
zoom = computed(() => {
const g = this.store.graph();
return g ? Math.round(g.zoom() * 100) : 100;
});
handleZoomIn() {
this.store.graph()?.zoom(0.1);
}
handleZoomOut() {
this.store.graph()?.zoom(-0.1);
}
handleFit() {
this.store.graph()?.zoomToFit({ padding: 40 });
}
handleZoomChange(value: number) {
const g = this.store.graph();
if (g) {
g.zoomTo(value / 100);
}
}
deleteSelected() {
const g = this.store.graph();
if (g) {
const cells = g.getSelectedCells();
if (cells.length) g.removeCells(cells);
}
}
wip() {
this.messageService.add({ severity: 'info', summary: 'В разработке' });
}
}

View File

@ -1,4 +1,4 @@
import { DeviceCategory } from '../types/index.ts';
import { DeviceCategory } from '../types/index';
export const LAYER_MAPPING: Record<number, DeviceCategory[]> = {
1: [

View File

@ -1,4 +1,4 @@
import { LineStyle, Medium } from '../types/index.ts';
import { LineStyle, Medium } from '../types/index';
export interface LineVisualStyle {
strokeDasharray: string;

View File

@ -1,4 +1,4 @@
import { EntityStatus } from '../types/index.ts';
import { EntityStatus } from '../types/index';
export interface StatusColorSet {
border: string;

View File

@ -0,0 +1,156 @@
import { Component, inject, computed, OnInit, OnDestroy } from '@angular/core';
import { MenuModule } from 'primeng/menu';
import { MessageService } from 'primeng/api';
import type { MenuItem } from 'primeng/api';
import { SchemaStore } from '../../../store/schema.store';
import { mockData } from '../../../mock/schema-data';
@Component({
selector: 'app-context-menu',
standalone: true,
imports: [MenuModule],
template: `
@if (store.contextMenu()?.visible) {
<div
[style.position]="'fixed'"
[style.left.px]="store.contextMenu()!.x"
[style.top.px]="store.contextMenu()!.y"
[style.zIndex]="1000"
>
<p-menu [model]="menuItems()" />
</div>
}
`,
styles: [`
:host ::ng-deep .p-menu {
min-width: 200px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
`],
})
export class ContextMenuComponent implements OnInit, OnDestroy {
readonly store = inject(SchemaStore);
private readonly messageService = inject(MessageService);
private clickListener: (() => void) | null = null;
ngOnInit() {
this.clickListener = () => {
// Delay to allow menu click to process first
setTimeout(() => this.store.setContextMenu(null), 50);
};
document.addEventListener('click', this.clickListener);
}
ngOnDestroy() {
if (this.clickListener) {
document.removeEventListener('click', this.clickListener);
}
}
menuItems = computed((): MenuItem[] => {
const ctx = this.store.contextMenu();
if (!ctx) return [];
const wip = () => this.messageService.add({ severity: 'info', summary: 'В разработке' });
const viewCard: MenuItem = {
label: 'Просмотр карточки',
icon: 'pi pi-eye',
command: () => {
this.store.setRightPanelData(ctx.data);
this.store.setContextMenu(null);
},
};
const showConnections = (deviceId: string): MenuItem => ({
label: 'Показать соединения',
icon: 'pi pi-link',
command: () => {
const deviceLines = mockData.lines.filter((l) => {
const portA = mockData.ports.find((p) => p.id === l.portAId);
const portZ = mockData.ports.find((p) => p.id === l.portZId);
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
});
this.store.setConnectionsPanelData({ lines: deviceLines, deviceId });
this.store.setConnectionsPanelVisible(true);
this.store.setContextMenu(null);
},
});
switch (ctx.type) {
case 'site':
return [
viewCard,
{ label: 'Добавить устройство', icon: 'pi pi-plus', command: wip },
{ label: 'Редактировать', icon: 'pi pi-pencil', command: wip },
{ separator: true },
{ label: 'Удалить', icon: 'pi pi-trash', command: wip },
];
case 'active-device':
return [
viewCard,
showConnections(ctx.data['entityId'] as string),
{ label: 'Создать линию', icon: 'pi pi-plus', command: wip },
{ label: 'Копировать', icon: 'pi pi-copy', command: wip },
{ label: 'Переместить на другой сайт', icon: 'pi pi-arrow-right', command: wip },
{ separator: true },
{ label: 'Удалить', icon: 'pi pi-trash', command: wip },
];
case 'passive-device':
return [
viewCard,
showConnections(ctx.data['entityId'] as string),
{ label: 'Копировать', icon: 'pi pi-copy', command: wip },
{ separator: true },
{ label: 'Удалить', icon: 'pi pi-trash', command: wip },
];
case 'line':
case 'line-group': {
const lineId = ctx.data['entityId'] as string;
return [
viewCard,
{
label: 'Показать соединения',
icon: 'pi pi-link',
command: () => {
const line = mockData.lines.find((l) => l.id === lineId);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
this.store.setConnectionsPanelData({ line, portA, portZ, deviceA: devA, deviceZ: devZ });
this.store.setConnectionsPanelVisible(true);
}
this.store.setContextMenu(null);
},
},
{ label: 'Разорвать линию', icon: 'pi pi-minus', command: wip },
{ label: 'Копировать', icon: 'pi pi-copy', command: wip },
{ separator: true },
{ label: 'Удалить', icon: 'pi pi-trash', command: wip },
];
}
case 'blank':
default:
return [
{ label: 'Добавить устройство', icon: 'pi pi-plus', command: wip },
{ label: 'Создать линию', icon: 'pi pi-share-alt', command: wip },
{ label: 'Вставить', icon: 'pi pi-clipboard', command: wip },
{ separator: true },
{
label: 'Уместить на экран',
icon: 'pi pi-arrows-alt',
command: () => {
this.store.graph()?.zoomToFit({ padding: 40 });
this.store.setContextMenu(null);
},
},
];
}
});
}

View File

@ -1,8 +1,8 @@
import type { EntityStatus } from '../../../types/index.ts';
import { Medium, LineStyle } from '../../../types/index.ts';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { getLineVisualStyle } from '../../../constants/lineStyles.ts';
import type { GraphEdgeConfig } from '../../../types/graph.ts';
import type { EntityStatus } from '../../../types/index';
import { Medium, LineStyle } from '../../../types/index';
import { STATUS_COLORS } from '../../../constants/status-colors';
import { getLineVisualStyle } from '../../../constants/line-styles';
import type { GraphEdgeConfig } from '../../../types/graph';
export function createEdgeConfig(
id: string,

View File

@ -1,5 +1,5 @@
import type { Line, Port, Device, SchemaData } from '../../../types/index.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../../constants/statusColors.ts';
import type { Line, Port, Device, SchemaData } from '../../../types/index';
import { STATUS_COLORS, STATUS_LABELS } from '../../../constants/status-colors';
export interface LineGroup {
key: string;

View File

@ -6,7 +6,6 @@ import { Keyboard } from '@antv/x6-plugin-keyboard';
import { Clipboard } from '@antv/x6-plugin-clipboard';
import { History } from '@antv/x6-plugin-history';
export function initGraph(
container: HTMLDivElement,
minimapContainer: HTMLDivElement | null,
@ -92,7 +91,7 @@ export function initGraph(
},
});
// Show tools on edge hover: segments, vertices, and arrowheads for reconnecting
// Show tools on edge hover
graph.on('edge:mouseenter', ({ edge }) => {
edge.addTools([
{
@ -149,9 +148,7 @@ export function initGraph(
});
graph.on('edge:mouseleave', ({ edge }) => {
// Don't remove tools if edge is being dragged
if (edge.hasTool('source-arrowhead') || edge.hasTool('target-arrowhead')) {
// Delay removal to avoid removing during drag
setTimeout(() => {
if (!document.querySelector('.x6-widget-transform')) {
edge.removeTools();

View File

@ -1,9 +1,9 @@
import { register } from '@antv/x6-react-shape';
import { SiteNode } from '../nodes/SiteNode.tsx';
import { CrossDeviceNode } from '../nodes/CrossDeviceNode.tsx';
import { SpliceNode } from '../nodes/SpliceNode.tsx';
import { DeviceNode } from '../nodes/DeviceNode.tsx';
import { CardNode } from '../nodes/CardNode.tsx';
import { Shape } from '@antv/x6';
import { renderSiteNode } from '../nodes/site-node.renderer';
import { renderCrossDeviceNode } from '../nodes/cross-device-node.renderer';
import { renderSpliceNode } from '../nodes/splice-node.renderer';
import { renderDeviceNode } from '../nodes/device-node.renderer';
import { renderCardNode } from '../nodes/card-node.renderer';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
@ -14,51 +14,61 @@ import {
CARD_HEIGHT,
SITE_MIN_WIDTH,
SITE_MIN_HEIGHT,
} from '../../../constants/sizes.ts';
import { portGroups } from '../ports/portConfig.ts';
} from '../../../constants/sizes';
import { portGroups } from '../ports/port-config';
export function registerAllNodes(): void {
register({
Shape.HTML.register({
shape: 'site-node',
width: SITE_MIN_WIDTH,
height: SITE_MIN_HEIGHT,
component: SiteNode,
effect: ['data'],
html(cell) {
return renderSiteNode(cell);
},
});
register({
Shape.HTML.register({
shape: 'cross-device-node',
width: CROSS_WIDTH,
height: CROSS_HEIGHT,
component: CrossDeviceNode,
ports: { groups: portGroups },
effect: ['data'],
html(cell) {
return renderCrossDeviceNode(cell);
},
ports: { groups: portGroups },
});
register({
Shape.HTML.register({
shape: 'splice-node',
width: SPLICE_SIZE,
height: SPLICE_SIZE,
component: SpliceNode,
ports: { groups: portGroups },
effect: ['data'],
html(cell) {
return renderSpliceNode(cell);
},
ports: { groups: portGroups },
});
register({
Shape.HTML.register({
shape: 'device-node',
width: DEVICE_MIN_WIDTH,
height: DEVICE_MIN_HEIGHT,
component: DeviceNode,
ports: { groups: portGroups },
effect: ['data'],
html(cell) {
return renderDeviceNode(cell);
},
ports: { groups: portGroups },
});
register({
Shape.HTML.register({
shape: 'card-node',
width: CARD_WIDTH,
height: CARD_HEIGHT,
component: CardNode,
ports: { groups: portGroups },
effect: ['data'],
html(cell) {
return renderCardNode(cell);
},
ports: { groups: portGroups },
});
}

View File

@ -1,10 +1,10 @@
import type { SchemaData } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
import type { GraphNodeConfig, GraphEdgeConfig, GraphBuildResult } from '../../../types/graph.ts';
import { createPortItem } from '../ports/portConfig.ts';
import { createEdgeConfig } from '../edges/edgeConfig.ts';
import { autoLayout, type LayoutResult } from '../layout/autoLayout.ts';
import { resolvePortSides } from './portSideResolver.ts';
import type { SchemaData } from '../../../types/index';
import { DeviceCategory } from '../../../types/index';
import type { GraphNodeConfig, GraphEdgeConfig, GraphBuildResult } from '../../../types/graph';
import { createPortItem } from '../ports/port-config';
import { createEdgeConfig } from '../edges/edge-config';
import { autoLayout, type LayoutResult } from '../layout/auto-layout';
import { resolvePortSides } from './port-side-resolver';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
@ -14,7 +14,7 @@ import {
DEVICE_HEADER_HEIGHT,
CARD_WIDTH,
CARD_HEIGHT,
} from '../../../constants/sizes.ts';
} from '../../../constants/sizes';
function getDeviceShape(category: DeviceCategory, deviceName: string, marking: string): string {
if (
@ -53,7 +53,6 @@ function getDeviceSize(
}
const portHeight = Math.max(portCount * 22, 60);
const cardsHeight = cardCount > 0 ? cardCount * (CARD_HEIGHT + 6) + 8 : 0;
// When device has both cards and ports, stack them vertically to avoid overlap
const bodyHeight = cardCount > 0 && portCount > 0
? cardsHeight + portHeight
: Math.max(portHeight, cardsHeight);
@ -115,13 +114,11 @@ export function buildGraphData(
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);
// When device has cards, position device-level ports below cards area
const hasCards = deviceCards.length > 0;
const cardsEndY = hasCards
? DEVICE_HEADER_HEIGHT + deviceCards.length * (CARD_HEIGHT + 6) + 8
: 0;
// Group device ports by resolved side for Y offset calculation
const leftDevicePorts = devicePorts.filter(
(p) => (portSideMap.get(p.id) ?? p.side) === 'left',
);
@ -147,7 +144,6 @@ export function buildGraphData(
return createPortItem(port.id, resolvedSide, label, port.labelColor || undefined, portArgs);
});
// Use absolute positioning when device has cards to avoid overlap with card ports
const leftPosition = hasCards ? 'absolute' : 'left';
const rightPosition = hasCards ? 'absolute' : 'right';
@ -297,7 +293,6 @@ export function buildGraphData(
const portZ = data.ports.find((p) => p.id === line.portZId);
if (!portA || !portZ) continue;
// Determine source and target cells
const sourceCellId = portA.cardId ?? portA.deviceId;
const targetCellId = portZ.cardId ?? portZ.deviceId;

View File

@ -0,0 +1,68 @@
import type { SchemaData } from '../../../types/index';
interface DevicePosition {
x: number;
y: number;
}
export function resolvePortSides(
data: SchemaData,
devicePositions: Map<string, DevicePosition>,
): Map<string, 'left' | 'right'> {
const result = new Map<string, 'left' | 'right'>();
for (const port of data.ports) {
const device = data.devices.find((d) => d.id === port.deviceId);
if (!device) continue;
// Cross devices: L ports → left, S ports → right (hardcoded)
if (
device.category === 'cross_optical' ||
device.category === 'cross_copper'
) {
if (port.name.startsWith('L')) {
result.set(port.id, 'left');
} else {
result.set(port.id, 'right');
}
continue;
}
// Find connected line for this port
const connectedLine = data.lines.find(
(l) => l.portAId === port.id || l.portZId === port.id,
);
if (!connectedLine) {
result.set(port.id, port.side);
continue;
}
// Determine connected port
const connectedPortId =
connectedLine.portAId === port.id
? connectedLine.portZId
: connectedLine.portAId;
const connectedPort = data.ports.find((p) => p.id === connectedPortId);
if (!connectedPort) {
result.set(port.id, port.side);
continue;
}
const myDevicePos = devicePositions.get(port.deviceId);
const connectedDevicePos = devicePositions.get(connectedPort.deviceId);
if (!myDevicePos || !connectedDevicePos) {
result.set(port.id, port.side);
continue;
}
// Port faces the connected device
if (connectedDevicePos.x < myDevicePos.x) {
result.set(port.id, 'left');
} else {
result.set(port.id, 'right');
}
}
return result;
}

View File

@ -1,6 +1,6 @@
import type { Device, Site, SchemaData } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
import { LAYER_MAPPING, MAX_CROSS_PER_LAYER } from '../../../constants/layerMapping.ts';
import type { Device, Site, SchemaData } from '../../../types/index';
import { DeviceCategory } from '../../../types/index';
import { LAYER_MAPPING, MAX_CROSS_PER_LAYER } from '../../../constants/layer-mapping';
import {
CROSS_WIDTH,
CROSS_HEIGHT,
@ -15,7 +15,7 @@ import {
LAYER_GAP,
DEVICE_GAP,
LAYER_PADDING_X,
} from '../../../constants/sizes.ts';
} from '../../../constants/sizes';
export interface LayoutResult {
nodePositions: Map<string, { x: number; y: number; width: number; height: number }>;

View File

@ -0,0 +1,42 @@
import type { Cell } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/status-colors';
import type { EntityStatus } from '../../../types/index';
interface CardNodeData {
slotName: string;
networkName: string;
status: EntityStatus;
}
export function renderCardNode(cell: Cell): HTMLElement {
const data = cell.getData() as CardNodeData;
const colors = STATUS_COLORS[data.status];
const size = (cell as any).getSize() as { width: number; height: number };
const container = document.createElement('div');
Object.assign(container.style, {
width: `${size.width}px`,
height: `${size.height}px`,
borderTop: `1.5px solid ${colors.border}`,
borderBottom: `1.5px solid ${colors.border}`,
borderLeft: `5px solid ${colors.border}`,
borderRight: `5px solid ${colors.border}`,
borderRadius: '0',
background: colors.fill,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
fontSize: '9px',
fontWeight: '600',
color: colors.text,
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: '12px',
padding: '2px 4px',
});
container.textContent = `${data.slotName}:${data.networkName}`;
return container;
}

View File

@ -0,0 +1,110 @@
import type { Cell } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/status-colors';
import { CROSS_BORDER_RADIUS } from '../../../constants/sizes';
import type { EntityStatus } from '../../../types/index';
interface CrossDeviceData {
name: string;
networkName: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function renderCrossDeviceNode(cell: Cell): HTMLElement {
const data = cell.getData() as CrossDeviceData;
const colors = STATUS_COLORS[data.status];
const size = (cell as any).getSize() as { width: number; height: number };
const r = CROSS_BORDER_RADIUS;
const w = size.width;
const h = size.height;
// Asymmetric rounding: top-left and bottom-right rounded
const path = `
M ${r} 0
L ${w} 0
L ${w} ${h - r}
Q ${w} ${h} ${w - r} ${h}
L 0 ${h}
L 0 ${r}
Q 0 0 ${r} 0
Z
`;
const container = document.createElement('div');
Object.assign(container.style, {
width: `${w}px`,
height: `${h}px`,
position: 'relative',
});
const svgNS = 'http://www.w3.org/2000/svg';
const svg = document.createElementNS(svgNS, 'svg');
svg.setAttribute('width', String(w));
svg.setAttribute('height', String(h));
Object.assign(svg.style, { position: 'absolute', top: '0', left: '0' });
const pathEl = document.createElementNS(svgNS, 'path');
pathEl.setAttribute('d', path);
pathEl.setAttribute('fill', colors.fill);
pathEl.setAttribute('stroke', colors.border);
pathEl.setAttribute('stroke-width', '1');
svg.appendChild(pathEl);
container.appendChild(svg);
// Text overlay
const textDiv = document.createElement('div');
Object.assign(textDiv.style, {
position: 'absolute',
top: '0',
left: '0',
width: `${w}px`,
height: `${h}px`,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 6px',
boxSizing: 'border-box',
textAlign: 'center',
fontSize: '10px',
lineHeight: '14px',
pointerEvents: 'none',
});
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
marginBottom: '4px',
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
textDiv.appendChild(nameEl);
if (data.networkName) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#595959', fontSize: '9px' });
el.textContent = data.networkName;
textDiv.appendChild(el);
}
if (data.marking) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.marking;
textDiv.appendChild(el);
}
if (data.id1) {
const el = document.createElement('div');
Object.assign(el.style, { color: '#8c8c8c', fontSize: '9px' });
el.textContent = data.id1;
textDiv.appendChild(el);
}
container.appendChild(textDiv);
return container;
}

View File

@ -0,0 +1,119 @@
import type { Cell } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/status-colors';
import { DEVICE_BORDER_RADIUS, DEVICE_HEADER_HEIGHT } from '../../../constants/sizes';
import { DeviceGroup, type EntityStatus } from '../../../types/index';
interface DeviceNodeData {
name: string;
networkName: string;
ipAddress: string;
marking: string;
id1: string;
id2: string;
group: DeviceGroup;
status: EntityStatus;
}
export function renderDeviceNode(cell: Cell): HTMLElement {
const data = cell.getData() as DeviceNodeData;
const colors = STATUS_COLORS[data.status];
const size = (cell as any).getSize() as { width: number; height: number };
const isActive = data.group === DeviceGroup.Active;
const container = document.createElement('div');
Object.assign(container.style, {
width: `${size.width}px`,
height: `${size.height}px`,
border: `1px solid ${colors.border}`,
borderRadius: `${DEVICE_BORDER_RADIUS}px`,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
fontSize: '10px',
lineHeight: '14px',
overflow: 'hidden',
});
// Header
const header = document.createElement('div');
Object.assign(header.style, {
height: `${DEVICE_HEADER_HEIGHT}px`,
minHeight: `${DEVICE_HEADER_HEIGHT}px`,
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',
});
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
wordBreak: 'break-word',
lineHeight: '13px',
});
nameEl.textContent = data.name;
header.appendChild(nameEl);
if (isActive) {
if (data.networkName) {
const subEl = document.createElement('div');
Object.assign(subEl.style, { opacity: '0.85', fontSize: '9px' });
subEl.textContent = data.networkName;
header.appendChild(subEl);
}
} else {
if (data.marking) {
const subEl = document.createElement('div');
Object.assign(subEl.style, { opacity: '0.85', fontSize: '9px' });
subEl.textContent = data.marking;
header.appendChild(subEl);
}
}
container.appendChild(header);
// Body
const body = document.createElement('div');
Object.assign(body.style, {
flex: '1',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '2px 6px',
textAlign: 'center',
});
if (isActive) {
if (data.ipAddress) {
const ipEl = document.createElement('div');
Object.assign(ipEl.style, { color: '#555555', fontSize: '9px' });
ipEl.textContent = data.ipAddress;
body.appendChild(ipEl);
}
} else {
if (data.id1) {
const id1El = document.createElement('div');
Object.assign(id1El.style, { color: '#666666', fontSize: '9px' });
id1El.textContent = data.id1;
body.appendChild(id1El);
}
if (data.id2) {
const id2El = document.createElement('div');
Object.assign(id2El.style, { color: '#888888', fontSize: '9px' });
id2El.textContent = data.id2;
body.appendChild(id2El);
}
}
container.appendChild(body);
return container;
}

View File

@ -0,0 +1,68 @@
import type { Cell } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/status-colors';
import { SITE_HEADER_HEIGHT } from '../../../constants/sizes';
import type { EntityStatus } from '../../../types/index';
interface SiteNodeData {
name: string;
address: string;
erpCode: string;
code1C: string;
status: EntityStatus;
}
export function renderSiteNode(cell: Cell): HTMLElement {
const data = cell.getData() as SiteNodeData;
const colors = STATUS_COLORS[data.status];
const size = (cell as any).getSize() as { width: number; height: number };
const container = document.createElement('div');
Object.assign(container.style, {
width: `${size.width}px`,
height: `${size.height}px`,
border: `3.87px solid ${colors.border}`,
borderRadius: '0',
background: 'transparent',
position: 'relative',
boxSizing: 'border-box',
overflow: 'visible',
pointerEvents: 'none',
});
const header = document.createElement('div');
Object.assign(header.style, {
height: `${SITE_HEADER_HEIGHT}px`,
background: '#1a1a2e',
color: '#ffffff',
padding: '4px 10px',
fontSize: '10px',
lineHeight: '14px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: '1px',
pointerEvents: 'auto',
});
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
const infoEl = document.createElement('div');
Object.assign(infoEl.style, {
opacity: '0.8',
fontSize: '9px',
wordBreak: 'break-word',
});
infoEl.textContent = `${data.address} | ERP: ${data.erpCode} | 1С: ${data.code1C}`;
header.appendChild(nameEl);
header.appendChild(infoEl);
container.appendChild(header);
return container;
}

View File

@ -0,0 +1,55 @@
import type { Cell } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/status-colors';
import { SPLICE_BORDER_RADIUS } from '../../../constants/sizes';
import type { EntityStatus } from '../../../types/index';
interface SpliceNodeData {
name: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function renderSpliceNode(cell: Cell): HTMLElement {
const data = cell.getData() as SpliceNodeData;
const colors = STATUS_COLORS[data.status];
const size = (cell as any).getSize() as { width: number; height: number };
const container = document.createElement('div');
Object.assign(container.style, {
width: `${size.width}px`,
height: `${size.height}px`,
border: `1px solid ${colors.border}`,
borderRadius: `${SPLICE_BORDER_RADIUS}px`,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
textAlign: 'center',
padding: '4px',
fontSize: '10px',
lineHeight: '14px',
});
const nameEl = document.createElement('div');
Object.assign(nameEl.style, {
fontWeight: '700',
fontSize: '11px',
marginBottom: '2px',
wordBreak: 'break-word',
});
nameEl.textContent = data.name;
container.appendChild(nameEl);
if (data.marking) {
const markingEl = document.createElement('div');
Object.assign(markingEl.style, { color: '#595959', fontSize: '9px' });
markingEl.textContent = data.marking;
container.appendChild(markingEl);
}
return container;
}

View File

@ -1,4 +1,4 @@
import { PORT_RADIUS } from '../../../constants/sizes.ts';
import { PORT_RADIUS } from '../../../constants/sizes';
export const portGroups = {
left: {

View File

@ -0,0 +1,263 @@
import {
Component,
ElementRef,
OnDestroy,
AfterViewInit,
viewChild,
inject,
effect,
} from '@angular/core';
import { SchemaStore } from '../../../store/schema.store';
import { initGraph } from '../graph/init-graph';
import { registerAllNodes } from '../graph/register-nodes';
import { buildGraphData } from '../helpers/data-mapper';
import { mockData } from '../../../mock/schema-data';
import { DeviceGroup } from '../../../types/index';
import type { Graph } from '@antv/x6';
let nodesRegistered = false;
@Component({
selector: 'app-schema-canvas',
standalone: true,
template: `
<div style="position: relative; width: 100%; height: 100%">
<div #graphContainer style="width: 100%; height: 100%"></div>
<div
#minimapContainer
[style.display]="store.displaySettings().showMinimap ? 'block' : 'none'"
style="
position: absolute;
bottom: 16px;
right: 16px;
width: 200px;
height: 150px;
border: 1px solid #d9d9d9;
background: #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
"
></div>
</div>
`,
})
export class SchemaCanvasComponent implements AfterViewInit, OnDestroy {
readonly store = inject(SchemaStore);
private readonly graphContainer = viewChild.required<ElementRef<HTMLDivElement>>('graphContainer');
private readonly minimapContainer = viewChild.required<ElementRef<HTMLDivElement>>('minimapContainer');
private graphInstance: Graph | null = null;
constructor() {
// Sync grid visibility with display settings
effect(() => {
const showGrid = this.store.displaySettings().showGrid;
const graph = this.store.graph();
if (!graph) return;
if (showGrid) {
graph.showGrid();
} else {
graph.hideGrid();
}
});
// Sync labels visibility
effect(() => {
const show = this.store.displaySettings().showLabels;
const graph = this.store.graph();
if (!graph) return;
for (const edge of graph.getEdges()) {
const labels = edge.getLabels();
if (labels.length > 0) {
edge.setLabels(
labels.map((label) => ({
...label,
attrs: {
...label.attrs,
label: {
...(label.attrs?.['label'] as Record<string, unknown>),
visibility: show ? 'visible' : 'hidden',
},
rect: {
...(label.attrs?.['rect'] as Record<string, unknown>),
visibility: show ? 'visible' : 'hidden',
},
},
})),
);
}
}
for (const node of graph.getNodes()) {
const ports = node.getPorts();
for (const port of ports) {
node.setPortProp(port.id!, 'attrs/text/visibility', show ? 'visible' : 'hidden');
}
}
});
}
ngAfterViewInit(): void {
const containerEl = this.graphContainer().nativeElement;
const minimapEl = this.minimapContainer().nativeElement;
if (!nodesRegistered) {
registerAllNodes();
nodesRegistered = true;
}
const graph = initGraph(containerEl, minimapEl);
this.graphInstance = graph;
this.store.setGraph(graph);
const { nodes, edges } = buildGraphData(mockData, this.store.displaySettings().lineType);
// Add nodes: sites first, then devices, then cards
const siteNodes = nodes.filter((n) => n.shape === 'site-node');
const deviceNodes = nodes.filter(
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
);
const cardNodes = nodes.filter((n) => n.shape === 'card-node');
for (const node of siteNodes) {
graph.addNode(node);
}
for (const node of deviceNodes) {
const graphNode = graph.addNode(node);
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
for (const node of cardNodes) {
const graphNode = graph.addNode(node);
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
for (const edge of edges) {
graph.addEdge(edge);
}
graph.centerContent();
// Event handlers
graph.on('node:click', ({ node }) => {
const data = node.getData() as Record<string, unknown>;
this.store.setRightPanelData(data);
});
graph.on('edge:click', ({ edge }) => {
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
this.store.setRightPanelData({
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
medium: line.medium,
lineStyle: line.lineStyle,
type: line.type,
});
} else {
const data = edge.getData() as Record<string, unknown>;
if (data) {
this.store.setRightPanelData(data);
}
}
});
graph.on('node:contextmenu', ({ e, node }) => {
e.preventDefault();
const data = node.getData() as Record<string, unknown>;
const entityType = data['entityType'] as string;
let menuType: 'site' | 'active-device' | 'passive-device' = 'active-device';
if (entityType === 'site') {
menuType = 'site';
} else if (entityType === 'device') {
const group = data['group'] as string;
menuType = group === DeviceGroup.Passive ? 'passive-device' : 'active-device';
}
this.store.setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: menuType,
data: data,
});
});
graph.on('edge:contextmenu', ({ e, edge }) => {
e.preventDefault();
const line = mockData.lines.find((l) => l.id === edge.id);
this.store.setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'line',
data: line
? {
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
}
: {},
});
});
graph.on('blank:contextmenu', ({ e }) => {
e.preventDefault();
this.store.setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'blank',
data: {},
});
});
graph.on('blank:click', () => {
this.store.setContextMenu(null);
this.store.setRightPanelData(null);
});
graph.on('edge:dblclick', ({ edge }) => {
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA
? mockData.devices.find((d) => d.id === portA.deviceId)
: null;
const devZ = portZ
? mockData.devices.find((d) => d.id === portZ.deviceId)
: null;
this.store.setConnectionsPanelData({
line,
portA,
portZ,
deviceA: devA,
deviceZ: devZ,
});
this.store.setConnectionsPanelVisible(true);
}
});
}
ngOnDestroy(): void {
this.graphInstance?.dispose();
}
}

View File

@ -1,4 +1,4 @@
import type { SchemaData } from '../types/index.ts';
import type { SchemaData } from '../types/index';
import {
EntityStatus,
DeviceGroup,
@ -6,7 +6,7 @@ import {
Medium,
LineStyle,
LineType,
} from '../types/index.ts';
} from '../types/index';
export const mockData: SchemaData = {
sites: [

View File

@ -0,0 +1,137 @@
import { computed } from '@angular/core';
import { signalStore, withState, withMethods, patchState, withComputed } from '@ngrx/signals';
import type { Graph } from '@antv/x6';
export interface DisplaySettings {
showGrid: boolean;
showMinimap: boolean;
lineType: 'manhattan' | 'normal';
snapToGrid: boolean;
showLabels: boolean;
}
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
type: 'site' | 'active-device' | 'passive-device' | 'line' | 'line-group' | 'blank';
data: Record<string, unknown>;
}
interface SchemaState {
graph: Graph | null;
mode: 'view' | 'edit';
selectedElements: string[];
displaySettings: DisplaySettings;
contextMenu: ContextMenuState | null;
rightPanelData: Record<string, unknown> | null;
connectionsPanelData: Record<string, unknown> | null;
connectionsPanelVisible: boolean;
lassoActive: boolean;
}
const initialState: SchemaState = {
graph: null,
mode: 'view',
selectedElements: [],
displaySettings: {
showGrid: true,
showMinimap: true,
lineType: 'manhattan',
snapToGrid: true,
showLabels: true,
},
contextMenu: null,
rightPanelData: null,
connectionsPanelData: null,
connectionsPanelVisible: false,
lassoActive: false,
};
export const SchemaStore = signalStore(
{ providedIn: 'root' },
withState(initialState),
withComputed((store) => ({
zoom: computed(() => {
const g = store.graph();
return g ? Math.round(g.zoom() * 100) : 100;
}),
})),
withMethods((store) => ({
setGraph(graph: Graph) {
patchState(store, { graph });
},
setMode(mode: 'view' | 'edit') {
patchState(store, { mode });
},
setSelectedElements(elements: string[]) {
patchState(store, { selectedElements: elements });
},
toggleGrid() {
patchState(store, (s) => ({
displaySettings: {
...s.displaySettings,
showGrid: !s.displaySettings.showGrid,
},
}));
},
toggleMinimap() {
patchState(store, (s) => ({
displaySettings: {
...s.displaySettings,
showMinimap: !s.displaySettings.showMinimap,
},
}));
},
switchLineType() {
const next: 'manhattan' | 'normal' =
store.displaySettings().lineType === 'manhattan' ? 'normal' : 'manhattan';
patchState(store, {
displaySettings: {
...store.displaySettings(),
lineType: next,
},
});
},
toggleSnapToGrid() {
patchState(store, (s) => ({
displaySettings: {
...s.displaySettings,
snapToGrid: !s.displaySettings.snapToGrid,
},
}));
},
toggleLabels() {
patchState(store, (s) => ({
displaySettings: {
...s.displaySettings,
showLabels: !s.displaySettings.showLabels,
},
}));
},
setContextMenu(menu: ContextMenuState | null) {
patchState(store, { contextMenu: menu });
},
setRightPanelData(data: Record<string, unknown> | null) {
patchState(store, { rightPanelData: data });
},
setConnectionsPanelData(data: Record<string, unknown> | null) {
patchState(store, { connectionsPanelData: data });
},
setConnectionsPanelVisible(visible: boolean) {
patchState(store, { connectionsPanelVisible: visible });
},
toggleLasso() {
const next = !store.lassoActive();
const graph = store.graph();
if (graph) {
if (next) {
graph.enableRubberband();
} else {
graph.disableRubberband();
}
}
patchState(store, { lassoActive: next });
},
})),
);

View File

@ -1,119 +0,0 @@
import { useState, type ReactNode } from 'react';
import { Button, Tooltip } from 'antd';
import { MenuFoldOutlined, MenuUnfoldOutlined } from '@ant-design/icons';
interface AppLayoutProps {
toolbar: ReactNode;
leftPanel: ReactNode;
canvas: ReactNode;
rightPanel: ReactNode;
bottomPanel: ReactNode;
}
export function AppLayout({
toolbar,
leftPanel,
canvas,
rightPanel,
bottomPanel,
}: AppLayoutProps) {
const [leftCollapsed, setLeftCollapsed] = useState(false);
const [rightCollapsed, setRightCollapsed] = useState(false);
const leftWidth = leftCollapsed ? 0 : 240;
const rightWidth = rightCollapsed ? 0 : 280;
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
height: '100vh',
overflow: 'hidden',
}}
>
{/* Toolbar */}
{toolbar}
{/* Main content */}
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
{/* Left panel */}
<div
style={{
width: leftWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
position: 'relative',
}}
>
{!leftCollapsed && leftPanel}
</div>
{/* Left toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderRight: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={leftCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="right">
<Button
type="text"
size="small"
icon={leftCollapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setLeftCollapsed(!leftCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Canvas + bottom panel */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<div style={{ flex: 1, overflow: 'hidden' }}>{canvas}</div>
{bottomPanel}
</div>
{/* Right toggle */}
<div
style={{
width: 20,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
borderLeft: '1px solid #f0f0f0',
background: '#fafafa',
}}
>
<Tooltip title={rightCollapsed ? 'Показать панель' : 'Скрыть панель'} placement="left">
<Button
type="text"
size="small"
icon={rightCollapsed ? <MenuFoldOutlined /> : <MenuUnfoldOutlined />}
onClick={() => setRightCollapsed(!rightCollapsed)}
style={{ fontSize: 10 }}
/>
</Tooltip>
</div>
{/* Right panel */}
<div
style={{
width: rightWidth,
transition: 'width 0.2s',
overflow: 'hidden',
flexShrink: 0,
}}
>
{!rightCollapsed && rightPanel}
</div>
</div>
</div>
);
}

View File

@ -1,170 +0,0 @@
import { useState } from 'react';
import { Table, Input, Button, Tag, Space } from 'antd';
import type { ColumnsType } from 'antd/es/table';
import { CloseOutlined, FullscreenOutlined, FullscreenExitOutlined } from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import { mockData } from '../../mock/schemaData.ts';
import type { EntityStatus, Line } from '../../types/index.ts';
const { Search } = Input;
interface ConnectionRow {
key: string;
lineName: string;
lineStatus: EntityStatus;
deviceAName: string;
portAName: string;
deviceZName: string;
portZName: string;
}
export function ConnectionsPanel() {
const visible = useSchemaStore((s) => s.connectionsPanelVisible);
const setVisible = useSchemaStore((s) => s.setConnectionsPanelVisible);
const panelData = useSchemaStore((s) => s.connectionsPanelData);
const [searchValue, setSearchValue] = useState('');
const [expanded, setExpanded] = useState(false);
if (!visible || !panelData) return null;
// Build rows from panel data
const rows: ConnectionRow[] = [];
if (panelData.line) {
// Single line mode
const line = panelData.line as Line;
const portA = panelData.portA as { name: string } | null;
const portZ = panelData.portZ as { name: string } | null;
const devA = panelData.deviceA as { name: string } | null;
const devZ = panelData.deviceZ as { name: string } | null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
} else if (panelData.lines) {
// Multiple lines mode
const lines = panelData.lines as Line[];
for (const line of lines) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
rows.push({
key: line.id,
lineName: line.name,
lineStatus: line.status,
deviceAName: devA?.name ?? '—',
portAName: portA?.name ?? '—',
deviceZName: devZ?.name ?? '—',
portZName: portZ?.name ?? '—',
});
}
}
const filtered = searchValue
? rows.filter(
(r) =>
r.lineName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceAName.toLowerCase().includes(searchValue.toLowerCase()) ||
r.deviceZName.toLowerCase().includes(searchValue.toLowerCase()),
)
: rows;
const columns: ColumnsType<ConnectionRow> = [
{
title: 'Линия',
dataIndex: 'lineName',
key: 'lineName',
render: (name: string, record: ConnectionRow) => {
const colors = STATUS_COLORS[record.lineStatus];
return (
<Space size={4}>
<Tag color={colors.border} style={{ color: colors.text, fontSize: 10 }}>
{STATUS_LABELS[record.lineStatus]}
</Tag>
<span>{name}</span>
</Space>
);
},
},
{
title: 'Устройство A',
key: 'deviceA',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceAName} ({record.portAName})
</span>
),
},
{
title: 'Устройство Z',
key: 'deviceZ',
render: (_: unknown, record: ConnectionRow) => (
<span>
{record.deviceZName} ({record.portZName})
</span>
),
},
];
return (
<div
style={{
height: expanded ? '60%' : '30%',
minHeight: 150,
borderTop: '1px solid #f0f0f0',
background: '#fff',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '6px 12px',
borderBottom: '1px solid #f0f0f0',
}}
>
<span style={{ fontWeight: 600, fontSize: 13 }}>Соединения</span>
<Space size={4}>
<Search
placeholder="Поиск..."
size="small"
style={{ width: 180 }}
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
<Button
size="small"
icon={expanded ? <FullscreenExitOutlined /> : <FullscreenOutlined />}
onClick={() => setExpanded(!expanded)}
/>
<Button
size="small"
icon={<CloseOutlined />}
onClick={() => setVisible(false)}
/>
</Space>
</div>
<div style={{ flex: 1, overflow: 'auto' }}>
<Table
dataSource={filtered}
columns={columns}
size="small"
pagination={false}
style={{ fontSize: 11 }}
/>
</div>
</div>
);
}

View File

@ -1,149 +0,0 @@
import { useState, useMemo } from 'react';
import { Tree, Input } from 'antd';
import type { TreeDataNode } from 'antd';
import {
ApartmentOutlined,
HddOutlined,
ClusterOutlined,
} from '@ant-design/icons';
import { mockData } from '../../mock/schemaData.ts';
import { useSchemaStore } from '../../store/schemaStore.ts';
const { Search } = Input;
export function LeftPanel() {
const [searchValue, setSearchValue] = useState('');
const [expandedKeys, setExpandedKeys] = useState<string[]>(['sites', 'all-devices']);
const graph = useSchemaStore((s) => s.graph);
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
const treeData = useMemo((): TreeDataNode[] => {
const sitesTree: TreeDataNode[] = mockData.sites
.filter((s) => !s.parentSiteId)
.map((site) => {
const children: TreeDataNode[] = [];
// Add devices belonging to this site
const siteDevices = mockData.devices.filter(
(d) => d.siteId === site.id,
);
for (const device of siteDevices) {
children.push({
key: device.id,
title: device.name,
icon: <HddOutlined />,
});
}
// Add child sites
const childSites = mockData.sites.filter(
(s) => s.parentSiteId === site.id,
);
for (const childSite of childSites) {
const childDevices = mockData.devices.filter(
(d) => d.siteId === childSite.id,
);
children.push({
key: childSite.id,
title: childSite.name,
icon: <ApartmentOutlined />,
children: childDevices.map((d) => ({
key: d.id,
title: d.name,
icon: <HddOutlined />,
})),
});
}
return {
key: site.id,
title: site.name,
icon: <ApartmentOutlined />,
children,
};
});
return [
{
key: 'sites',
title: 'Сайты',
icon: <ClusterOutlined />,
children: sitesTree,
},
];
}, []);
const filteredTreeData = useMemo(() => {
if (!searchValue) return treeData;
const filterTree = (nodes: TreeDataNode[]): TreeDataNode[] => {
return nodes
.map((node) => {
const title = String(node.title ?? '');
const match = title.toLowerCase().includes(searchValue.toLowerCase());
const filteredChildren = node.children
? filterTree(node.children)
: [];
if (match || filteredChildren.length > 0) {
return { ...node, children: filteredChildren };
}
return null;
})
.filter(Boolean) as TreeDataNode[];
};
return filterTree(treeData);
}, [treeData, searchValue]);
const handleSelect = (selectedKeys: React.Key[]) => {
const key = selectedKeys[0] as string;
if (!key || !graph) return;
// Find the node on the graph and center on it
const cell = graph.getCellById(key);
if (cell) {
graph.centerCell(cell);
graph.select(cell);
// Set right panel data
const data = cell.getData() as Record<string, unknown> | undefined;
if (data) {
setRightPanelData(data);
}
}
};
return (
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
background: '#fff',
borderRight: '1px solid #f0f0f0',
}}
>
<div style={{ padding: '8px 12px', borderBottom: '1px solid #f0f0f0' }}>
<Search
placeholder="Поиск..."
size="small"
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
allowClear
/>
</div>
<div style={{ flex: 1, overflow: 'auto', padding: '4px 0' }}>
<Tree
showIcon
treeData={filteredTreeData}
expandedKeys={expandedKeys}
onExpand={(keys) => setExpandedKeys(keys as string[])}
onSelect={handleSelect}
blockNode
style={{ fontSize: 12 }}
/>
</div>
</div>
);
}

View File

@ -1,134 +0,0 @@
import { Descriptions, Empty, Tag } from 'antd';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { STATUS_COLORS, STATUS_LABELS } from '../../constants/statusColors.ts';
import type { EntityStatus } from '../../types/index.ts';
function StatusTag({ status }: { status: EntityStatus }) {
const colors = STATUS_COLORS[status];
const label = STATUS_LABELS[status];
return (
<Tag color={colors.border} style={{ color: colors.text }}>
{label}
</Tag>
);
}
export function RightPanel() {
const data = useSchemaStore((s) => s.rightPanelData);
if (!data) {
return (
<div
style={{
height: '100%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#fff',
borderLeft: '1px solid #f0f0f0',
}}
>
<Empty description="Выберите объект" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</div>
);
}
const entityType = data.entityType as string;
const status = data.status as EntityStatus;
if (entityType === 'site') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Сайт"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Адрес">{data.address as string}</Descriptions.Item>
<Descriptions.Item label="ERP">{data.erpCode as string}</Descriptions.Item>
<Descriptions.Item label="1С">{data.code1C as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'device') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Устройство"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="IP">{data.ipAddress as string}</Descriptions.Item>
<Descriptions.Item label="Маркировка">{data.marking as string}</Descriptions.Item>
<Descriptions.Item label="Группа">{data.group as string}</Descriptions.Item>
<Descriptions.Item label="Категория">{data.category as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'line') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Линия"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Название">{data.name as string}</Descriptions.Item>
<Descriptions.Item label="Среда">{data.medium as string}</Descriptions.Item>
<Descriptions.Item label="Тип линии">{data.lineStyle as string}</Descriptions.Item>
<Descriptions.Item label="Тип">{data.type as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
if (entityType === 'card') {
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions
title="Карта"
column={1}
size="small"
bordered
labelStyle={{ fontSize: 11, width: 110 }}
contentStyle={{ fontSize: 11 }}
>
<Descriptions.Item label="Слот">{data.slotName as string}</Descriptions.Item>
<Descriptions.Item label="Сетевое имя">{data.networkName as string}</Descriptions.Item>
<Descriptions.Item label="Статус"><StatusTag status={status} /></Descriptions.Item>
</Descriptions>
</div>
);
}
return (
<div style={{ padding: 12, background: '#fff', borderLeft: '1px solid #f0f0f0', height: '100%', overflow: 'auto' }}>
<Descriptions title="Объект" column={1} size="small" bordered>
{Object.entries(data).map(([key, value]) => (
<Descriptions.Item key={key} label={key}>
{String(value ?? '')}
</Descriptions.Item>
))}
</Descriptions>
</div>
);
}

View File

@ -1,178 +0,0 @@
import { Button, Slider, Space, Switch, Tooltip, message } from 'antd';
import {
ZoomInOutlined,
ZoomOutOutlined,
ExpandOutlined,
PlusOutlined,
DeleteOutlined,
ReloadOutlined,
PictureOutlined,
AppstoreOutlined,
NodeIndexOutlined,
EyeOutlined,
EditOutlined,
GatewayOutlined,
} from '@ant-design/icons';
import { useSchemaStore } from '../../store/schemaStore.ts';
export function Toolbar() {
const graph = useSchemaStore((s) => s.graph);
const mode = useSchemaStore((s) => s.mode);
const setMode = useSchemaStore((s) => s.setMode);
const displaySettings = useSchemaStore((s) => s.displaySettings);
const toggleGrid = useSchemaStore((s) => s.toggleGrid);
const toggleMinimap = useSchemaStore((s) => s.toggleMinimap);
const switchLineType = useSchemaStore((s) => s.switchLineType);
const toggleLabels = useSchemaStore((s) => s.toggleLabels);
const lassoActive = useSchemaStore((s) => s.lassoActive);
const toggleLasso = useSchemaStore((s) => s.toggleLasso);
const zoom = graph ? Math.round(graph.zoom() * 100) : 100;
const handleZoomIn = () => graph?.zoom(0.1);
const handleZoomOut = () => graph?.zoom(-0.1);
const handleFit = () => graph?.zoomToFit({ padding: 40 });
const handleZoomChange = (value: number) => {
if (graph) {
graph.zoomTo(value / 100);
}
};
const handleExportPng = () => {
message.info('В разработке');
};
return (
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 16px',
height: 48,
borderBottom: '1px solid #f0f0f0',
background: '#fff',
flexShrink: 0,
}}
>
{/* Left: display settings */}
<Space size="middle">
<Tooltip title="Сетка">
<Switch
size="small"
checked={displaySettings.showGrid}
onChange={toggleGrid}
checkedChildren={<AppstoreOutlined />}
unCheckedChildren={<AppstoreOutlined />}
/>
</Tooltip>
<Tooltip title="Мини-карта">
<Switch
size="small"
checked={displaySettings.showMinimap}
onChange={toggleMinimap}
/>
</Tooltip>
<Tooltip
title={
displaySettings.lineType === 'manhattan'
? 'Ломаные линии'
: 'Прямые линии'
}
>
<Button
size="small"
icon={<NodeIndexOutlined />}
onClick={switchLineType}
type={displaySettings.lineType === 'manhattan' ? 'primary' : 'default'}
/>
</Tooltip>
<Tooltip title="Подписи">
<Switch
size="small"
checked={displaySettings.showLabels}
onChange={toggleLabels}
/>
</Tooltip>
</Space>
{/* Center: actions */}
<Space>
<Tooltip title="Добавить объект">
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Удалить">
<Button
size="small"
icon={<DeleteOutlined />}
onClick={() => {
if (graph) {
const cells = graph.getSelectedCells();
if (cells.length) graph.removeCells(cells);
}
}}
/>
</Tooltip>
<Tooltip title="Обновить раскладку">
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => message.info('В разработке')}
/>
</Tooltip>
<Tooltip title="Выделение лассо">
<Button
size="small"
type={lassoActive ? 'primary' : 'default'}
icon={<GatewayOutlined />}
onClick={toggleLasso}
/>
</Tooltip>
<Tooltip title="Экспорт PNG">
<Button
size="small"
icon={<PictureOutlined />}
onClick={handleExportPng}
/>
</Tooltip>
</Space>
{/* Right: zoom + mode */}
<Space size="middle">
<Space size={4}>
<Tooltip title="Уменьшить">
<Button size="small" icon={<ZoomOutOutlined />} onClick={handleZoomOut} />
</Tooltip>
<Slider
style={{ width: 100 }}
min={10}
max={300}
value={zoom}
onChange={handleZoomChange}
tooltip={{ formatter: (v) => `${v}%` }}
/>
<Tooltip title="Увеличить">
<Button size="small" icon={<ZoomInOutlined />} onClick={handleZoomIn} />
</Tooltip>
<Tooltip title="Уместить на экран">
<Button size="small" icon={<ExpandOutlined />} onClick={handleFit} />
</Tooltip>
</Space>
<Tooltip title={mode === 'view' ? 'Режим просмотра' : 'Режим редактирования'}>
<Button
size="small"
type={mode === 'edit' ? 'primary' : 'default'}
icon={mode === 'view' ? <EyeOutlined /> : <EditOutlined />}
onClick={() => setMode(mode === 'view' ? 'edit' : 'view')}
>
{mode === 'view' ? 'Просмотр' : 'Редактирование'}
</Button>
</Tooltip>
</Space>
</div>
);
}

View File

@ -1,267 +0,0 @@
import { useEffect, useRef } from 'react';
import { useSchemaStore } from '../../store/schemaStore.ts';
import { initGraph } from './graph/initGraph.ts';
import { registerAllNodes } from './graph/registerNodes.ts';
import { buildGraphData } from './helpers/dataMapper.ts';
import { mockData } from '../../mock/schemaData.ts';
import { DeviceGroup } from '../../types/index.ts';
let nodesRegistered = false;
export function SchemaCanvas() {
const containerRef = useRef<HTMLDivElement>(null);
const minimapRef = useRef<HTMLDivElement>(null);
const setGraph = useSchemaStore((state) => state.setGraph);
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
const setRightPanelData = useSchemaStore((state) => state.setRightPanelData);
const setConnectionsPanelData = useSchemaStore(
(state) => state.setConnectionsPanelData,
);
const setConnectionsPanelVisible = useSchemaStore(
(state) => state.setConnectionsPanelVisible,
);
const displaySettings = useSchemaStore((state) => state.displaySettings);
useEffect(() => {
if (!containerRef.current) return;
if (!nodesRegistered) {
registerAllNodes();
nodesRegistered = true;
}
const graph = initGraph(containerRef.current, minimapRef.current);
setGraph(graph);
const { nodes, edges } = buildGraphData(mockData, displaySettings.lineType);
// Add nodes first (sites, then devices, then cards)
const siteNodes = nodes.filter((n) => n.shape === 'site-node');
const deviceNodes = nodes.filter(
(n) => n.shape !== 'site-node' && n.shape !== 'card-node',
);
const cardNodes = nodes.filter((n) => n.shape === 'card-node');
for (const node of siteNodes) {
graph.addNode(node);
}
for (const node of deviceNodes) {
const graphNode = graph.addNode(node);
// Set parent (embed in site)
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
for (const node of cardNodes) {
const graphNode = graph.addNode(node);
if (node.parent) {
const parentNode = graph.getCellById(node.parent);
if (parentNode) {
parentNode.addChild(graphNode);
}
}
}
// Add edges
for (const edge of edges) {
graph.addEdge(edge);
}
// Center content
graph.centerContent();
// Event handlers
graph.on('node:click', ({ node }) => {
const data = node.getData() as Record<string, unknown>;
setRightPanelData(data);
});
graph.on('edge:click', ({ edge }) => {
const data = edge.getData() as Record<string, unknown>;
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
setRightPanelData({
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
medium: line.medium,
lineStyle: line.lineStyle,
type: line.type,
});
} else if (data) {
setRightPanelData(data);
}
});
graph.on('node:contextmenu', ({ e, node }) => {
e.preventDefault();
const data = node.getData() as Record<string, unknown>;
const entityType = data.entityType as string;
let menuType: 'site' | 'active-device' | 'passive-device' = 'active-device';
if (entityType === 'site') {
menuType = 'site';
} else if (entityType === 'device') {
const group = data.group as string;
menuType = group === DeviceGroup.Passive ? 'passive-device' : 'active-device';
}
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: menuType,
data: data,
});
});
graph.on('edge:contextmenu', ({ e, edge }) => {
e.preventDefault();
const line = mockData.lines.find((l) => l.id === edge.id);
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'line',
data: line
? {
entityType: 'line',
entityId: line.id,
name: line.name,
status: line.status,
}
: {},
});
});
graph.on('blank:contextmenu', ({ e }) => {
e.preventDefault();
setContextMenu({
visible: true,
x: e.clientX,
y: e.clientY,
type: 'blank',
data: {},
});
});
graph.on('blank:click', () => {
setContextMenu(null);
setRightPanelData(null);
});
// Port side recalculation on node move
graph.on('node:moved', () => {
// In a full implementation, we'd recalculate port sides here
});
// Show connections panel on edge double click
graph.on('edge:dblclick', ({ edge }) => {
const line = mockData.lines.find((l) => l.id === edge.id);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA
? mockData.devices.find((d) => d.id === portA.deviceId)
: null;
const devZ = portZ
? mockData.devices.find((d) => d.id === portZ.deviceId)
: null;
setConnectionsPanelData({
line,
portA,
portZ,
deviceA: devA,
deviceZ: devZ,
});
setConnectionsPanelVisible(true);
}
});
return () => {
graph.dispose();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Sync display settings: grid
useEffect(() => {
const graph = useSchemaStore.getState().graph;
if (!graph) return;
if (displaySettings.showGrid) {
graph.showGrid();
} else {
graph.hideGrid();
}
}, [displaySettings.showGrid]);
// Sync display settings: labels
useEffect(() => {
const graph = useSchemaStore.getState().graph;
if (!graph) return;
const show = displaySettings.showLabels;
// Toggle edge labels
for (const edge of graph.getEdges()) {
const labels = edge.getLabels();
if (labels.length > 0) {
edge.setLabels(
labels.map((label) => ({
...label,
attrs: {
...label.attrs,
label: {
...(label.attrs?.label as Record<string, unknown>),
visibility: show ? 'visible' : 'hidden',
},
rect: {
...(label.attrs?.rect as Record<string, unknown>),
visibility: show ? 'visible' : 'hidden',
},
},
})),
);
}
}
// Toggle port labels
for (const node of graph.getNodes()) {
const ports = node.getPorts();
for (const port of ports) {
node.setPortProp(port.id!, 'attrs/text/visibility', show ? 'visible' : 'hidden');
}
}
}, [displaySettings.showLabels]);
return (
<div style={{ position: 'relative', width: '100%', height: '100%' }}>
<div
ref={containerRef}
style={{ width: '100%', height: '100%' }}
/>
<div
ref={minimapRef}
style={{
position: 'absolute',
bottom: 16,
right: 16,
width: 200,
height: 150,
border: '1px solid #d9d9d9',
background: '#fff',
borderRadius: 4,
overflow: 'hidden',
display: displaySettings.showMinimap ? 'block' : 'none',
boxShadow: '0 2px 8px rgba(0,0,0,0.1)',
}}
/>
</div>
);
}

View File

@ -1,139 +0,0 @@
import { useEffect, useRef } from 'react';
import { Dropdown, message } from 'antd';
import type { MenuProps } from 'antd';
import { useContextMenu } from '../../../hooks/useContextMenu.ts';
import { useSchemaStore } from '../../../store/schemaStore.ts';
import { mockData } from '../../../mock/schemaData.ts';
export function ContextMenu() {
const { contextMenu, hideMenu } = useContextMenu();
const setRightPanelData = useSchemaStore((s) => s.setRightPanelData);
const setConnectionsPanelVisible = useSchemaStore(
(s) => s.setConnectionsPanelVisible,
);
const setConnectionsPanelData = useSchemaStore(
(s) => s.setConnectionsPanelData,
);
const triggerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleClick = () => hideMenu();
document.addEventListener('click', handleClick);
return () => document.removeEventListener('click', handleClick);
}, [hideMenu]);
if (!contextMenu?.visible) return null;
const wip = () => message.info('В разработке');
const siteMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
{ key: 'edit', label: 'Редактировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const activeDeviceMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const deviceId = contextMenu.data.entityId as string;
const deviceLines = mockData.lines.filter((l) => {
const portA = mockData.ports.find((p) => p.id === l.portAId);
const portZ = mockData.ports.find((p) => p.id === l.portZId);
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
});
setConnectionsPanelData({ lines: deviceLines, deviceId });
setConnectionsPanelVisible(true);
hideMenu();
}},
{ key: 'create-line', label: 'Создать линию', onClick: wip },
{ key: 'copy', label: 'Копировать', onClick: wip },
{ key: 'move', label: 'Переместить на другой сайт', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const passiveDeviceMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const deviceId = contextMenu.data.entityId as string;
const deviceLines = mockData.lines.filter((l) => {
const portA = mockData.ports.find((p) => p.id === l.portAId);
const portZ = mockData.ports.find((p) => p.id === l.portZId);
return portA?.deviceId === deviceId || portZ?.deviceId === deviceId;
});
setConnectionsPanelData({ lines: deviceLines, deviceId });
setConnectionsPanelVisible(true);
hideMenu();
}},
{ key: 'copy', label: 'Копировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const lineMenu: MenuProps['items'] = [
{ key: 'view', label: 'Просмотр карточки', onClick: () => { setRightPanelData(contextMenu.data); hideMenu(); } },
{ key: 'connections', label: 'Показать соединения', onClick: () => {
const lineId = contextMenu.data.entityId as string;
const line = mockData.lines.find((l) => l.id === lineId);
if (line) {
const portA = mockData.ports.find((p) => p.id === line.portAId);
const portZ = mockData.ports.find((p) => p.id === line.portZId);
const devA = portA ? mockData.devices.find((d) => d.id === portA.deviceId) : null;
const devZ = portZ ? mockData.devices.find((d) => d.id === portZ.deviceId) : null;
setConnectionsPanelData({ line, portA, portZ, deviceA: devA, deviceZ: devZ });
setConnectionsPanelVisible(true);
}
hideMenu();
}},
{ key: 'break', label: 'Разорвать линию', onClick: wip },
{ key: 'copy', label: 'Копировать', onClick: wip },
{ type: 'divider' },
{ key: 'delete', label: 'Удалить', danger: true, onClick: wip },
];
const blankMenu: MenuProps['items'] = [
{ key: 'add-device', label: 'Добавить устройство', onClick: wip },
{ key: 'create-line', label: 'Создать линию', onClick: wip },
{ key: 'paste', label: 'Вставить', onClick: wip },
{ type: 'divider' },
{ key: 'fit', label: 'Уместить на экран', onClick: () => {
const graph = useSchemaStore.getState().graph;
graph?.zoomToFit({ padding: 40 });
hideMenu();
}},
];
const menuMap: Record<string, MenuProps['items']> = {
'site': siteMenu,
'active-device': activeDeviceMenu,
'passive-device': passiveDeviceMenu,
'line': lineMenu,
'line-group': lineMenu,
'blank': blankMenu,
};
const items = menuMap[contextMenu.type] ?? blankMenu;
return (
<div
ref={triggerRef}
style={{
position: 'fixed',
left: contextMenu.x,
top: contextMenu.y,
zIndex: 1000,
}}
>
<Dropdown
menu={{ items }}
open={true}
onOpenChange={(open) => { if (!open) hideMenu(); }}
trigger={['contextMenu']}
>
<div style={{ width: 1, height: 1 }} />
</Dropdown>
</div>
);
}

View File

@ -1,64 +0,0 @@
import type { SchemaData, Port } from '../../../types/index.ts';
import { DeviceCategory } from '../../../types/index.ts';
interface DevicePosition {
x: number;
y: number;
}
export function resolvePortSides(
data: SchemaData,
devicePositions: Map<string, DevicePosition>,
): Map<string, 'left' | 'right'> {
const portSideMap = new Map<string, 'left' | 'right'>();
const device = (id: string) => data.devices.find((d) => d.id === id);
for (const port of data.ports) {
const dev = device(port.deviceId);
if (!dev) {
portSideMap.set(port.id, port.side);
continue;
}
// Cross devices: L ports → left, S ports → right (hardcoded)
if (
dev.category === DeviceCategory.CrossOptical ||
dev.category === DeviceCategory.CrossCopper
) {
const side = port.slotName === 'L' || port.name.startsWith('L') ? 'left' : 'right';
portSideMap.set(port.id, side);
continue;
}
// For other devices, determine side based on connected device position
const connectedLine = data.lines.find(
(l) => l.portAId === port.id || l.portZId === port.id,
);
if (!connectedLine) {
portSideMap.set(port.id, port.side);
continue;
}
const otherPortId =
connectedLine.portAId === port.id
? connectedLine.portZId
: connectedLine.portAId;
const otherPort = data.ports.find((p: Port) => p.id === otherPortId);
if (!otherPort) {
portSideMap.set(port.id, port.side);
continue;
}
const thisPos = devicePositions.get(port.deviceId);
const otherPos = devicePositions.get(otherPort.deviceId);
if (thisPos && otherPos) {
portSideMap.set(port.id, otherPos.x < thisPos.x ? 'left' : 'right');
} else {
portSideMap.set(port.id, port.side);
}
}
return portSideMap;
}

View File

@ -1,43 +0,0 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface CardNodeData {
slotName: string;
networkName: string;
status: EntityStatus;
}
export function CardNode({ node }: { node: Node }) {
const data = node.getData() as CardNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
borderTop: `1.5px solid ${colors.border}`,
borderBottom: `1.5px solid ${colors.border}`,
borderLeft: `5px solid ${colors.border}`,
borderRight: `5px solid ${colors.border}`,
borderRadius: 0,
background: colors.fill,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
fontSize: 9,
fontWeight: 600,
color: colors.text,
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: '12px',
padding: '2px 4px',
}}
>
{data.slotName}:{data.networkName}
</div>
);
}

View File

@ -1,83 +0,0 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { CROSS_BORDER_RADIUS } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface CrossDeviceData {
name: string;
networkName: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function CrossDeviceNode({ node }: { node: Node }) {
const data = node.getData() as CrossDeviceData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
const r = CROSS_BORDER_RADIUS;
const w = size.width;
const h = size.height;
// Asymmetric rounding: top-left and bottom-right rounded
const path = `
M ${r} 0
L ${w} 0
L ${w} ${h - r}
Q ${w} ${h} ${w - r} ${h}
L 0 ${h}
L 0 ${r}
Q 0 0 ${r} 0
Z
`;
return (
<div style={{ width: w, height: h, position: 'relative' }}>
<svg
width={w}
height={h}
style={{ position: 'absolute', top: 0, left: 0 }}
>
<path
d={path}
fill={colors.fill}
stroke={colors.border}
strokeWidth={1}
/>
</svg>
<div
style={{
position: 'absolute',
top: 0,
left: 0,
width: w,
height: h,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
padding: '8px 6px',
boxSizing: 'border-box',
textAlign: 'center',
fontSize: 10,
lineHeight: '14px',
pointerEvents: 'none',
}}
>
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 4, wordBreak: 'break-word' }}>
{data.name}
</div>
{data.networkName && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.networkName}</div>
)}
{data.marking && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.marking}</div>
)}
{data.id1 && (
<div style={{ color: '#8c8c8c', fontSize: 9 }}>{data.id1}</div>
)}
</div>
</div>
);
}

View File

@ -1,102 +0,0 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { DEVICE_BORDER_RADIUS, DEVICE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
import { DeviceGroup, type EntityStatus } from '../../../types/index.ts';
interface DeviceNodeData {
name: string;
networkName: string;
ipAddress: string;
marking: string;
id1: string;
id2: string;
group: DeviceGroup;
status: EntityStatus;
}
export function DeviceNode({ node }: { node: Node }) {
const data = node.getData() as DeviceNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
const isActive = data.group === DeviceGroup.Active;
return (
<div
style={{
width: size.width,
height: size.height,
border: `1px solid ${colors.border}`,
borderRadius: DEVICE_BORDER_RADIUS,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
boxSizing: 'border-box',
fontSize: 10,
lineHeight: '14px',
overflow: 'hidden',
}}
>
<div
style={{
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',
}}
>
<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>
);
}

View File

@ -1,57 +0,0 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { SITE_HEADER_HEIGHT } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface SiteNodeData {
name: string;
address: string;
erpCode: string;
code1C: string;
status: EntityStatus;
}
export function SiteNode({ node }: { node: Node }) {
const data = node.getData() as SiteNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
border: `3.87px solid ${colors.border}`,
borderRadius: 0,
background: 'transparent',
position: 'relative',
boxSizing: 'border-box',
overflow: 'visible',
pointerEvents: 'none',
}}
>
<div
style={{
height: SITE_HEADER_HEIGHT,
background: '#1a1a2e',
color: '#ffffff',
padding: '4px 10px',
fontSize: 10,
lineHeight: '14px',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
gap: 1,
pointerEvents: 'auto',
}}
>
<div style={{ fontWeight: 700, fontSize: 11, wordBreak: 'break-word' }}>
{data.name}
</div>
<div style={{ opacity: 0.8, fontSize: 9, wordBreak: 'break-word' }}>
{data.address} | ERP: {data.erpCode} | 1С: {data.code1C}
</div>
</div>
</div>
);
}

View File

@ -1,46 +0,0 @@
import type { Node } from '@antv/x6';
import { STATUS_COLORS } from '../../../constants/statusColors.ts';
import { SPLICE_BORDER_RADIUS } from '../../../constants/sizes.ts';
import type { EntityStatus } from '../../../types/index.ts';
interface SpliceNodeData {
name: string;
marking: string;
id1: string;
id2: string;
status: EntityStatus;
}
export function SpliceNode({ node }: { node: Node }) {
const data = node.getData() as SpliceNodeData;
const colors = STATUS_COLORS[data.status];
const size = node.getSize();
return (
<div
style={{
width: size.width,
height: size.height,
border: `1px solid ${colors.border}`,
borderRadius: SPLICE_BORDER_RADIUS,
background: colors.fill,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
boxSizing: 'border-box',
textAlign: 'center',
padding: 4,
fontSize: 10,
lineHeight: '14px',
}}
>
<div style={{ fontWeight: 700, fontSize: 11, marginBottom: 2, wordBreak: 'break-word' }}>
{data.name}
</div>
{data.marking && (
<div style={{ color: '#595959', fontSize: 9 }}>{data.marking}</div>
)}
</div>
);
}

View File

@ -1,21 +0,0 @@
import { useCallback } from 'react';
import { useSchemaStore } from '../store/schemaStore.ts';
import type { ContextMenuState } from '../store/schemaStore.ts';
export function useContextMenu() {
const contextMenu = useSchemaStore((state) => state.contextMenu);
const setContextMenu = useSchemaStore((state) => state.setContextMenu);
const showMenu = useCallback(
(menu: Omit<ContextMenuState, 'visible'>) => {
setContextMenu({ ...menu, visible: true });
},
[setContextMenu],
);
const hideMenu = useCallback(() => {
setContextMenu(null);
}, [setContextMenu]);
return { contextMenu, showMenu, hideMenu };
}

View File

@ -1,5 +0,0 @@
import { useSchemaStore } from '../store/schemaStore.ts';
export function useGraph() {
return useSchemaStore((state) => state.graph);
}

12
frontend/src/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Схема связей устройств</title>
<link rel="icon" type="image/x-icon" href="favicon.ico" />
</head>
<body>
<app-root></app-root>
</body>
</html>

6
frontend/src/main.ts Normal file
View File

@ -0,0 +1,6 @@
import { bootstrapApplication } from '@angular/platform-browser';
import { appConfig } from './app/app.config';
import { AppComponent } from './app/app.component';
bootstrapApplication(AppComponent, appConfig)
.catch((err) => console.error(err));

View File

@ -1,5 +0,0 @@
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App.tsx';
createRoot(document.getElementById('root')!).render(<App />);

View File

@ -1,119 +0,0 @@
import { create } from 'zustand';
import type { Graph } from '@antv/x6';
export interface DisplaySettings {
showGrid: boolean;
showMinimap: boolean;
lineType: 'manhattan' | 'normal';
snapToGrid: boolean;
showLabels: boolean;
}
export interface ContextMenuState {
visible: boolean;
x: number;
y: number;
type: 'site' | 'active-device' | 'passive-device' | 'line' | 'line-group' | 'blank';
data: Record<string, unknown>;
}
interface SchemaStore {
graph: Graph | null;
mode: 'view' | 'edit';
selectedElements: string[];
displaySettings: DisplaySettings;
contextMenu: ContextMenuState | null;
rightPanelData: Record<string, unknown> | null;
connectionsPanelData: Record<string, unknown> | null;
connectionsPanelVisible: boolean;
lassoActive: boolean;
setGraph: (graph: Graph) => void;
setMode: (mode: 'view' | 'edit') => void;
setSelectedElements: (elements: string[]) => void;
toggleGrid: () => void;
toggleMinimap: () => void;
switchLineType: () => void;
toggleSnapToGrid: () => void;
toggleLabels: () => void;
setContextMenu: (menu: ContextMenuState | null) => void;
setRightPanelData: (data: Record<string, unknown> | null) => void;
setConnectionsPanelData: (data: Record<string, unknown> | null) => void;
setConnectionsPanelVisible: (visible: boolean) => void;
toggleLasso: () => void;
}
export const useSchemaStore = create<SchemaStore>((set) => ({
graph: null,
mode: 'view',
selectedElements: [],
displaySettings: {
showGrid: true,
showMinimap: true,
lineType: 'manhattan',
snapToGrid: true,
showLabels: true,
},
contextMenu: null,
rightPanelData: null,
connectionsPanelData: null,
connectionsPanelVisible: false,
lassoActive: false,
setGraph: (graph) => set({ graph }),
setMode: (mode) => set({ mode }),
setSelectedElements: (elements) => set({ selectedElements: elements }),
toggleGrid: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showGrid: !state.displaySettings.showGrid,
},
})),
toggleMinimap: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showMinimap: !state.displaySettings.showMinimap,
},
})),
switchLineType: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
lineType:
state.displaySettings.lineType === 'manhattan' ? 'normal' : 'manhattan',
},
})),
toggleSnapToGrid: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
snapToGrid: !state.displaySettings.snapToGrid,
},
})),
toggleLabels: () =>
set((state) => ({
displaySettings: {
...state.displaySettings,
showLabels: !state.displaySettings.showLabels,
},
})),
setContextMenu: (menu) => set({ contextMenu: menu }),
setRightPanelData: (data) => set({ rightPanelData: data }),
setConnectionsPanelData: (data) => set({ connectionsPanelData: data }),
setConnectionsPanelVisible: (visible) =>
set({ connectionsPanelVisible: visible }),
toggleLasso: () =>
set((state) => {
const next = !state.lassoActive;
if (state.graph) {
if (next) {
state.graph.enableRubberband();
} else {
state.graph.disableRubberband();
}
}
return { lassoActive: next };
}),
}));

View File

@ -1,10 +1,12 @@
@import "primeicons/primeicons.css";
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body, #root {
html, body {
width: 100%;
height: 100%;
overflow: hidden;

View File

@ -1,28 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": false,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
"outDir": "./out-tsc/app",
"types": []
},
"include": ["src"]
"files": [
"src/main.ts"
],
"include": [
"src/**/*.d.ts"
]
}

View File

@ -1,7 +1,27 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"compileOnSave": false,
"compilerOptions": {
"outDir": "./dist/out-tsc",
"strict": true,
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"moduleResolution": "bundler",
"importHelpers": true,
"target": "ES2022",
"module": "ES2022"
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,
"strictInjectionParameters": true,
"strictInputAccessModifiers": true,
"strictTemplates": true
}
}

View File

@ -1,26 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,15 @@
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jasmine"
]
},
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

View File

@ -1,18 +0,0 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
build: {
outDir: '../dist',
emptyOutDir: true,
rollupOptions: {
output: {
manualChunks: {
antd: ['antd', '@ant-design/icons'],
x6: ['@antv/x6', '@antv/x6-react-shape'],
},
},
},
},
})