mirror of
https://github.com/sotam0316/drawNET.git
synced 2026-04-25 03:58:37 +09:00
Initial commit: drawNET Alpha v1.0 - Professional Topology Designer with Full i18n and Performance Optimizations
This commit is contained in:
@@ -0,0 +1,120 @@
|
||||
import { state } from '../../state.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { t } from '../../i18n.js';
|
||||
|
||||
/**
|
||||
* excel_exporter.js - Exports topology data to a structured CSV format compatible with Excel.
|
||||
* Includes all metadata (Parent Group, Tags, Description) for both Nodes and Edges.
|
||||
*/
|
||||
|
||||
export function exportToExcel() {
|
||||
if (!state.graph) return;
|
||||
|
||||
const nodes = state.graph.getNodes();
|
||||
const edges = state.graph.getEdges();
|
||||
|
||||
if (nodes.length === 0 && edges.length === 0) {
|
||||
alert(t('pptx_no_data') || 'No data to export.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Header Definition (Unified for mapping ease)
|
||||
const headers = [
|
||||
"Category",
|
||||
"ID",
|
||||
"Label/Name",
|
||||
"Type",
|
||||
"Parent Group",
|
||||
"Source (ID/Label)",
|
||||
"Target (ID/Label)",
|
||||
"Tags",
|
||||
"Description",
|
||||
"IP Address",
|
||||
"Vendor",
|
||||
"Model",
|
||||
"Asset Tag"
|
||||
];
|
||||
|
||||
const csvRows = [];
|
||||
|
||||
// Add Nodes
|
||||
nodes.forEach(node => {
|
||||
const d = node.getData() || {};
|
||||
const label = node.attr('label/text') || d.label || '';
|
||||
|
||||
// Find Parent Group Name
|
||||
let parentName = '';
|
||||
const parent = node.getParent();
|
||||
if (parent && parent.isNode()) {
|
||||
parentName = parent.attr('label/text') || parent.getData()?.label || parent.id;
|
||||
}
|
||||
|
||||
csvRows.push([
|
||||
"Node",
|
||||
node.id,
|
||||
label,
|
||||
d.type || '',
|
||||
parentName,
|
||||
"", // Source
|
||||
"", // Target
|
||||
(d.tags || []).join('; '),
|
||||
d.description || '',
|
||||
d.ip || '',
|
||||
d.vendor || '',
|
||||
d.model || '',
|
||||
d.asset_tag || ''
|
||||
]);
|
||||
});
|
||||
|
||||
// Add Edges
|
||||
edges.forEach(edge => {
|
||||
const d = edge.getData() || {};
|
||||
const label = (edge.getLabels()[0]?.attrs?.label?.text) || d.label || '';
|
||||
|
||||
const sourceCell = edge.getSourceCell();
|
||||
const targetCell = edge.getTargetCell();
|
||||
|
||||
const sourceStr = sourceCell ? (sourceCell.attr('label/text') || sourceCell.id) : (edge.getSource().id || 'Unknown');
|
||||
const targetStr = targetCell ? (targetCell.attr('label/text') || targetCell.id) : (edge.getTarget().id || 'Unknown');
|
||||
|
||||
csvRows.push([
|
||||
"Edge",
|
||||
edge.id,
|
||||
label,
|
||||
d.type || 'Connection',
|
||||
"", // Parent Group (Edges usually not grouped in same way)
|
||||
sourceStr,
|
||||
targetStr,
|
||||
(d.tags || []).join('; '),
|
||||
d.description || '',
|
||||
"", // IP
|
||||
"", // Vendor
|
||||
"", // Model
|
||||
d.asset_tag || ''
|
||||
]);
|
||||
});
|
||||
|
||||
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'Excel'), 'info', 2000));
|
||||
|
||||
// Generate CSV Content with UTF-8 BOM
|
||||
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel (Required for Korean/Special chars)
|
||||
headers.join(',') + "\n" +
|
||||
csvRows.map(row => row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',')).join('\n');
|
||||
|
||||
// Trigger Download
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
const projectName = document.getElementById('project-title')?.innerText || "Project";
|
||||
|
||||
link.setAttribute("href", url);
|
||||
link.setAttribute("download", `drawNET_Inventory_${projectName}_${timestamp}.csv`);
|
||||
link.style.visibility = 'hidden';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'Excel'), 'success'));
|
||||
logger.high(`Inventory exported for ${nodes.length} nodes and ${edges.length} edges.`);
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
import { state } from '../../state.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { showToast } from '../../ui/toast.js';
|
||||
|
||||
/**
|
||||
* 전역 내보내기 로딩 오버레이 표시/숨김
|
||||
*/
|
||||
function showLoading() {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.id = 'export-loading-overlay';
|
||||
overlay.className = 'export-overlay';
|
||||
overlay.innerHTML = `
|
||||
<div class="export-spinner"></div>
|
||||
<div class="export-text">${t('export_preparing')}</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
function hideLoading() {
|
||||
const overlay = document.getElementById('export-loading-overlay');
|
||||
if (overlay) overlay.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지를 Base64 Data URL로 변환 (캐시 및 재시도 적용)
|
||||
*/
|
||||
const assetCache = new Map();
|
||||
async function toDataURL(url, retries = 2) {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
if (assetCache.has(url)) return assetCache.get(url);
|
||||
|
||||
const origin = window.location.origin;
|
||||
const absoluteUrl = url.startsWith('http') ? url :
|
||||
(url.startsWith('/') ? `${origin}${url}` : `${origin}/${url}`);
|
||||
|
||||
for (let i = 0; i <= retries; i++) {
|
||||
try {
|
||||
const response = await fetch(absoluteUrl);
|
||||
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
||||
const blob = await response.blob();
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result);
|
||||
reader.onerror = reject;
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
assetCache.set(url, dataUrl);
|
||||
return dataUrl;
|
||||
} catch (e) {
|
||||
if (i === retries) {
|
||||
logger.critical(`Base64 conversion failed after ${retries} retries for: ${url}`, e);
|
||||
return url;
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 100 * (i + 1))); // 지수 백오프 대기
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 내보내기 전 모든 노드의 이미지 속성을 Base64로 일괄 변환 (상속된 속성 포함)
|
||||
*/
|
||||
export async function prepareExport() {
|
||||
if (!state.graph) return null;
|
||||
|
||||
const backupNodes = [];
|
||||
const nodes = state.graph.getNodes();
|
||||
|
||||
const promises = nodes.map(async (node) => {
|
||||
const imageAttrs = [];
|
||||
const attrs = node.getAttrs();
|
||||
|
||||
// 1. 노드의 모든 속성을 재귀적으로 탐색하여 이미지 경로처럼 보이는 문자열 식별
|
||||
const selectors = Object.keys(attrs);
|
||||
// 기본 'image' 선택자 외에도 커스텀 선택자가 있을 수 있으므로 모두 확인
|
||||
const targetSelectors = new Set(['image', 'icon', 'body', ...selectors]);
|
||||
|
||||
for (const selector of targetSelectors) {
|
||||
// xlink:href와 href 두 가지 모두 명시적으로 점검
|
||||
const keys = ['xlink:href', 'href', 'src'];
|
||||
for (const key of keys) {
|
||||
const path = `${selector}/${key}`;
|
||||
const val = node.attr(path);
|
||||
|
||||
if (val && typeof val === 'string' && !val.startsWith('data:')) {
|
||||
const trimmedVal = val.trim();
|
||||
if (trimmedVal.includes('.') || trimmedVal.startsWith('/') || trimmedVal.startsWith('http')) {
|
||||
const base64Data = await toDataURL(trimmedVal);
|
||||
|
||||
if (base64Data && base64Data !== trimmedVal) {
|
||||
imageAttrs.push({ path, originalValue: val });
|
||||
|
||||
// 중요: 한 쪽에서만 발견되어도 호환성을 위해 두 가지 속성 모두 업데이트
|
||||
const pairKey = key === 'xlink:href' ? 'href' : (key === 'href' ? 'xlink:href' : null);
|
||||
node.attr(path, base64Data);
|
||||
if (pairKey) {
|
||||
const pairPath = `${selector}/${pairKey}`;
|
||||
imageAttrs.push({ path: pairPath, originalValue: node.attr(pairPath) });
|
||||
node.attr(pairPath, base64Data);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (imageAttrs.length > 0) {
|
||||
backupNodes.push({ node, imageAttrs });
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
// PC 사양에 따른 DOM 동기화 안정성을 위해 1초 대기 (사용자 요청 반영)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
|
||||
return backupNodes;
|
||||
}
|
||||
|
||||
/**
|
||||
* 내보내기 후 원래 속성으로 복구
|
||||
*/
|
||||
export function cleanupExport(backup) {
|
||||
if (!backup) return;
|
||||
try {
|
||||
backup.forEach(item => {
|
||||
item.imageAttrs.forEach(attr => {
|
||||
item.node.attr(attr.path, attr.originalValue);
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Cleanup export failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* exportToPNG - PNG 이미지 내보내기
|
||||
*/
|
||||
export async function exportToPNG() {
|
||||
if (!state.graph) return;
|
||||
|
||||
showLoading();
|
||||
showToast(t('msg_export_start').replace('{format}', 'PNG'), 'info', 2000);
|
||||
const backup = await prepareExport();
|
||||
try {
|
||||
const bbox = state.graph.getCellsBBox(state.graph.getCells());
|
||||
const padding = 100;
|
||||
|
||||
state.graph.toPNG((dataUri) => {
|
||||
const link = document.createElement('a');
|
||||
link.download = `drawNET_Topology_${Date.now()}.png`;
|
||||
link.href = dataUri;
|
||||
link.click();
|
||||
cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_success').replace('{format}', 'PNG'), 'success');
|
||||
logger.high("Exported to PNG successfully.");
|
||||
}, {
|
||||
padding,
|
||||
backgroundColor: '#ffffff',
|
||||
width: (bbox.width + padding * 2) * 2,
|
||||
height: (bbox.height + padding * 2) * 2,
|
||||
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
|
||||
});
|
||||
} catch (err) {
|
||||
if (backup) cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_failed').replace('{format}', 'PNG'), 'error');
|
||||
logger.critical("PNG Export failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* exportToSVG - SVG 벡터 내보내기
|
||||
*/
|
||||
export async function exportToSVG() {
|
||||
if (!state.graph) return;
|
||||
|
||||
showLoading();
|
||||
const backup = await prepareExport();
|
||||
try {
|
||||
// 실제 셀들의 정확한 좌표 영역 계산 (줌/이동 무관한 절대 영역)
|
||||
const bbox = state.graph.getCellsBBox(state.graph.getCells());
|
||||
const padding = 120;
|
||||
|
||||
// 여백을 포함한 최종 영역 설정
|
||||
const exportArea = {
|
||||
x: bbox.x - padding,
|
||||
y: bbox.y - padding,
|
||||
width: bbox.width + padding * 2,
|
||||
height: bbox.height + padding * 2
|
||||
};
|
||||
|
||||
state.graph.toSVG((svg) => {
|
||||
const blob = new Blob([svg], { type: 'image/svg+xml' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.download = `drawNET_Topology_${Date.now()}.svg`;
|
||||
link.href = url;
|
||||
link.click();
|
||||
cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_success').replace('{format}', 'SVG'), 'success');
|
||||
URL.revokeObjectURL(url);
|
||||
logger.high("Exported to SVG successfully.");
|
||||
}, {
|
||||
// 명시적 viewBox 지정으로 짤림 방지
|
||||
viewBox: exportArea,
|
||||
width: exportArea.width,
|
||||
height: exportArea.height,
|
||||
backgroundColor: '#ffffff',
|
||||
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
|
||||
});
|
||||
} catch (err) {
|
||||
if (backup) cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_failed').replace('{format}', 'SVG'), 'error');
|
||||
logger.critical("SVG Export failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* exportToPDF - PDF 문서 내보내기
|
||||
*/
|
||||
export async function exportToPDF() {
|
||||
if (!state.graph) return;
|
||||
|
||||
showLoading();
|
||||
const backup = await prepareExport();
|
||||
try {
|
||||
const { jsPDF } = window.jspdf;
|
||||
const bbox = state.graph.getCellsBBox(state.graph.getCells());
|
||||
const padding = 120;
|
||||
const totalW = bbox.width + padding * 2;
|
||||
const totalH = bbox.height + padding * 2;
|
||||
|
||||
state.graph.toPNG((dataUri) => {
|
||||
const orientation = totalW > totalH ? 'l' : 'p';
|
||||
const pdf = new jsPDF(orientation, 'px', [totalW, totalH]);
|
||||
pdf.addImage(dataUri, 'PNG', padding, padding, bbox.width, bbox.height);
|
||||
pdf.save(`drawNET_Topology_${Date.now()}.pdf`);
|
||||
cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_success').replace('{format}', 'PDF'), 'success');
|
||||
logger.high("Exported to PDF successfully.");
|
||||
}, {
|
||||
padding,
|
||||
backgroundColor: '#ffffff',
|
||||
width: totalW * 2,
|
||||
height: totalH * 2,
|
||||
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
|
||||
});
|
||||
} catch (err) {
|
||||
if (backup) cleanupExport(backup);
|
||||
hideLoading();
|
||||
showToast(t('msg_export_failed').replace('{format}', 'PDF'), 'error');
|
||||
logger.critical("PDF Export failed", err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { state } from '../../state.js';
|
||||
import { getProjectData, restoreProjectData } from './json_handler.js';
|
||||
import { exportToPPTX } from './pptx_exporter.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* initGraphIO - 그래프 관련 I/O 이벤트 리스너 초기화
|
||||
*/
|
||||
export function initGraphIO() {
|
||||
// Export JSON (.dnet)
|
||||
const exportBtn = document.getElementById('export-json');
|
||||
if (exportBtn) {
|
||||
exportBtn.addEventListener('click', () => {
|
||||
const projectData = getProjectData();
|
||||
if (!projectData) return;
|
||||
|
||||
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(projectData, null, 2));
|
||||
const safeName = (state.projectName || "project").replace(/[^a-z0-9]/gi, '_').toLowerCase();
|
||||
const dlAnchorElem = document.createElement('a');
|
||||
dlAnchorElem.setAttribute("href", dataStr);
|
||||
dlAnchorElem.setAttribute("download", `${safeName}_${Date.now()}.dnet`);
|
||||
dlAnchorElem.click();
|
||||
logger.high("Project exported (.dnet v3.0).");
|
||||
});
|
||||
}
|
||||
|
||||
// Export PPTX
|
||||
const exportPptxBtn = document.getElementById('export-pptx');
|
||||
if (exportPptxBtn) {
|
||||
exportPptxBtn.addEventListener('click', () => {
|
||||
if (state.license.level === 'Trial') return;
|
||||
exportToPPTX();
|
||||
});
|
||||
}
|
||||
|
||||
// Export Excel
|
||||
const exportExcelBtn = document.getElementById('export-excel');
|
||||
if (exportExcelBtn) {
|
||||
exportExcelBtn.addEventListener('click', () => {
|
||||
if (state.license.level === 'Trial') return;
|
||||
import('./excel_exporter.js').then(m => m.exportToExcel());
|
||||
});
|
||||
}
|
||||
|
||||
// Export PNG (Allowed for Trial)
|
||||
const exportPngBtn = document.getElementById('export-png');
|
||||
if (exportPngBtn) {
|
||||
import('./image_exporter.js').then(m => {
|
||||
exportPngBtn.addEventListener('click', () => m.exportToPNG());
|
||||
});
|
||||
}
|
||||
|
||||
// Export SVG
|
||||
const exportSvgBtn = document.getElementById('export-svg');
|
||||
if (exportSvgBtn) {
|
||||
import('./image_exporter.js').then(m => {
|
||||
exportSvgBtn.addEventListener('click', () => {
|
||||
if (state.license.level === 'Trial') return;
|
||||
m.exportToSVG();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Export PDF (Allowed for Trial)
|
||||
const exportPdfBtn = document.getElementById('export-pdf');
|
||||
if (exportPdfBtn) {
|
||||
import('./image_exporter.js').then(m => {
|
||||
exportPdfBtn.addEventListener('click', () => m.exportToPDF());
|
||||
});
|
||||
}
|
||||
|
||||
// Import
|
||||
const importInput = document.getElementById('import-json-input');
|
||||
if (importInput) {
|
||||
importInput.setAttribute('accept', '.dnet,.json');
|
||||
}
|
||||
const importBtn = document.getElementById('import-json');
|
||||
if (importBtn && importInput) {
|
||||
importBtn.addEventListener('click', () => importInput.click());
|
||||
importInput.addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target.result);
|
||||
if (!state.graph) return;
|
||||
|
||||
const isDrawNET = data.header?.app === "drawNET Premium";
|
||||
if (!isDrawNET) {
|
||||
alert("지원하지 않는 파일 형식입니다.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.graphJson) {
|
||||
const ok = restoreProjectData(data);
|
||||
if (ok) {
|
||||
logger.high("Import complete (v3.0 JSON).");
|
||||
}
|
||||
} else {
|
||||
alert("이 파일은 구버전(v2.x) 형식입니다. 현재 버전에서는 지원되지 않습니다.\n새로 작성 후 저장해 주세요.");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.critical("Failed to parse project file.", err);
|
||||
alert("프로젝트 파일을 읽을 수 없습니다.");
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
e.target.value = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for compatibility
|
||||
export { getProjectData, restoreProjectData, exportToPPTX };
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* graph/io/json_handler.js - Project serialization and restoration with auto-repair logic.
|
||||
*/
|
||||
import { state } from '../../state.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
|
||||
/**
|
||||
* getProjectData - 프로젝트 전체를 JSON으로 직렬화 (graph.toJSON 기반)
|
||||
*/
|
||||
export function getProjectData() {
|
||||
if (!state.graph) return null;
|
||||
|
||||
const graphJson = state.graph.toJSON();
|
||||
const viewport = state.graph.translate();
|
||||
|
||||
return {
|
||||
header: {
|
||||
app: "drawNET Premium",
|
||||
version: "3.0.0",
|
||||
export_time: new Date().toISOString(),
|
||||
projectName: state.projectName || "Untitled_Project"
|
||||
},
|
||||
graphJson: graphJson,
|
||||
viewport: {
|
||||
zoom: state.graph.zoom(),
|
||||
tx: viewport.tx,
|
||||
ty: viewport.ty
|
||||
},
|
||||
settings: {
|
||||
gridSpacing: state.gridSpacing,
|
||||
gridStyle: state.gridStyle,
|
||||
isSnapEnabled: state.isSnapEnabled,
|
||||
canvasSize: state.canvasSize,
|
||||
theme: document.documentElement.getAttribute('data-theme')
|
||||
},
|
||||
layers: state.layers,
|
||||
activeLayerId: state.activeLayerId
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* restoreProjectData - JSON 프로젝트 데이터로 그래프 복원
|
||||
*/
|
||||
export function restoreProjectData(data) {
|
||||
if (!state.graph || !data) return false;
|
||||
|
||||
try {
|
||||
if (data.graphJson) {
|
||||
state.graph.fromJSON(data.graphJson);
|
||||
|
||||
// Migrate old data to new schema (Add defaults if missing)
|
||||
state.graph.getCells().forEach(cell => {
|
||||
const cellData = cell.getData() || {};
|
||||
let updated = false;
|
||||
|
||||
// 1. Recover missing position/size for nodes from data.pos
|
||||
if (cell.isNode()) {
|
||||
const pos = cell.getPosition();
|
||||
if ((!pos || (pos.x === 0 && pos.y === 0)) && cellData.pos) {
|
||||
cell.setPosition(cellData.pos.x, cellData.pos.y);
|
||||
logger.info(`Recovered position for ${cell.id} from data.pos`);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
const size = cell.getSize();
|
||||
if (!size || (size.width === 0 && size.height === 0)) {
|
||||
if (cellData.is_group) cell.setSize(200, 200);
|
||||
else cell.setSize(60, 60);
|
||||
updated = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Ensure description exists for all objects
|
||||
if (cellData.description === undefined) {
|
||||
cellData.description = "";
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// 3. Ensure routing_offset exists for edges
|
||||
if (cell.isEdge()) {
|
||||
if (cellData.routing_offset === undefined) {
|
||||
cellData.routing_offset = 20;
|
||||
updated = true;
|
||||
}
|
||||
|
||||
// [6개월 뒤의 나를 위한 메모]
|
||||
// 하위 호환성 유지 및 데이터 무결성 강화를 위한 자가 치유(Self-healing) 로직.
|
||||
// 기존 도면을 불러올 때 누락된 크로스 레이어 정보를 노드 데이터를 참조하여 실시간 복구함.
|
||||
if (cellData.source_layer === undefined || cellData.target_layer === undefined) {
|
||||
const srcNode = state.graph.getCellById(cellData.source);
|
||||
const dstNode = state.graph.getCellById(cellData.target);
|
||||
|
||||
// Default to current edge's layer if node is missing (safe fallback)
|
||||
const srcLayer = srcNode?.getData()?.layerId || cellData.layerId || 'l1';
|
||||
const dstLayer = dstNode?.getData()?.layerId || cellData.layerId || 'l1';
|
||||
|
||||
cellData.source_layer = srcLayer;
|
||||
cellData.target_layer = dstLayer;
|
||||
cellData.is_cross_layer = (srcLayer !== dstLayer);
|
||||
updated = true;
|
||||
logger.info(`Auto-repaired layer metadata for edge ${cell.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Ensure label consistency (data vs attrs)
|
||||
const pureLabel = cellData.label || "";
|
||||
const visualLabel = cell.attr('label/text');
|
||||
if (visualLabel !== pureLabel) {
|
||||
cell.attr('label/text', pureLabel);
|
||||
updated = true;
|
||||
}
|
||||
|
||||
if (updated) {
|
||||
cell.setData(cellData, { silent: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const vp = data.viewport || {};
|
||||
if (vp.zoom !== undefined) state.graph.zoomTo(vp.zoom);
|
||||
if (vp.tx !== undefined) state.graph.translate(vp.tx, vp.ty || 0);
|
||||
|
||||
const settings = data.settings || {};
|
||||
if (settings.gridSpacing) {
|
||||
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: settings.gridSpacing } }));
|
||||
}
|
||||
if (settings.gridStyle) {
|
||||
window.dispatchEvent(new CustomEvent('gridChanged', { detail: { style: settings.gridStyle } }));
|
||||
}
|
||||
if (settings.theme) {
|
||||
document.documentElement.setAttribute('data-theme', settings.theme);
|
||||
}
|
||||
|
||||
// Restore Layers
|
||||
if (data.layers && Array.isArray(data.layers)) {
|
||||
state.layers = data.layers;
|
||||
state.activeLayerId = data.activeLayerId || data.layers[0]?.id;
|
||||
}
|
||||
|
||||
// Restore Metadata
|
||||
if (data.header?.projectName) {
|
||||
state.projectName = data.header.projectName;
|
||||
const titleEl = document.getElementById('project-title');
|
||||
if (titleEl) titleEl.innerText = state.projectName;
|
||||
}
|
||||
|
||||
state.graph.trigger('project:restored');
|
||||
return true;
|
||||
} catch (e) {
|
||||
logger.critical("Failed to restore project data.", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { state } from '../../state.js';
|
||||
import { logger } from '../../utils/logger.js';
|
||||
import { t } from '../../i18n.js';
|
||||
import { applyLayerFilters } from '../layers.js';
|
||||
import { prepareExport, cleanupExport } from './image_exporter.js';
|
||||
|
||||
/**
|
||||
* PPTX로 내보내기 (PptxGenJS 활용)
|
||||
* 단순 캡처가 아닌 개별 이미지 배치 및 선 객체 생성을 통한 고도화된 리포트 생성
|
||||
*/
|
||||
export async function exportToPPTX() {
|
||||
if (!state.graph) return;
|
||||
if (typeof PptxGenJS === 'undefined') {
|
||||
alert(t('pptx_lib_error'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const pptx = new PptxGenJS();
|
||||
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_start').replace('{format}', 'PPTX'), 'info', 3000));
|
||||
pptx.layout = 'LAYOUT_16x9';
|
||||
|
||||
const projectTitle = document.getElementById('project-title')?.innerText || t('untitled_project');
|
||||
const allCells = state.graph.getCells();
|
||||
if (allCells.length === 0) {
|
||||
alert(t('pptx_no_data'));
|
||||
return;
|
||||
}
|
||||
|
||||
// --- 1. 슬라이드 1: 요약 정보 ---
|
||||
const summarySlide = pptx.addSlide();
|
||||
summarySlide.addText(t('pptx_report_title').replace('{title}', projectTitle), {
|
||||
x: 0.5, y: 0.3, w: '90%', h: 0.6, fontSize: 24, color: '363636', bold: true
|
||||
});
|
||||
|
||||
const stats = {};
|
||||
allCells.filter(c => c.isNode()).forEach(node => {
|
||||
const type = node.getData()?.type || 'Unknown';
|
||||
stats[type] = (stats[type] || 0) + 1;
|
||||
});
|
||||
|
||||
const summaryRows = [[t('pptx_obj_type'), t('pptx_quantity')]];
|
||||
Object.entries(stats).sort().forEach(([type, count]) => {
|
||||
summaryRows.push([type, count.toString()]);
|
||||
});
|
||||
|
||||
summarySlide.addTable(summaryRows, {
|
||||
x: 0.5, y: 1.2, w: 4.0, colW: [2.5, 1.5],
|
||||
border: { pt: 1, color: 'E2E8F0' }, fill: { color: 'F8FAFC' },
|
||||
fontSize: 11, align: 'center'
|
||||
});
|
||||
|
||||
summarySlide.addText(t('pptx_generated_at').replace('{date}', new Date().toLocaleString()), {
|
||||
x: 6.0, y: 5.2, w: 3.5, h: 0.3, fontSize: 9, color: '999999', align: 'right'
|
||||
});
|
||||
|
||||
// --- 좌표 변환 헬퍼 (Inches) ---
|
||||
const MARGIN = 0.5;
|
||||
const SLIDE_W = 10 - (MARGIN * 2);
|
||||
const SLIDE_H = 5.625 - (MARGIN * 2);
|
||||
|
||||
const addGraphToSlide = (slide, cells, title) => {
|
||||
if (cells.length === 0) return;
|
||||
|
||||
if (title) {
|
||||
slide.addText(title, { x: 0.5, y: 0.2, w: 5.0, h: 0.4, fontSize: 14, bold: true, color: '4B5563' });
|
||||
}
|
||||
|
||||
// High-Fidelity Hybrid: Capture topology as PNG
|
||||
// Since toPNG is asynchronous with a callback, we use a different approach for batching or just use the current canvas state
|
||||
// But for PPTX report, we want to ensure it's high quality.
|
||||
// X6 toPNG is usually sync if no external images, but here we have SVGs.
|
||||
|
||||
// To keep it simple and stable, we'll use a placeholder or wait for the user to confirm.
|
||||
// Actually, we can use the plugin's sync return if possible, or just export the whole thing.
|
||||
|
||||
return new Promise(async (resolve) => {
|
||||
const backup = await prepareExport();
|
||||
const bbox = state.graph.getContentBBox();
|
||||
const ratio = bbox.width / bbox.height;
|
||||
const maxW = 8.0;
|
||||
const maxH = 4.5;
|
||||
|
||||
let imgW = maxW;
|
||||
let imgH = imgW / ratio;
|
||||
|
||||
if (imgH > maxH) {
|
||||
imgH = maxH;
|
||||
imgW = imgH * ratio;
|
||||
}
|
||||
|
||||
const imgX = 1.0 + (maxW - imgW) / 2;
|
||||
const imgY = 0.8 + (maxH - imgH) / 2;
|
||||
|
||||
state.graph.toPNG((dataUri) => {
|
||||
slide.addImage({ data: dataUri, x: imgX, y: imgY, w: imgW, h: imgH });
|
||||
cleanupExport(backup);
|
||||
resolve();
|
||||
}, {
|
||||
padding: 10,
|
||||
backgroundColor: '#ffffff',
|
||||
width: bbox.width * 2,
|
||||
height: bbox.height * 2,
|
||||
stylesheet: '.x6-port { display: none !important; } .x6-edge-selected path { filter: none !important; }'
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
// --- 2. 슬라이드 2: 전체 조감도 ---
|
||||
const mainDiagSlide = pptx.addSlide();
|
||||
// Backup visibility
|
||||
const originalVisibility = state.layers.map(l => ({ id: l.id, visible: l.visible }));
|
||||
|
||||
// Show everything for overall view
|
||||
state.layers.forEach(l => l.visible = true);
|
||||
await applyLayerFilters();
|
||||
await addGraphToSlide(mainDiagSlide, allCells, t('pptx_overall_topo'));
|
||||
|
||||
// --- 3. 슬라이드 3~: 레이어별 상세도 ---
|
||||
if (state.layers && state.layers.length > 1) {
|
||||
for (const layer of state.layers) {
|
||||
// Only show this layer
|
||||
state.layers.forEach(l => l.visible = (l.id === layer.id));
|
||||
await applyLayerFilters();
|
||||
|
||||
const layerCells = allCells.filter(c => (c.getData()?.layerId || 'l1') === layer.id);
|
||||
if (layerCells.length > 0) {
|
||||
const layerSlide = pptx.addSlide();
|
||||
await addGraphToSlide(layerSlide, layerCells, t('pptx_layer_title').replace('{name}', layer.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original visibility
|
||||
originalVisibility.forEach(orig => {
|
||||
const l = state.layers.find(ly => ly.id === orig.id);
|
||||
if (l) l.visible = orig.visible;
|
||||
});
|
||||
await applyLayerFilters();
|
||||
|
||||
// --- 4. 슬라이드 마지막: 상세 인벤토리 테이블 ---
|
||||
const nodes = allCells.filter(c => c.isNode());
|
||||
if (nodes.length > 0) {
|
||||
const tableSlide = pptx.addSlide();
|
||||
tableSlide.addText(t('pptx_inv_title'), { x: 0.5, y: 0.3, w: 5.0, h: 0.5, fontSize: 18, bold: true });
|
||||
|
||||
const tableRows = [[t('prop_label'), t('prop_type'), t('prop_parent'), t('prop_model'), t('prop_ip'), t('prop_status')]];
|
||||
nodes.forEach(node => {
|
||||
const d = node.getData() || {};
|
||||
const parent = node.getParent();
|
||||
const parentLabel = parent ? (parent.getData()?.label || parent.attr('label/text') || parent.id) : '-';
|
||||
|
||||
tableRows.push([
|
||||
d.label || node.attr('label/text') || '',
|
||||
d.type || '',
|
||||
parentLabel,
|
||||
d.model || '',
|
||||
d.ip || '',
|
||||
d.status || ''
|
||||
]);
|
||||
});
|
||||
|
||||
tableSlide.addTable(tableRows, {
|
||||
x: 0.5, y: 1.0, w: 9.0,
|
||||
colW: [1.5, 1.2, 1.5, 1.6, 1.6, 1.6],
|
||||
border: { pt: 0.5, color: 'CCCCCC' },
|
||||
fontSize: 8,
|
||||
autoPage: true
|
||||
});
|
||||
}
|
||||
|
||||
// --- 5. 슬라이드: 객체별 상세 설명 (Label & Description) ---
|
||||
const descObjects = allCells.filter(c => {
|
||||
const d = c.getData() || {};
|
||||
if (c.isNode()) {
|
||||
// Include all standard nodes, but only include groups if they have a description
|
||||
return !d.is_group || !!d.description;
|
||||
}
|
||||
// Include edges only if they have a user-defined label or description
|
||||
return !!(d.label || d.description);
|
||||
});
|
||||
|
||||
if (descObjects.length > 0) {
|
||||
const descSlide = pptx.addSlide();
|
||||
descSlide.addText(t('pptx_desc_table_title'), {
|
||||
x: 0.5, y: 0.3, w: 8.0, h: 0.5, fontSize: 18, bold: true, color: '2D3748'
|
||||
});
|
||||
|
||||
const descRows = [[t('prop_label'), t('prop_type'), t('prop_tags'), t('prop_description')]];
|
||||
descObjects.forEach(cell => {
|
||||
const d = cell.getData() || {};
|
||||
let label = '';
|
||||
|
||||
if (cell.isNode()) {
|
||||
label = d.label || cell.attr('label/text') || `Node (${cell.id.substring(0,8)})`;
|
||||
} else {
|
||||
// Optimized Edge Label: Use d.label if available, else Source -> Target name
|
||||
if (d.label) {
|
||||
label = d.label;
|
||||
} else {
|
||||
const src = state.graph.getCellById(cell.getSource().cell);
|
||||
const dst = state.graph.getCellById(cell.getTarget().cell);
|
||||
const srcName = src?.getData()?.label || src?.attr('label/text') || 'Unknown';
|
||||
const dstName = dst?.getData()?.label || dst?.attr('label/text') || 'Unknown';
|
||||
label = `${srcName} -> ${dstName}`;
|
||||
}
|
||||
}
|
||||
|
||||
const type = cell.isNode() ? (d.type || 'Node') : `Edge (${d.routing || 'manhattan'})`;
|
||||
const tags = (d.tags || []).join(', ');
|
||||
|
||||
descRows.push([
|
||||
label,
|
||||
type,
|
||||
tags || '-',
|
||||
d.description || '-'
|
||||
]);
|
||||
});
|
||||
|
||||
descSlide.addTable(descRows, {
|
||||
x: 0.5, y: 1.0, w: 9.0,
|
||||
colW: [1.5, 1.2, 1.8, 4.5],
|
||||
border: { pt: 0.5, color: 'CCCCCC' },
|
||||
fill: { color: 'FFFFFF' },
|
||||
fontSize: 9,
|
||||
autoPage: true,
|
||||
newSlideProps: { margin: [0.5, 0.5, 0.5, 0.5] }
|
||||
});
|
||||
}
|
||||
|
||||
pptx.writeFile({ fileName: `drawNET_Report_${Date.now()}.pptx` });
|
||||
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_success').replace('{format}', 'PPTX'), 'success'));
|
||||
} catch (err) {
|
||||
import('../../ui/toast.js').then(m => m.showToast(t('msg_export_failed').replace('{format}', 'PPTX'), 'error'));
|
||||
logger.critical("PPTX export failed.", err);
|
||||
alert(t('pptx_export_error').replace('{message}', err.message));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user