Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations

This commit is contained in:
leeyj
2026-03-22 22:37:24 +09:00
commit 5cea93e317
192 changed files with 14449 additions and 0 deletions
@@ -0,0 +1,208 @@
import { state } from '../../../state.js';
import { logger } from '../../../utils/logger.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6EdgeConfig - Maps edge data to X6 connecting properties
*/
export function getX6EdgeConfig(data) {
if (!data || !data.source || !data.target) {
return {
source: data?.source,
target: data?.target,
router: null,
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: 'block'
}
}
};
}
// 1. Resolve Routing Type (Explicit choice > Adaptive)
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
// Adaptive Routing: Only force straight if NO explicit choice was made or if it's currently manhattan
if (state.graph && (routingType === 'manhattan' || !data.routing)) {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const b1 = srcNode.getBBox();
const b2 = dstNode.getBBox();
const p1 = { x: b1.x + b1.width / 2, y: b1.y + b1.height / 2 };
const p2 = { x: b2.x + b2.width / 2, y: b2.y + b2.height / 2 };
const dist = Math.sqrt(Math.pow(p1.x - p2.x, 2) + Math.pow(p1.y - p2.y, 2));
if (dist < 100) {
routingType = 'straight';
}
}
}
const isStraight = (routingType === 'straight');
// 2. Resolve Anchors & Router Args
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
// Manhattan Router Args (Enhanced for topological diagrams)
const routerArgs = {
padding: 10,
step: 5, // [TWEAK] Increase precision (10 -> 5) to reduce redundant bends
offset: data.routing_offset || 20,
exclude(cell) {
return cell && typeof cell.getData === 'function' && cell.getData()?.is_group;
}
};
// Sync Manhattan directions with manual anchors to force the router to respect the side
const validSides = ['top', 'bottom', 'left', 'right'];
if (srcAnchor && validSides.includes(srcAnchor)) {
routerArgs.startDirections = [srcAnchor];
}
if (dstAnchor && validSides.includes(dstAnchor)) {
routerArgs.endDirections = [dstAnchor];
}
if (routingType === 'manhattan-u') {
routerArgs.startDirections = ['top'];
routerArgs.endDirections = ['top'];
}
// 3. Intelligent Port Matching (Shortest Path)
// If ports are not explicitly provided (e.g. via Auto-connect Shift+A),
// we find the closest port pair to ensure the most direct connection.
let resolvedSourcePort = data.source_port;
let resolvedTargetPort = data.target_port;
if (state.graph && !resolvedSourcePort && !resolvedTargetPort && srcAnchor === 'orth' && dstAnchor === 'orth') {
const srcNode = state.graph.getCellById(data.source);
const dstNode = state.graph.getCellById(data.target);
if (srcNode && dstNode && srcNode.isNode() && dstNode.isNode()) {
const bestPorts = findClosestPorts(srcNode, dstNode);
if (bestPorts) {
resolvedSourcePort = bestPorts.source;
resolvedTargetPort = bestPorts.target;
}
}
}
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
const isStraightFinal = (routingType === 'straight');
const direction = data.direction || 'none';
// Resolve source/target configuration
// Rule: If a port is explicitly provided AND the anchor is set to 'orth' (Auto),
// we keep the port. If a manual anchor (top, left, etc.) is set, we clear the port.
const sourceConfig = {
cell: data.source,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedSourcePort && srcAnchor === 'orth') {
sourceConfig.port = resolvedSourcePort;
} else {
// [FIX] In Straight mode, directional anchors (top, left etc) force a bend.
// We use 'center' but rely on 'boundary' connection point for a perfect straight diagonal.
sourceConfig.anchor = isStraight ? { name: 'center' } : { name: srcAnchor };
}
const targetConfig = {
cell: data.target,
connectionPoint: { name: 'boundary', args: { padding: 6 } }
};
if (resolvedTargetPort && dstAnchor === 'orth') {
targetConfig.port = resolvedTargetPort;
} else {
targetConfig.anchor = isStraight ? { name: 'center' } : { name: dstAnchor };
}
const config = {
id: data.id || `e_${data.source}_${data.target}`,
source: sourceConfig,
target: targetConfig,
router: isStraight ? null : (routerConfigs[routingType] || routerConfigs.manhattan),
connector: isStraight ? { name: 'normal' } : { name: 'jumpover', args: { type: 'arc', size: 6 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || (data.style === 'double' ? 4 : 2)),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : (data.style === 'dotted' ? '2,2' : '0'))),
targetMarker: (direction === 'forward' || direction === 'both') ? 'block' : null,
sourceMarker: (direction === 'backward' || direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: (() => {
const lbls = [];
if (data.label) {
lbls.push({
attrs: {
text: { text: data.label, fill: data.color || '#64748b', fontSize: 11, fontWeight: '600' },
rect: { fill: 'rgba(255,255,255,0.8)', rx: 4, ry: 4, strokeWidth: 0 }
}
});
}
if (data.is_tunnel) {
lbls.push({
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 3, ry: 3, stroke: '#ef4444', strokeWidth: 1 }
},
position: { distance: 0.25 }
});
}
return lbls;
})(),
zIndex: data.zIndex || calculateCellZIndex(data, 0, false), // Edges default to 30 (Above groups)
movable: !data.locked,
deletable: !data.locked,
data: data
};
return config;
}
/**
* findClosestPorts - Finds the pair of ports with the minimum distance between two nodes.
*/
function findClosestPorts(srcNode, dstNode) {
const srcPorts = srcNode.getPorts();
const dstPorts = dstNode.getPorts();
if (srcPorts.length === 0 || dstPorts.length === 0) return null;
let minParams = null;
let minDistance = Infinity;
srcPorts.forEach(sp => {
dstPorts.forEach(dp => {
const p1 = srcNode.getPortProp(sp.id, 'args/point') || { x: 0, y: 0 };
const p2 = dstNode.getPortProp(dp.id, 'args/point') || { x: 0, y: 0 };
// Convert to absolute coordinates
const pos1 = srcNode.getPosition();
const pos2 = dstNode.getPosition();
const abs1 = { x: pos1.x + p1.x, y: pos1.y + p1.y };
const abs2 = { x: pos2.x + p2.x, y: pos2.y + p2.y };
const dist = Math.sqrt(Math.pow(abs1.x - abs2.x, 2) + Math.pow(abs1.y - abs2.y, 2));
if (dist < minDistance) {
minDistance = dist;
minParams = { source: sp.id, target: dp.id };
}
});
});
return minParams;
}
@@ -0,0 +1,2 @@
export { getX6NodeConfig } from './node.js';
export { getX6EdgeConfig } from './edge.js';
@@ -0,0 +1,138 @@
import { state } from '../../../state.js';
import { shapeRegistry } from '../registry.js';
import { getLabelAttributes, generateRackUnitsPath } from '../utils.js';
import { DEFAULTS } from '../../../constants.js';
import { calculateCellZIndex } from '../../layers.js';
/**
* getX6NodeConfig - Maps data properties to X6 node attributes using ShapeRegistry
*/
export function getX6NodeConfig(data) {
const nodeType = (data.type || '').toLowerCase();
// 1. Find the plugin in the registry
let plugin = shapeRegistry.get(nodeType);
// Fallback for generic types if not found by direct name
if (!plugin) {
if (nodeType === 'rectangle') plugin = shapeRegistry.get('rect');
else if (nodeType === 'blank') plugin = shapeRegistry.get('dummy');
}
// Default to 'default' plugin if still not found
if (!plugin) plugin = shapeRegistry.get('default');
// 2. Resolve asset path (Skip for groups and primitives!)
const primitives = ['rect', 'circle', 'ellipse', 'rounded-rect', 'text-box', 'label', 'dot', 'dummy', 'rack', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input', 'table', 'user', 'admin', 'hourglass', 'polyline'];
let assetPath = `/static/assets/${DEFAULTS.DEFAULT_ICON}`;
if (data.is_group || primitives.includes(nodeType)) {
assetPath = undefined;
} else if (plugin.shape === 'drawnet-node' || plugin.type === 'default' || !plugin.icon) {
const currentPath = data.assetPath || data.asset_path;
if (currentPath) {
assetPath = currentPath.startsWith('/static/assets/') ? currentPath : `/static/assets/${currentPath}`;
} else {
const matchedAsset = state.assetsData?.find(a => {
const searchType = (a.type || '').toLowerCase();
const searchId = (a.id || '').toLowerCase();
return nodeType === searchType || nodeType === searchId ||
nodeType.includes(searchType) || nodeType.includes(searchId);
});
if (matchedAsset) {
const iconFileName = matchedAsset.path || (matchedAsset.views && matchedAsset.views.icon) || DEFAULTS.DEFAULT_ICON;
assetPath = `/static/assets/${iconFileName}`;
data.assetPath = iconFileName;
}
}
}
// 3. Build Base Configuration
let config = {
id: data.id,
shape: plugin.shape,
position: data.pos ? { x: data.pos.x, y: data.pos.y } : { x: 100, y: 100 },
zIndex: data.zIndex || calculateCellZIndex(data, 0, true),
movable: !data.locked,
deletable: !data.locked,
data: data,
attrs: {
label: {
text: data.label || '',
fill: data.color || data['text-color'] || '#64748b',
...getLabelAttributes(data.label_pos || data['label-pos'], nodeType, data.is_group)
}
}
};
// 4. Plugin Specific Overrides
if (data.is_group) {
config.attrs.body = {
fill: data.background || '#f1f5f9',
stroke: data['border-color'] || '#3b82f6'
};
} else if (plugin.shape === 'drawnet-icon' && plugin.icon) {
config.attrs.icon = {
text: plugin.icon,
fill: data.color || '#64748b'
};
} else if (plugin.shape === 'drawnet-node') {
config.attrs.image = { 'xlink:href': assetPath };
}
// 5. Primitive Styling Persistence
if (primitives.includes(nodeType)) {
if (!config.attrs.body) config.attrs.body = {};
if (data.fill) config.attrs.body.fill = data.fill;
if (data.border) config.attrs.body.stroke = data.border;
}
// Rack specific handling
if (nodeType === 'rack') {
const slots = data.slots || 42;
const height = data.height || 600;
config.data.is_group = true;
config.attrs = {
...config.attrs,
units: { d: generateRackUnitsPath(height, slots) },
label: { text: data.label || `Rack (${slots}U)` }
};
}
// Rich Card specific handling
if (nodeType === 'rich-card' || plugin.shape === 'drawnet-rich-card') {
config.data.headerText = data.headerText || data.label || 'RICH INFO CARD';
config.data.content = data.content || '1. Content goes here...\n2. Support multiple lines';
config.data.cardType = data.cardType || 'numbered';
config.data.headerColor = data.headerColor || '#3b82f6';
config.data.headerAlign = data.headerAlign || 'left';
config.data.contentAlign = data.contentAlign || 'left';
const hAnchor = config.data.headerAlign === 'center' ? 'middle' : (config.data.headerAlign === 'right' ? 'end' : 'start');
const hX = config.data.headerAlign === 'center' ? 0.5 : (config.data.headerAlign === 'right' ? '100%' : 10);
const hX2 = config.data.headerAlign === 'right' ? -10 : 0;
config.attrs = {
...config.attrs,
headerLabel: {
text: config.data.headerText,
textAnchor: hAnchor,
refX: hX,
refX2: hX2
},
header: { fill: config.data.headerColor },
body: { stroke: config.data.headerColor }
};
// Initial render trigger (Hack: X6 might not be ready, so we defer)
setTimeout(() => {
const cell = state.graph.getCellById(config.id);
if (cell) {
import('../utils.js').then(m => m.renderRichContent(cell));
}
}, 50);
}
return config;
}