static 폴더 및 하위 파일 업로드

This commit is contained in:
sotam0316
2026-04-22 12:05:03 +09:00
commit 514b209a5a
203 changed files with 29494 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
import { initAssets } from './modules/assets/index.js';
import { initGraph } from './modules/graph.js';
import { initUIToggles } from './modules/ui.js';
import { initSettings } from './modules/settings/ui.js';
import { applySavedSettings, applySavedTheme } from './modules/settings/store.js';
import { initHotkeys } from './modules/hotkeys/index.js';
import { initGlobalSearch } from './modules/graph/search.js';
import { initInventory } from './modules/graph/analysis.js';
import { initLayerPanel } from './modules/ui/layer_panel.js';
import { applyLayerFilters } from './modules/graph/layers.js';
import { initI18n } from './modules/i18n.js';
import { initPropertiesSidebar } from './modules/properties_sidebar/index.js';
import { initPersistence } from './modules/persistence.js';
import { initGraphIO } from './modules/graph/io/index.js';
import { initContextMenu } from './modules/ui/context_menu/index.js';
import { logger } from './modules/utils/logger.js';
document.addEventListener('DOMContentLoaded', async () => {
// 0. Initialize i18n (Language first)
await initI18n();
// 1. Apply Saved Theme immediately (prevent flash)
applySavedTheme();
// 2. Load Assets & Sidebar
await initAssets();
// 2.5 Load Global Config
try {
const configRes = await fetch('/api/config');
if (configRes.ok) {
const configData = await configRes.json();
const { state } = await import('./modules/state.js');
state.appConfig = configData;
logger.info("Global configuration loaded from server.");
}
} catch (err) {
logger.high("Failed to load global config, using defaults.");
}
// 3. Initialize X6 Graph
initGraph();
// 4. Initialize UI Toggles
initUIToggles();
// 5. Initialize Settings Modal Logic
initSettings();
// 6. Apply Saved Visual Settings (Grid, Size)
applySavedSettings();
// 7. Initialize Keyboard Hotkeys
initHotkeys();
// 8. Initialize UI Components & Persistence
initPropertiesSidebar();
initContextMenu();
initGraphIO();
initGlobalSearch();
initInventory();
initLayerPanel();
applyLayerFilters();
initPersistence();
// Help Button
const helpBtn = document.getElementById('help-btn');
if (helpBtn) {
helpBtn.addEventListener('click', async () => {
const { showHelpModal } = await import('./modules/ui/help_modal.js');
showHelpModal();
});
}
logger.high("drawNET Premium initialized successfully.");
});
+398
View File
File diff suppressed because one or more lines are too long
+140
View File
@@ -0,0 +1,140 @@
/**
* @license
* Lodash <https://lodash.com/>
* Copyright OpenJS Foundation and other contributors <https://openjsf.org/>
* Released under MIT license <https://lodash.com/license>
* Based on Underscore.js 1.8.3 <http://underscorejs.org/LICENSE>
* Copyright Jeremy Ashkenas, DocumentCloud and Investigative Reporters & Editors
*/
(function(){function n(n,t,r){switch(r.length){case 0:return n.call(t);case 1:return n.call(t,r[0]);case 2:return n.call(t,r[0],r[1]);case 3:return n.call(t,r[0],r[1],r[2])}return n.apply(t,r)}function t(n,t,r,e){for(var u=-1,i=null==n?0:n.length;++u<i;){var o=n[u];t(e,o,r(o),n)}return e}function r(n,t){for(var r=-1,e=null==n?0:n.length;++r<e&&t(n[r],r,n)!==!1;);return n}function e(n,t){for(var r=null==n?0:n.length;r--&&t(n[r],r,n)!==!1;);return n}function u(n,t){for(var r=-1,e=null==n?0:n.length;++r<e;)if(!t(n[r],r,n))return!1;
return!0}function i(n,t){for(var r=-1,e=null==n?0:n.length,u=0,i=[];++r<e;){var o=n[r];t(o,r,n)&&(i[u++]=o)}return i}function o(n,t){return!!(null==n?0:n.length)&&y(n,t,0)>-1}function f(n,t,r){for(var e=-1,u=null==n?0:n.length;++e<u;)if(r(t,n[e]))return!0;return!1}function c(n,t){for(var r=-1,e=null==n?0:n.length,u=Array(e);++r<e;)u[r]=t(n[r],r,n);return u}function a(n,t){for(var r=-1,e=t.length,u=n.length;++r<e;)n[u+r]=t[r];return n}function l(n,t,r,e){var u=-1,i=null==n?0:n.length;for(e&&i&&(r=n[++u]);++u<i;)r=t(r,n[u],u,n);
return r}function s(n,t,r,e){var u=null==n?0:n.length;for(e&&u&&(r=n[--u]);u--;)r=t(r,n[u],u,n);return r}function h(n,t){for(var r=-1,e=null==n?0:n.length;++r<e;)if(t(n[r],r,n))return!0;return!1}function p(n){return n.split("")}function _(n){return n.match($t)||[]}function v(n,t,r){var e;return r(n,function(n,r,u){if(t(n,r,u))return e=r,!1}),e}function g(n,t,r,e){for(var u=n.length,i=r+(e?1:-1);e?i--:++i<u;)if(t(n[i],i,n))return i;return-1}function y(n,t,r){return t===t?Z(n,t,r):g(n,b,r)}function d(n,t,r,e){
for(var u=r-1,i=n.length;++u<i;)if(e(n[u],t))return u;return-1}function b(n){return n!==n}function w(n,t){var r=null==n?0:n.length;return r?k(n,t)/r:Cn}function m(n){return function(t){return null==t?X:t[n]}}function x(n){return function(t){return null==n?X:n[t]}}function j(n,t,r,e,u){return u(n,function(n,u,i){r=e?(e=!1,n):t(r,n,u,i)}),r}function A(n,t){var r=n.length;for(n.sort(t);r--;)n[r]=n[r].value;return n}function k(n,t){for(var r,e=-1,u=n.length;++e<u;){var i=t(n[e]);i!==X&&(r=r===X?i:r+i);
}return r}function O(n,t){for(var r=-1,e=Array(n);++r<n;)e[r]=t(r);return e}function I(n,t){return c(t,function(t){return[t,n[t]]})}function R(n){return n?n.slice(0,H(n)+1).replace(Lt,""):n}function z(n){return function(t){return n(t)}}function E(n,t){return c(t,function(t){return n[t]})}function S(n,t){return n.has(t)}function W(n,t){for(var r=-1,e=n.length;++r<e&&y(t,n[r],0)>-1;);return r}function L(n,t){for(var r=n.length;r--&&y(t,n[r],0)>-1;);return r}function C(n,t){for(var r=n.length,e=0;r--;)n[r]===t&&++e;
return e}function U(n){return"\\"+Yr[n]}function B(n,t){return null==n?X:n[t]}function T(n){return Nr.test(n)}function $(n){return Pr.test(n)}function D(n){for(var t,r=[];!(t=n.next()).done;)r.push(t.value);return r}function M(n){var t=-1,r=Array(n.size);return n.forEach(function(n,e){r[++t]=[e,n]}),r}function F(n,t){return function(r){return n(t(r))}}function N(n,t){for(var r=-1,e=n.length,u=0,i=[];++r<e;){var o=n[r];o!==t&&o!==cn||(n[r]=cn,i[u++]=r)}return i}function P(n){var t=-1,r=Array(n.size);
return n.forEach(function(n){r[++t]=n}),r}function q(n){var t=-1,r=Array(n.size);return n.forEach(function(n){r[++t]=[n,n]}),r}function Z(n,t,r){for(var e=r-1,u=n.length;++e<u;)if(n[e]===t)return e;return-1}function K(n,t,r){for(var e=r+1;e--;)if(n[e]===t)return e;return e}function V(n){return T(n)?J(n):_e(n)}function G(n){return T(n)?Y(n):p(n)}function H(n){for(var t=n.length;t--&&Ct.test(n.charAt(t)););return t}function J(n){for(var t=Mr.lastIndex=0;Mr.test(n);)++t;return t}function Y(n){return n.match(Mr)||[];
}function Q(n){return n.match(Fr)||[]}var X,nn="4.17.21",tn=200,rn="Unsupported core-js use. Try https://npms.io/search?q=ponyfill.",en="Expected a function",un="Invalid `variable` option passed into `_.template`",on="__lodash_hash_undefined__",fn=500,cn="__lodash_placeholder__",an=1,ln=2,sn=4,hn=1,pn=2,_n=1,vn=2,gn=4,yn=8,dn=16,bn=32,wn=64,mn=128,xn=256,jn=512,An=30,kn="...",On=800,In=16,Rn=1,zn=2,En=3,Sn=1/0,Wn=9007199254740991,Ln=1.7976931348623157e308,Cn=NaN,Un=4294967295,Bn=Un-1,Tn=Un>>>1,$n=[["ary",mn],["bind",_n],["bindKey",vn],["curry",yn],["curryRight",dn],["flip",jn],["partial",bn],["partialRight",wn],["rearg",xn]],Dn="[object Arguments]",Mn="[object Array]",Fn="[object AsyncFunction]",Nn="[object Boolean]",Pn="[object Date]",qn="[object DOMException]",Zn="[object Error]",Kn="[object Function]",Vn="[object GeneratorFunction]",Gn="[object Map]",Hn="[object Number]",Jn="[object Null]",Yn="[object Object]",Qn="[object Promise]",Xn="[object Proxy]",nt="[object RegExp]",tt="[object Set]",rt="[object String]",et="[object Symbol]",ut="[object Undefined]",it="[object WeakMap]",ot="[object WeakSet]",ft="[object ArrayBuffer]",ct="[object DataView]",at="[object Float32Array]",lt="[object Float64Array]",st="[object Int8Array]",ht="[object Int16Array]",pt="[object Int32Array]",_t="[object Uint8Array]",vt="[object Uint8ClampedArray]",gt="[object Uint16Array]",yt="[object Uint32Array]",dt=/\b__p \+= '';/g,bt=/\b(__p \+=) '' \+/g,wt=/(__e\(.*?\)|\b__t\)) \+\n'';/g,mt=/&(?:amp|lt|gt|quot|#39);/g,xt=/[&<>"']/g,jt=RegExp(mt.source),At=RegExp(xt.source),kt=/<%-([\s\S]+?)%>/g,Ot=/<%([\s\S]+?)%>/g,It=/<%=([\s\S]+?)%>/g,Rt=/\.|\[(?:[^[\]]*|(["'])(?:(?!\1)[^\\]|\\.)*?\1)\]/,zt=/^\w*$/,Et=/[^.[\]]+|\[(?:(-?\d+(?:\.\d+)?)|(["'])((?:(?!\2)[^\\]|\\.)*?)\2)\]|(?=(?:\.|\[\])(?:\.|\[\]|$))/g,St=/[\\^$.*+?()[\]{}|]/g,Wt=RegExp(St.source),Lt=/^\s+/,Ct=/\s/,Ut=/\{(?:\n\/\* \[wrapped with .+\] \*\/)?\n?/,Bt=/\{\n\/\* \[wrapped with (.+)\] \*/,Tt=/,? & /,$t=/[^\x00-\x2f\x3a-\x40\x5b-\x60\x7b-\x7f]+/g,Dt=/[()=,{}\[\]\/\s]/,Mt=/\\(\\)?/g,Ft=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,Nt=/\w*$/,Pt=/^[-+]0x[0-9a-f]+$/i,qt=/^0b[01]+$/i,Zt=/^\[object .+?Constructor\]$/,Kt=/^0o[0-7]+$/i,Vt=/^(?:0|[1-9]\d*)$/,Gt=/[\xc0-\xd6\xd8-\xf6\xf8-\xff\u0100-\u017f]/g,Ht=/($^)/,Jt=/['\n\r\u2028\u2029\\]/g,Yt="\\ud800-\\udfff",Qt="\\u0300-\\u036f",Xt="\\ufe20-\\ufe2f",nr="\\u20d0-\\u20ff",tr=Qt+Xt+nr,rr="\\u2700-\\u27bf",er="a-z\\xdf-\\xf6\\xf8-\\xff",ur="\\xac\\xb1\\xd7\\xf7",ir="\\x00-\\x2f\\x3a-\\x40\\x5b-\\x60\\x7b-\\xbf",or="\\u2000-\\u206f",fr=" \\t\\x0b\\f\\xa0\\ufeff\\n\\r\\u2028\\u2029\\u1680\\u180e\\u2000\\u2001\\u2002\\u2003\\u2004\\u2005\\u2006\\u2007\\u2008\\u2009\\u200a\\u202f\\u205f\\u3000",cr="A-Z\\xc0-\\xd6\\xd8-\\xde",ar="\\ufe0e\\ufe0f",lr=ur+ir+or+fr,sr="['\u2019]",hr="["+Yt+"]",pr="["+lr+"]",_r="["+tr+"]",vr="\\d+",gr="["+rr+"]",yr="["+er+"]",dr="[^"+Yt+lr+vr+rr+er+cr+"]",br="\\ud83c[\\udffb-\\udfff]",wr="(?:"+_r+"|"+br+")",mr="[^"+Yt+"]",xr="(?:\\ud83c[\\udde6-\\uddff]){2}",jr="[\\ud800-\\udbff][\\udc00-\\udfff]",Ar="["+cr+"]",kr="\\u200d",Or="(?:"+yr+"|"+dr+")",Ir="(?:"+Ar+"|"+dr+")",Rr="(?:"+sr+"(?:d|ll|m|re|s|t|ve))?",zr="(?:"+sr+"(?:D|LL|M|RE|S|T|VE))?",Er=wr+"?",Sr="["+ar+"]?",Wr="(?:"+kr+"(?:"+[mr,xr,jr].join("|")+")"+Sr+Er+")*",Lr="\\d*(?:1st|2nd|3rd|(?![123])\\dth)(?=\\b|[A-Z_])",Cr="\\d*(?:1ST|2ND|3RD|(?![123])\\dTH)(?=\\b|[a-z_])",Ur=Sr+Er+Wr,Br="(?:"+[gr,xr,jr].join("|")+")"+Ur,Tr="(?:"+[mr+_r+"?",_r,xr,jr,hr].join("|")+")",$r=RegExp(sr,"g"),Dr=RegExp(_r,"g"),Mr=RegExp(br+"(?="+br+")|"+Tr+Ur,"g"),Fr=RegExp([Ar+"?"+yr+"+"+Rr+"(?="+[pr,Ar,"$"].join("|")+")",Ir+"+"+zr+"(?="+[pr,Ar+Or,"$"].join("|")+")",Ar+"?"+Or+"+"+Rr,Ar+"+"+zr,Cr,Lr,vr,Br].join("|"),"g"),Nr=RegExp("["+kr+Yt+tr+ar+"]"),Pr=/[a-z][A-Z]|[A-Z]{2}[a-z]|[0-9][a-zA-Z]|[a-zA-Z][0-9]|[^a-zA-Z0-9 ]/,qr=["Array","Buffer","DataView","Date","Error","Float32Array","Float64Array","Function","Int8Array","Int16Array","Int32Array","Map","Math","Object","Promise","RegExp","Set","String","Symbol","TypeError","Uint8Array","Uint8ClampedArray","Uint16Array","Uint32Array","WeakMap","_","clearTimeout","isFinite","parseInt","setTimeout"],Zr=-1,Kr={};
Kr[at]=Kr[lt]=Kr[st]=Kr[ht]=Kr[pt]=Kr[_t]=Kr[vt]=Kr[gt]=Kr[yt]=!0,Kr[Dn]=Kr[Mn]=Kr[ft]=Kr[Nn]=Kr[ct]=Kr[Pn]=Kr[Zn]=Kr[Kn]=Kr[Gn]=Kr[Hn]=Kr[Yn]=Kr[nt]=Kr[tt]=Kr[rt]=Kr[it]=!1;var Vr={};Vr[Dn]=Vr[Mn]=Vr[ft]=Vr[ct]=Vr[Nn]=Vr[Pn]=Vr[at]=Vr[lt]=Vr[st]=Vr[ht]=Vr[pt]=Vr[Gn]=Vr[Hn]=Vr[Yn]=Vr[nt]=Vr[tt]=Vr[rt]=Vr[et]=Vr[_t]=Vr[vt]=Vr[gt]=Vr[yt]=!0,Vr[Zn]=Vr[Kn]=Vr[it]=!1;var Gr={"\xc0":"A","\xc1":"A","\xc2":"A","\xc3":"A","\xc4":"A","\xc5":"A","\xe0":"a","\xe1":"a","\xe2":"a","\xe3":"a","\xe4":"a","\xe5":"a",
"\xc7":"C","\xe7":"c","\xd0":"D","\xf0":"d","\xc8":"E","\xc9":"E","\xca":"E","\xcb":"E","\xe8":"e","\xe9":"e","\xea":"e","\xeb":"e","\xcc":"I","\xcd":"I","\xce":"I","\xcf":"I","\xec":"i","\xed":"i","\xee":"i","\xef":"i","\xd1":"N","\xf1":"n","\xd2":"O","\xd3":"O","\xd4":"O","\xd5":"O","\xd6":"O","\xd8":"O","\xf2":"o","\xf3":"o","\xf4":"o","\xf5":"o","\xf6":"o","\xf8":"o","\xd9":"U","\xda":"U","\xdb":"U","\xdc":"U","\xf9":"u","\xfa":"u","\xfb":"u","\xfc":"u","\xdd":"Y","\xfd":"y","\xff":"y","\xc6":"Ae",
"\xe6":"ae","\xde":"Th","\xfe":"th","\xdf":"ss","\u0100":"A","\u0102":"A","\u0104":"A","\u0101":"a","\u0103":"a","\u0105":"a","\u0106":"C","\u0108":"C","\u010a":"C","\u010c":"C","\u0107":"c","\u0109":"c","\u010b":"c","\u010d":"c","\u010e":"D","\u0110":"D","\u010f":"d","\u0111":"d","\u0112":"E","\u0114":"E","\u0116":"E","\u0118":"E","\u011a":"E","\u0113":"e","\u0115":"e","\u0117":"e","\u0119":"e","\u011b":"e","\u011c":"G","\u011e":"G","\u0120":"G","\u0122":"G","\u011d":"g","\u011f":"g","\u0121":"g",
"\u0123":"g","\u0124":"H","\u0126":"H","\u0125":"h","\u0127":"h","\u0128":"I","\u012a":"I","\u012c":"I","\u012e":"I","\u0130":"I","\u0129":"i","\u012b":"i","\u012d":"i","\u012f":"i","\u0131":"i","\u0134":"J","\u0135":"j","\u0136":"K","\u0137":"k","\u0138":"k","\u0139":"L","\u013b":"L","\u013d":"L","\u013f":"L","\u0141":"L","\u013a":"l","\u013c":"l","\u013e":"l","\u0140":"l","\u0142":"l","\u0143":"N","\u0145":"N","\u0147":"N","\u014a":"N","\u0144":"n","\u0146":"n","\u0148":"n","\u014b":"n","\u014c":"O",
"\u014e":"O","\u0150":"O","\u014d":"o","\u014f":"o","\u0151":"o","\u0154":"R","\u0156":"R","\u0158":"R","\u0155":"r","\u0157":"r","\u0159":"r","\u015a":"S","\u015c":"S","\u015e":"S","\u0160":"S","\u015b":"s","\u015d":"s","\u015f":"s","\u0161":"s","\u0162":"T","\u0164":"T","\u0166":"T","\u0163":"t","\u0165":"t","\u0167":"t","\u0168":"U","\u016a":"U","\u016c":"U","\u016e":"U","\u0170":"U","\u0172":"U","\u0169":"u","\u016b":"u","\u016d":"u","\u016f":"u","\u0171":"u","\u0173":"u","\u0174":"W","\u0175":"w",
"\u0176":"Y","\u0177":"y","\u0178":"Y","\u0179":"Z","\u017b":"Z","\u017d":"Z","\u017a":"z","\u017c":"z","\u017e":"z","\u0132":"IJ","\u0133":"ij","\u0152":"Oe","\u0153":"oe","\u0149":"'n","\u017f":"s"},Hr={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},Jr={"&amp;":"&","&lt;":"<","&gt;":">","&quot;":'"',"&#39;":"'"},Yr={"\\":"\\","'":"'","\n":"n","\r":"r","\u2028":"u2028","\u2029":"u2029"},Qr=parseFloat,Xr=parseInt,ne="object"==typeof global&&global&&global.Object===Object&&global,te="object"==typeof self&&self&&self.Object===Object&&self,re=ne||te||Function("return this")(),ee="object"==typeof exports&&exports&&!exports.nodeType&&exports,ue=ee&&"object"==typeof module&&module&&!module.nodeType&&module,ie=ue&&ue.exports===ee,oe=ie&&ne.process,fe=function(){
try{var n=ue&&ue.require&&ue.require("util").types;return n?n:oe&&oe.binding&&oe.binding("util")}catch(n){}}(),ce=fe&&fe.isArrayBuffer,ae=fe&&fe.isDate,le=fe&&fe.isMap,se=fe&&fe.isRegExp,he=fe&&fe.isSet,pe=fe&&fe.isTypedArray,_e=m("length"),ve=x(Gr),ge=x(Hr),ye=x(Jr),de=function p(x){function Z(n){if(cc(n)&&!bh(n)&&!(n instanceof Ct)){if(n instanceof Y)return n;if(bl.call(n,"__wrapped__"))return eo(n)}return new Y(n)}function J(){}function Y(n,t){this.__wrapped__=n,this.__actions__=[],this.__chain__=!!t,
this.__index__=0,this.__values__=X}function Ct(n){this.__wrapped__=n,this.__actions__=[],this.__dir__=1,this.__filtered__=!1,this.__iteratees__=[],this.__takeCount__=Un,this.__views__=[]}function $t(){var n=new Ct(this.__wrapped__);return n.__actions__=Tu(this.__actions__),n.__dir__=this.__dir__,n.__filtered__=this.__filtered__,n.__iteratees__=Tu(this.__iteratees__),n.__takeCount__=this.__takeCount__,n.__views__=Tu(this.__views__),n}function Yt(){if(this.__filtered__){var n=new Ct(this);n.__dir__=-1,
n.__filtered__=!0}else n=this.clone(),n.__dir__*=-1;return n}function Qt(){var n=this.__wrapped__.value(),t=this.__dir__,r=bh(n),e=t<0,u=r?n.length:0,i=Oi(0,u,this.__views__),o=i.start,f=i.end,c=f-o,a=e?f:o-1,l=this.__iteratees__,s=l.length,h=0,p=Hl(c,this.__takeCount__);if(!r||!e&&u==c&&p==c)return wu(n,this.__actions__);var _=[];n:for(;c--&&h<p;){a+=t;for(var v=-1,g=n[a];++v<s;){var y=l[v],d=y.iteratee,b=y.type,w=d(g);if(b==zn)g=w;else if(!w){if(b==Rn)continue n;break n}}_[h++]=g}return _}function Xt(n){
var t=-1,r=null==n?0:n.length;for(this.clear();++t<r;){var e=n[t];this.set(e[0],e[1])}}function nr(){this.__data__=is?is(null):{},this.size=0}function tr(n){var t=this.has(n)&&delete this.__data__[n];return this.size-=t?1:0,t}function rr(n){var t=this.__data__;if(is){var r=t[n];return r===on?X:r}return bl.call(t,n)?t[n]:X}function er(n){var t=this.__data__;return is?t[n]!==X:bl.call(t,n)}function ur(n,t){var r=this.__data__;return this.size+=this.has(n)?0:1,r[n]=is&&t===X?on:t,this}function ir(n){
var t=-1,r=null==n?0:n.length;for(this.clear();++t<r;){var e=n[t];this.set(e[0],e[1])}}function or(){this.__data__=[],this.size=0}function fr(n){var t=this.__data__,r=Wr(t,n);return!(r<0)&&(r==t.length-1?t.pop():Ll.call(t,r,1),--this.size,!0)}function cr(n){var t=this.__data__,r=Wr(t,n);return r<0?X:t[r][1]}function ar(n){return Wr(this.__data__,n)>-1}function lr(n,t){var r=this.__data__,e=Wr(r,n);return e<0?(++this.size,r.push([n,t])):r[e][1]=t,this}function sr(n){var t=-1,r=null==n?0:n.length;for(this.clear();++t<r;){
var e=n[t];this.set(e[0],e[1])}}function hr(){this.size=0,this.__data__={hash:new Xt,map:new(ts||ir),string:new Xt}}function pr(n){var t=xi(this,n).delete(n);return this.size-=t?1:0,t}function _r(n){return xi(this,n).get(n)}function vr(n){return xi(this,n).has(n)}function gr(n,t){var r=xi(this,n),e=r.size;return r.set(n,t),this.size+=r.size==e?0:1,this}function yr(n){var t=-1,r=null==n?0:n.length;for(this.__data__=new sr;++t<r;)this.add(n[t])}function dr(n){return this.__data__.set(n,on),this}function br(n){
return this.__data__.has(n)}function wr(n){this.size=(this.__data__=new ir(n)).size}function mr(){this.__data__=new ir,this.size=0}function xr(n){var t=this.__data__,r=t.delete(n);return this.size=t.size,r}function jr(n){return this.__data__.get(n)}function Ar(n){return this.__data__.has(n)}function kr(n,t){var r=this.__data__;if(r instanceof ir){var e=r.__data__;if(!ts||e.length<tn-1)return e.push([n,t]),this.size=++r.size,this;r=this.__data__=new sr(e)}return r.set(n,t),this.size=r.size,this}function Or(n,t){
var r=bh(n),e=!r&&dh(n),u=!r&&!e&&mh(n),i=!r&&!e&&!u&&Oh(n),o=r||e||u||i,f=o?O(n.length,hl):[],c=f.length;for(var a in n)!t&&!bl.call(n,a)||o&&("length"==a||u&&("offset"==a||"parent"==a)||i&&("buffer"==a||"byteLength"==a||"byteOffset"==a)||Ci(a,c))||f.push(a);return f}function Ir(n){var t=n.length;return t?n[tu(0,t-1)]:X}function Rr(n,t){return Xi(Tu(n),Mr(t,0,n.length))}function zr(n){return Xi(Tu(n))}function Er(n,t,r){(r===X||Gf(n[t],r))&&(r!==X||t in n)||Br(n,t,r)}function Sr(n,t,r){var e=n[t];
bl.call(n,t)&&Gf(e,r)&&(r!==X||t in n)||Br(n,t,r)}function Wr(n,t){for(var r=n.length;r--;)if(Gf(n[r][0],t))return r;return-1}function Lr(n,t,r,e){return ys(n,function(n,u,i){t(e,n,r(n),i)}),e}function Cr(n,t){return n&&$u(t,Pc(t),n)}function Ur(n,t){return n&&$u(t,qc(t),n)}function Br(n,t,r){"__proto__"==t&&Tl?Tl(n,t,{configurable:!0,enumerable:!0,value:r,writable:!0}):n[t]=r}function Tr(n,t){for(var r=-1,e=t.length,u=il(e),i=null==n;++r<e;)u[r]=i?X:Mc(n,t[r]);return u}function Mr(n,t,r){return n===n&&(r!==X&&(n=n<=r?n:r),
t!==X&&(n=n>=t?n:t)),n}function Fr(n,t,e,u,i,o){var f,c=t&an,a=t&ln,l=t&sn;if(e&&(f=i?e(n,u,i,o):e(n)),f!==X)return f;if(!fc(n))return n;var s=bh(n);if(s){if(f=zi(n),!c)return Tu(n,f)}else{var h=zs(n),p=h==Kn||h==Vn;if(mh(n))return Iu(n,c);if(h==Yn||h==Dn||p&&!i){if(f=a||p?{}:Ei(n),!c)return a?Mu(n,Ur(f,n)):Du(n,Cr(f,n))}else{if(!Vr[h])return i?n:{};f=Si(n,h,c)}}o||(o=new wr);var _=o.get(n);if(_)return _;o.set(n,f),kh(n)?n.forEach(function(r){f.add(Fr(r,t,e,r,n,o))}):jh(n)&&n.forEach(function(r,u){
f.set(u,Fr(r,t,e,u,n,o))});var v=l?a?di:yi:a?qc:Pc,g=s?X:v(n);return r(g||n,function(r,u){g&&(u=r,r=n[u]),Sr(f,u,Fr(r,t,e,u,n,o))}),f}function Nr(n){var t=Pc(n);return function(r){return Pr(r,n,t)}}function Pr(n,t,r){var e=r.length;if(null==n)return!e;for(n=ll(n);e--;){var u=r[e],i=t[u],o=n[u];if(o===X&&!(u in n)||!i(o))return!1}return!0}function Gr(n,t,r){if("function"!=typeof n)throw new pl(en);return Ws(function(){n.apply(X,r)},t)}function Hr(n,t,r,e){var u=-1,i=o,a=!0,l=n.length,s=[],h=t.length;
if(!l)return s;r&&(t=c(t,z(r))),e?(i=f,a=!1):t.length>=tn&&(i=S,a=!1,t=new yr(t));n:for(;++u<l;){var p=n[u],_=null==r?p:r(p);if(p=e||0!==p?p:0,a&&_===_){for(var v=h;v--;)if(t[v]===_)continue n;s.push(p)}else i(t,_,e)||s.push(p)}return s}function Jr(n,t){var r=!0;return ys(n,function(n,e,u){return r=!!t(n,e,u)}),r}function Yr(n,t,r){for(var e=-1,u=n.length;++e<u;){var i=n[e],o=t(i);if(null!=o&&(f===X?o===o&&!bc(o):r(o,f)))var f=o,c=i}return c}function ne(n,t,r,e){var u=n.length;for(r=kc(r),r<0&&(r=-r>u?0:u+r),
e=e===X||e>u?u:kc(e),e<0&&(e+=u),e=r>e?0:Oc(e);r<e;)n[r++]=t;return n}function te(n,t){var r=[];return ys(n,function(n,e,u){t(n,e,u)&&r.push(n)}),r}function ee(n,t,r,e,u){var i=-1,o=n.length;for(r||(r=Li),u||(u=[]);++i<o;){var f=n[i];t>0&&r(f)?t>1?ee(f,t-1,r,e,u):a(u,f):e||(u[u.length]=f)}return u}function ue(n,t){return n&&bs(n,t,Pc)}function oe(n,t){return n&&ws(n,t,Pc)}function fe(n,t){return i(t,function(t){return uc(n[t])})}function _e(n,t){t=ku(t,n);for(var r=0,e=t.length;null!=n&&r<e;)n=n[no(t[r++])];
return r&&r==e?n:X}function de(n,t,r){var e=t(n);return bh(n)?e:a(e,r(n))}function we(n){return null==n?n===X?ut:Jn:Bl&&Bl in ll(n)?ki(n):Ki(n)}function me(n,t){return n>t}function xe(n,t){return null!=n&&bl.call(n,t)}function je(n,t){return null!=n&&t in ll(n)}function Ae(n,t,r){return n>=Hl(t,r)&&n<Gl(t,r)}function ke(n,t,r){for(var e=r?f:o,u=n[0].length,i=n.length,a=i,l=il(i),s=1/0,h=[];a--;){var p=n[a];a&&t&&(p=c(p,z(t))),s=Hl(p.length,s),l[a]=!r&&(t||u>=120&&p.length>=120)?new yr(a&&p):X}p=n[0];
var _=-1,v=l[0];n:for(;++_<u&&h.length<s;){var g=p[_],y=t?t(g):g;if(g=r||0!==g?g:0,!(v?S(v,y):e(h,y,r))){for(a=i;--a;){var d=l[a];if(!(d?S(d,y):e(n[a],y,r)))continue n}v&&v.push(y),h.push(g)}}return h}function Oe(n,t,r,e){return ue(n,function(n,u,i){t(e,r(n),u,i)}),e}function Ie(t,r,e){r=ku(r,t),t=Gi(t,r);var u=null==t?t:t[no(jo(r))];return null==u?X:n(u,t,e)}function Re(n){return cc(n)&&we(n)==Dn}function ze(n){return cc(n)&&we(n)==ft}function Ee(n){return cc(n)&&we(n)==Pn}function Se(n,t,r,e,u){
return n===t||(null==n||null==t||!cc(n)&&!cc(t)?n!==n&&t!==t:We(n,t,r,e,Se,u))}function We(n,t,r,e,u,i){var o=bh(n),f=bh(t),c=o?Mn:zs(n),a=f?Mn:zs(t);c=c==Dn?Yn:c,a=a==Dn?Yn:a;var l=c==Yn,s=a==Yn,h=c==a;if(h&&mh(n)){if(!mh(t))return!1;o=!0,l=!1}if(h&&!l)return i||(i=new wr),o||Oh(n)?pi(n,t,r,e,u,i):_i(n,t,c,r,e,u,i);if(!(r&hn)){var p=l&&bl.call(n,"__wrapped__"),_=s&&bl.call(t,"__wrapped__");if(p||_){var v=p?n.value():n,g=_?t.value():t;return i||(i=new wr),u(v,g,r,e,i)}}return!!h&&(i||(i=new wr),vi(n,t,r,e,u,i));
}function Le(n){return cc(n)&&zs(n)==Gn}function Ce(n,t,r,e){var u=r.length,i=u,o=!e;if(null==n)return!i;for(n=ll(n);u--;){var f=r[u];if(o&&f[2]?f[1]!==n[f[0]]:!(f[0]in n))return!1}for(;++u<i;){f=r[u];var c=f[0],a=n[c],l=f[1];if(o&&f[2]){if(a===X&&!(c in n))return!1}else{var s=new wr;if(e)var h=e(a,l,c,n,t,s);if(!(h===X?Se(l,a,hn|pn,e,s):h))return!1}}return!0}function Ue(n){return!(!fc(n)||Di(n))&&(uc(n)?kl:Zt).test(to(n))}function Be(n){return cc(n)&&we(n)==nt}function Te(n){return cc(n)&&zs(n)==tt;
}function $e(n){return cc(n)&&oc(n.length)&&!!Kr[we(n)]}function De(n){return"function"==typeof n?n:null==n?La:"object"==typeof n?bh(n)?Ze(n[0],n[1]):qe(n):Fa(n)}function Me(n){if(!Mi(n))return Vl(n);var t=[];for(var r in ll(n))bl.call(n,r)&&"constructor"!=r&&t.push(r);return t}function Fe(n){if(!fc(n))return Zi(n);var t=Mi(n),r=[];for(var e in n)("constructor"!=e||!t&&bl.call(n,e))&&r.push(e);return r}function Ne(n,t){return n<t}function Pe(n,t){var r=-1,e=Hf(n)?il(n.length):[];return ys(n,function(n,u,i){
e[++r]=t(n,u,i)}),e}function qe(n){var t=ji(n);return 1==t.length&&t[0][2]?Ni(t[0][0],t[0][1]):function(r){return r===n||Ce(r,n,t)}}function Ze(n,t){return Bi(n)&&Fi(t)?Ni(no(n),t):function(r){var e=Mc(r,n);return e===X&&e===t?Nc(r,n):Se(t,e,hn|pn)}}function Ke(n,t,r,e,u){n!==t&&bs(t,function(i,o){if(u||(u=new wr),fc(i))Ve(n,t,o,r,Ke,e,u);else{var f=e?e(Ji(n,o),i,o+"",n,t,u):X;f===X&&(f=i),Er(n,o,f)}},qc)}function Ve(n,t,r,e,u,i,o){var f=Ji(n,r),c=Ji(t,r),a=o.get(c);if(a)return Er(n,r,a),X;var l=i?i(f,c,r+"",n,t,o):X,s=l===X;
if(s){var h=bh(c),p=!h&&mh(c),_=!h&&!p&&Oh(c);l=c,h||p||_?bh(f)?l=f:Jf(f)?l=Tu(f):p?(s=!1,l=Iu(c,!0)):_?(s=!1,l=Wu(c,!0)):l=[]:gc(c)||dh(c)?(l=f,dh(f)?l=Rc(f):fc(f)&&!uc(f)||(l=Ei(c))):s=!1}s&&(o.set(c,l),u(l,c,e,i,o),o.delete(c)),Er(n,r,l)}function Ge(n,t){var r=n.length;if(r)return t+=t<0?r:0,Ci(t,r)?n[t]:X}function He(n,t,r){t=t.length?c(t,function(n){return bh(n)?function(t){return _e(t,1===n.length?n[0]:n)}:n}):[La];var e=-1;return t=c(t,z(mi())),A(Pe(n,function(n,r,u){return{criteria:c(t,function(t){
return t(n)}),index:++e,value:n}}),function(n,t){return Cu(n,t,r)})}function Je(n,t){return Ye(n,t,function(t,r){return Nc(n,r)})}function Ye(n,t,r){for(var e=-1,u=t.length,i={};++e<u;){var o=t[e],f=_e(n,o);r(f,o)&&fu(i,ku(o,n),f)}return i}function Qe(n){return function(t){return _e(t,n)}}function Xe(n,t,r,e){var u=e?d:y,i=-1,o=t.length,f=n;for(n===t&&(t=Tu(t)),r&&(f=c(n,z(r)));++i<o;)for(var a=0,l=t[i],s=r?r(l):l;(a=u(f,s,a,e))>-1;)f!==n&&Ll.call(f,a,1),Ll.call(n,a,1);return n}function nu(n,t){for(var r=n?t.length:0,e=r-1;r--;){
var u=t[r];if(r==e||u!==i){var i=u;Ci(u)?Ll.call(n,u,1):yu(n,u)}}return n}function tu(n,t){return n+Nl(Ql()*(t-n+1))}function ru(n,t,r,e){for(var u=-1,i=Gl(Fl((t-n)/(r||1)),0),o=il(i);i--;)o[e?i:++u]=n,n+=r;return o}function eu(n,t){var r="";if(!n||t<1||t>Wn)return r;do t%2&&(r+=n),t=Nl(t/2),t&&(n+=n);while(t);return r}function uu(n,t){return Ls(Vi(n,t,La),n+"")}function iu(n){return Ir(ra(n))}function ou(n,t){var r=ra(n);return Xi(r,Mr(t,0,r.length))}function fu(n,t,r,e){if(!fc(n))return n;t=ku(t,n);
for(var u=-1,i=t.length,o=i-1,f=n;null!=f&&++u<i;){var c=no(t[u]),a=r;if("__proto__"===c||"constructor"===c||"prototype"===c)return n;if(u!=o){var l=f[c];a=e?e(l,c,f):X,a===X&&(a=fc(l)?l:Ci(t[u+1])?[]:{})}Sr(f,c,a),f=f[c]}return n}function cu(n){return Xi(ra(n))}function au(n,t,r){var e=-1,u=n.length;t<0&&(t=-t>u?0:u+t),r=r>u?u:r,r<0&&(r+=u),u=t>r?0:r-t>>>0,t>>>=0;for(var i=il(u);++e<u;)i[e]=n[e+t];return i}function lu(n,t){var r;return ys(n,function(n,e,u){return r=t(n,e,u),!r}),!!r}function su(n,t,r){
var e=0,u=null==n?e:n.length;if("number"==typeof t&&t===t&&u<=Tn){for(;e<u;){var i=e+u>>>1,o=n[i];null!==o&&!bc(o)&&(r?o<=t:o<t)?e=i+1:u=i}return u}return hu(n,t,La,r)}function hu(n,t,r,e){var u=0,i=null==n?0:n.length;if(0===i)return 0;t=r(t);for(var o=t!==t,f=null===t,c=bc(t),a=t===X;u<i;){var l=Nl((u+i)/2),s=r(n[l]),h=s!==X,p=null===s,_=s===s,v=bc(s);if(o)var g=e||_;else g=a?_&&(e||h):f?_&&h&&(e||!p):c?_&&h&&!p&&(e||!v):!p&&!v&&(e?s<=t:s<t);g?u=l+1:i=l}return Hl(i,Bn)}function pu(n,t){for(var r=-1,e=n.length,u=0,i=[];++r<e;){
var o=n[r],f=t?t(o):o;if(!r||!Gf(f,c)){var c=f;i[u++]=0===o?0:o}}return i}function _u(n){return"number"==typeof n?n:bc(n)?Cn:+n}function vu(n){if("string"==typeof n)return n;if(bh(n))return c(n,vu)+"";if(bc(n))return vs?vs.call(n):"";var t=n+"";return"0"==t&&1/n==-Sn?"-0":t}function gu(n,t,r){var e=-1,u=o,i=n.length,c=!0,a=[],l=a;if(r)c=!1,u=f;else if(i>=tn){var s=t?null:ks(n);if(s)return P(s);c=!1,u=S,l=new yr}else l=t?[]:a;n:for(;++e<i;){var h=n[e],p=t?t(h):h;if(h=r||0!==h?h:0,c&&p===p){for(var _=l.length;_--;)if(l[_]===p)continue n;
t&&l.push(p),a.push(h)}else u(l,p,r)||(l!==a&&l.push(p),a.push(h))}return a}function yu(n,t){return t=ku(t,n),n=Gi(n,t),null==n||delete n[no(jo(t))]}function du(n,t,r,e){return fu(n,t,r(_e(n,t)),e)}function bu(n,t,r,e){for(var u=n.length,i=e?u:-1;(e?i--:++i<u)&&t(n[i],i,n););return r?au(n,e?0:i,e?i+1:u):au(n,e?i+1:0,e?u:i)}function wu(n,t){var r=n;return r instanceof Ct&&(r=r.value()),l(t,function(n,t){return t.func.apply(t.thisArg,a([n],t.args))},r)}function mu(n,t,r){var e=n.length;if(e<2)return e?gu(n[0]):[];
for(var u=-1,i=il(e);++u<e;)for(var o=n[u],f=-1;++f<e;)f!=u&&(i[u]=Hr(i[u]||o,n[f],t,r));return gu(ee(i,1),t,r)}function xu(n,t,r){for(var e=-1,u=n.length,i=t.length,o={};++e<u;){r(o,n[e],e<i?t[e]:X)}return o}function ju(n){return Jf(n)?n:[]}function Au(n){return"function"==typeof n?n:La}function ku(n,t){return bh(n)?n:Bi(n,t)?[n]:Cs(Ec(n))}function Ou(n,t,r){var e=n.length;return r=r===X?e:r,!t&&r>=e?n:au(n,t,r)}function Iu(n,t){if(t)return n.slice();var r=n.length,e=zl?zl(r):new n.constructor(r);
return n.copy(e),e}function Ru(n){var t=new n.constructor(n.byteLength);return new Rl(t).set(new Rl(n)),t}function zu(n,t){return new n.constructor(t?Ru(n.buffer):n.buffer,n.byteOffset,n.byteLength)}function Eu(n){var t=new n.constructor(n.source,Nt.exec(n));return t.lastIndex=n.lastIndex,t}function Su(n){return _s?ll(_s.call(n)):{}}function Wu(n,t){return new n.constructor(t?Ru(n.buffer):n.buffer,n.byteOffset,n.length)}function Lu(n,t){if(n!==t){var r=n!==X,e=null===n,u=n===n,i=bc(n),o=t!==X,f=null===t,c=t===t,a=bc(t);
if(!f&&!a&&!i&&n>t||i&&o&&c&&!f&&!a||e&&o&&c||!r&&c||!u)return 1;if(!e&&!i&&!a&&n<t||a&&r&&u&&!e&&!i||f&&r&&u||!o&&u||!c)return-1}return 0}function Cu(n,t,r){for(var e=-1,u=n.criteria,i=t.criteria,o=u.length,f=r.length;++e<o;){var c=Lu(u[e],i[e]);if(c){if(e>=f)return c;return c*("desc"==r[e]?-1:1)}}return n.index-t.index}function Uu(n,t,r,e){for(var u=-1,i=n.length,o=r.length,f=-1,c=t.length,a=Gl(i-o,0),l=il(c+a),s=!e;++f<c;)l[f]=t[f];for(;++u<o;)(s||u<i)&&(l[r[u]]=n[u]);for(;a--;)l[f++]=n[u++];return l;
}function Bu(n,t,r,e){for(var u=-1,i=n.length,o=-1,f=r.length,c=-1,a=t.length,l=Gl(i-f,0),s=il(l+a),h=!e;++u<l;)s[u]=n[u];for(var p=u;++c<a;)s[p+c]=t[c];for(;++o<f;)(h||u<i)&&(s[p+r[o]]=n[u++]);return s}function Tu(n,t){var r=-1,e=n.length;for(t||(t=il(e));++r<e;)t[r]=n[r];return t}function $u(n,t,r,e){var u=!r;r||(r={});for(var i=-1,o=t.length;++i<o;){var f=t[i],c=e?e(r[f],n[f],f,r,n):X;c===X&&(c=n[f]),u?Br(r,f,c):Sr(r,f,c)}return r}function Du(n,t){return $u(n,Is(n),t)}function Mu(n,t){return $u(n,Rs(n),t);
}function Fu(n,r){return function(e,u){var i=bh(e)?t:Lr,o=r?r():{};return i(e,n,mi(u,2),o)}}function Nu(n){return uu(function(t,r){var e=-1,u=r.length,i=u>1?r[u-1]:X,o=u>2?r[2]:X;for(i=n.length>3&&"function"==typeof i?(u--,i):X,o&&Ui(r[0],r[1],o)&&(i=u<3?X:i,u=1),t=ll(t);++e<u;){var f=r[e];f&&n(t,f,e,i)}return t})}function Pu(n,t){return function(r,e){if(null==r)return r;if(!Hf(r))return n(r,e);for(var u=r.length,i=t?u:-1,o=ll(r);(t?i--:++i<u)&&e(o[i],i,o)!==!1;);return r}}function qu(n){return function(t,r,e){
for(var u=-1,i=ll(t),o=e(t),f=o.length;f--;){var c=o[n?f:++u];if(r(i[c],c,i)===!1)break}return t}}function Zu(n,t,r){function e(){return(this&&this!==re&&this instanceof e?i:n).apply(u?r:this,arguments)}var u=t&_n,i=Gu(n);return e}function Ku(n){return function(t){t=Ec(t);var r=T(t)?G(t):X,e=r?r[0]:t.charAt(0),u=r?Ou(r,1).join(""):t.slice(1);return e[n]()+u}}function Vu(n){return function(t){return l(Ra(ca(t).replace($r,"")),n,"")}}function Gu(n){return function(){var t=arguments;switch(t.length){
case 0:return new n;case 1:return new n(t[0]);case 2:return new n(t[0],t[1]);case 3:return new n(t[0],t[1],t[2]);case 4:return new n(t[0],t[1],t[2],t[3]);case 5:return new n(t[0],t[1],t[2],t[3],t[4]);case 6:return new n(t[0],t[1],t[2],t[3],t[4],t[5]);case 7:return new n(t[0],t[1],t[2],t[3],t[4],t[5],t[6])}var r=gs(n.prototype),e=n.apply(r,t);return fc(e)?e:r}}function Hu(t,r,e){function u(){for(var o=arguments.length,f=il(o),c=o,a=wi(u);c--;)f[c]=arguments[c];var l=o<3&&f[0]!==a&&f[o-1]!==a?[]:N(f,a);
return o-=l.length,o<e?oi(t,r,Qu,u.placeholder,X,f,l,X,X,e-o):n(this&&this!==re&&this instanceof u?i:t,this,f)}var i=Gu(t);return u}function Ju(n){return function(t,r,e){var u=ll(t);if(!Hf(t)){var i=mi(r,3);t=Pc(t),r=function(n){return i(u[n],n,u)}}var o=n(t,r,e);return o>-1?u[i?t[o]:o]:X}}function Yu(n){return gi(function(t){var r=t.length,e=r,u=Y.prototype.thru;for(n&&t.reverse();e--;){var i=t[e];if("function"!=typeof i)throw new pl(en);if(u&&!o&&"wrapper"==bi(i))var o=new Y([],!0)}for(e=o?e:r;++e<r;){
i=t[e];var f=bi(i),c="wrapper"==f?Os(i):X;o=c&&$i(c[0])&&c[1]==(mn|yn|bn|xn)&&!c[4].length&&1==c[9]?o[bi(c[0])].apply(o,c[3]):1==i.length&&$i(i)?o[f]():o.thru(i)}return function(){var n=arguments,e=n[0];if(o&&1==n.length&&bh(e))return o.plant(e).value();for(var u=0,i=r?t[u].apply(this,n):e;++u<r;)i=t[u].call(this,i);return i}})}function Qu(n,t,r,e,u,i,o,f,c,a){function l(){for(var y=arguments.length,d=il(y),b=y;b--;)d[b]=arguments[b];if(_)var w=wi(l),m=C(d,w);if(e&&(d=Uu(d,e,u,_)),i&&(d=Bu(d,i,o,_)),
y-=m,_&&y<a){return oi(n,t,Qu,l.placeholder,r,d,N(d,w),f,c,a-y)}var x=h?r:this,j=p?x[n]:n;return y=d.length,f?d=Hi(d,f):v&&y>1&&d.reverse(),s&&c<y&&(d.length=c),this&&this!==re&&this instanceof l&&(j=g||Gu(j)),j.apply(x,d)}var s=t&mn,h=t&_n,p=t&vn,_=t&(yn|dn),v=t&jn,g=p?X:Gu(n);return l}function Xu(n,t){return function(r,e){return Oe(r,n,t(e),{})}}function ni(n,t){return function(r,e){var u;if(r===X&&e===X)return t;if(r!==X&&(u=r),e!==X){if(u===X)return e;"string"==typeof r||"string"==typeof e?(r=vu(r),
e=vu(e)):(r=_u(r),e=_u(e)),u=n(r,e)}return u}}function ti(t){return gi(function(r){return r=c(r,z(mi())),uu(function(e){var u=this;return t(r,function(t){return n(t,u,e)})})})}function ri(n,t){t=t===X?" ":vu(t);var r=t.length;if(r<2)return r?eu(t,n):t;var e=eu(t,Fl(n/V(t)));return T(t)?Ou(G(e),0,n).join(""):e.slice(0,n)}function ei(t,r,e,u){function i(){for(var r=-1,c=arguments.length,a=-1,l=u.length,s=il(l+c),h=this&&this!==re&&this instanceof i?f:t;++a<l;)s[a]=u[a];for(;c--;)s[a++]=arguments[++r];
return n(h,o?e:this,s)}var o=r&_n,f=Gu(t);return i}function ui(n){return function(t,r,e){return e&&"number"!=typeof e&&Ui(t,r,e)&&(r=e=X),t=Ac(t),r===X?(r=t,t=0):r=Ac(r),e=e===X?t<r?1:-1:Ac(e),ru(t,r,e,n)}}function ii(n){return function(t,r){return"string"==typeof t&&"string"==typeof r||(t=Ic(t),r=Ic(r)),n(t,r)}}function oi(n,t,r,e,u,i,o,f,c,a){var l=t&yn,s=l?o:X,h=l?X:o,p=l?i:X,_=l?X:i;t|=l?bn:wn,t&=~(l?wn:bn),t&gn||(t&=~(_n|vn));var v=[n,t,u,p,s,_,h,f,c,a],g=r.apply(X,v);return $i(n)&&Ss(g,v),g.placeholder=e,
Yi(g,n,t)}function fi(n){var t=al[n];return function(n,r){if(n=Ic(n),r=null==r?0:Hl(kc(r),292),r&&Zl(n)){var e=(Ec(n)+"e").split("e");return e=(Ec(t(e[0]+"e"+(+e[1]+r)))+"e").split("e"),+(e[0]+"e"+(+e[1]-r))}return t(n)}}function ci(n){return function(t){var r=zs(t);return r==Gn?M(t):r==tt?q(t):I(t,n(t))}}function ai(n,t,r,e,u,i,o,f){var c=t&vn;if(!c&&"function"!=typeof n)throw new pl(en);var a=e?e.length:0;if(a||(t&=~(bn|wn),e=u=X),o=o===X?o:Gl(kc(o),0),f=f===X?f:kc(f),a-=u?u.length:0,t&wn){var l=e,s=u;
e=u=X}var h=c?X:Os(n),p=[n,t,r,e,u,l,s,i,o,f];if(h&&qi(p,h),n=p[0],t=p[1],r=p[2],e=p[3],u=p[4],f=p[9]=p[9]===X?c?0:n.length:Gl(p[9]-a,0),!f&&t&(yn|dn)&&(t&=~(yn|dn)),t&&t!=_n)_=t==yn||t==dn?Hu(n,t,f):t!=bn&&t!=(_n|bn)||u.length?Qu.apply(X,p):ei(n,t,r,e);else var _=Zu(n,t,r);return Yi((h?ms:Ss)(_,p),n,t)}function li(n,t,r,e){return n===X||Gf(n,gl[r])&&!bl.call(e,r)?t:n}function si(n,t,r,e,u,i){return fc(n)&&fc(t)&&(i.set(t,n),Ke(n,t,X,si,i),i.delete(t)),n}function hi(n){return gc(n)?X:n}function pi(n,t,r,e,u,i){
var o=r&hn,f=n.length,c=t.length;if(f!=c&&!(o&&c>f))return!1;var a=i.get(n),l=i.get(t);if(a&&l)return a==t&&l==n;var s=-1,p=!0,_=r&pn?new yr:X;for(i.set(n,t),i.set(t,n);++s<f;){var v=n[s],g=t[s];if(e)var y=o?e(g,v,s,t,n,i):e(v,g,s,n,t,i);if(y!==X){if(y)continue;p=!1;break}if(_){if(!h(t,function(n,t){if(!S(_,t)&&(v===n||u(v,n,r,e,i)))return _.push(t)})){p=!1;break}}else if(v!==g&&!u(v,g,r,e,i)){p=!1;break}}return i.delete(n),i.delete(t),p}function _i(n,t,r,e,u,i,o){switch(r){case ct:if(n.byteLength!=t.byteLength||n.byteOffset!=t.byteOffset)return!1;
n=n.buffer,t=t.buffer;case ft:return!(n.byteLength!=t.byteLength||!i(new Rl(n),new Rl(t)));case Nn:case Pn:case Hn:return Gf(+n,+t);case Zn:return n.name==t.name&&n.message==t.message;case nt:case rt:return n==t+"";case Gn:var f=M;case tt:var c=e&hn;if(f||(f=P),n.size!=t.size&&!c)return!1;var a=o.get(n);if(a)return a==t;e|=pn,o.set(n,t);var l=pi(f(n),f(t),e,u,i,o);return o.delete(n),l;case et:if(_s)return _s.call(n)==_s.call(t)}return!1}function vi(n,t,r,e,u,i){var o=r&hn,f=yi(n),c=f.length;if(c!=yi(t).length&&!o)return!1;
for(var a=c;a--;){var l=f[a];if(!(o?l in t:bl.call(t,l)))return!1}var s=i.get(n),h=i.get(t);if(s&&h)return s==t&&h==n;var p=!0;i.set(n,t),i.set(t,n);for(var _=o;++a<c;){l=f[a];var v=n[l],g=t[l];if(e)var y=o?e(g,v,l,t,n,i):e(v,g,l,n,t,i);if(!(y===X?v===g||u(v,g,r,e,i):y)){p=!1;break}_||(_="constructor"==l)}if(p&&!_){var d=n.constructor,b=t.constructor;d!=b&&"constructor"in n&&"constructor"in t&&!("function"==typeof d&&d instanceof d&&"function"==typeof b&&b instanceof b)&&(p=!1)}return i.delete(n),
i.delete(t),p}function gi(n){return Ls(Vi(n,X,_o),n+"")}function yi(n){return de(n,Pc,Is)}function di(n){return de(n,qc,Rs)}function bi(n){for(var t=n.name+"",r=fs[t],e=bl.call(fs,t)?r.length:0;e--;){var u=r[e],i=u.func;if(null==i||i==n)return u.name}return t}function wi(n){return(bl.call(Z,"placeholder")?Z:n).placeholder}function mi(){var n=Z.iteratee||Ca;return n=n===Ca?De:n,arguments.length?n(arguments[0],arguments[1]):n}function xi(n,t){var r=n.__data__;return Ti(t)?r["string"==typeof t?"string":"hash"]:r.map;
}function ji(n){for(var t=Pc(n),r=t.length;r--;){var e=t[r],u=n[e];t[r]=[e,u,Fi(u)]}return t}function Ai(n,t){var r=B(n,t);return Ue(r)?r:X}function ki(n){var t=bl.call(n,Bl),r=n[Bl];try{n[Bl]=X;var e=!0}catch(n){}var u=xl.call(n);return e&&(t?n[Bl]=r:delete n[Bl]),u}function Oi(n,t,r){for(var e=-1,u=r.length;++e<u;){var i=r[e],o=i.size;switch(i.type){case"drop":n+=o;break;case"dropRight":t-=o;break;case"take":t=Hl(t,n+o);break;case"takeRight":n=Gl(n,t-o)}}return{start:n,end:t}}function Ii(n){var t=n.match(Bt);
return t?t[1].split(Tt):[]}function Ri(n,t,r){t=ku(t,n);for(var e=-1,u=t.length,i=!1;++e<u;){var o=no(t[e]);if(!(i=null!=n&&r(n,o)))break;n=n[o]}return i||++e!=u?i:(u=null==n?0:n.length,!!u&&oc(u)&&Ci(o,u)&&(bh(n)||dh(n)))}function zi(n){var t=n.length,r=new n.constructor(t);return t&&"string"==typeof n[0]&&bl.call(n,"index")&&(r.index=n.index,r.input=n.input),r}function Ei(n){return"function"!=typeof n.constructor||Mi(n)?{}:gs(El(n))}function Si(n,t,r){var e=n.constructor;switch(t){case ft:return Ru(n);
case Nn:case Pn:return new e(+n);case ct:return zu(n,r);case at:case lt:case st:case ht:case pt:case _t:case vt:case gt:case yt:return Wu(n,r);case Gn:return new e;case Hn:case rt:return new e(n);case nt:return Eu(n);case tt:return new e;case et:return Su(n)}}function Wi(n,t){var r=t.length;if(!r)return n;var e=r-1;return t[e]=(r>1?"& ":"")+t[e],t=t.join(r>2?", ":" "),n.replace(Ut,"{\n/* [wrapped with "+t+"] */\n")}function Li(n){return bh(n)||dh(n)||!!(Cl&&n&&n[Cl])}function Ci(n,t){var r=typeof n;
return t=null==t?Wn:t,!!t&&("number"==r||"symbol"!=r&&Vt.test(n))&&n>-1&&n%1==0&&n<t}function Ui(n,t,r){if(!fc(r))return!1;var e=typeof t;return!!("number"==e?Hf(r)&&Ci(t,r.length):"string"==e&&t in r)&&Gf(r[t],n)}function Bi(n,t){if(bh(n))return!1;var r=typeof n;return!("number"!=r&&"symbol"!=r&&"boolean"!=r&&null!=n&&!bc(n))||(zt.test(n)||!Rt.test(n)||null!=t&&n in ll(t))}function Ti(n){var t=typeof n;return"string"==t||"number"==t||"symbol"==t||"boolean"==t?"__proto__"!==n:null===n}function $i(n){
var t=bi(n),r=Z[t];if("function"!=typeof r||!(t in Ct.prototype))return!1;if(n===r)return!0;var e=Os(r);return!!e&&n===e[0]}function Di(n){return!!ml&&ml in n}function Mi(n){var t=n&&n.constructor;return n===("function"==typeof t&&t.prototype||gl)}function Fi(n){return n===n&&!fc(n)}function Ni(n,t){return function(r){return null!=r&&(r[n]===t&&(t!==X||n in ll(r)))}}function Pi(n){var t=Cf(n,function(n){return r.size===fn&&r.clear(),n}),r=t.cache;return t}function qi(n,t){var r=n[1],e=t[1],u=r|e,i=u<(_n|vn|mn),o=e==mn&&r==yn||e==mn&&r==xn&&n[7].length<=t[8]||e==(mn|xn)&&t[7].length<=t[8]&&r==yn;
if(!i&&!o)return n;e&_n&&(n[2]=t[2],u|=r&_n?0:gn);var f=t[3];if(f){var c=n[3];n[3]=c?Uu(c,f,t[4]):f,n[4]=c?N(n[3],cn):t[4]}return f=t[5],f&&(c=n[5],n[5]=c?Bu(c,f,t[6]):f,n[6]=c?N(n[5],cn):t[6]),f=t[7],f&&(n[7]=f),e&mn&&(n[8]=null==n[8]?t[8]:Hl(n[8],t[8])),null==n[9]&&(n[9]=t[9]),n[0]=t[0],n[1]=u,n}function Zi(n){var t=[];if(null!=n)for(var r in ll(n))t.push(r);return t}function Ki(n){return xl.call(n)}function Vi(t,r,e){return r=Gl(r===X?t.length-1:r,0),function(){for(var u=arguments,i=-1,o=Gl(u.length-r,0),f=il(o);++i<o;)f[i]=u[r+i];
i=-1;for(var c=il(r+1);++i<r;)c[i]=u[i];return c[r]=e(f),n(t,this,c)}}function Gi(n,t){return t.length<2?n:_e(n,au(t,0,-1))}function Hi(n,t){for(var r=n.length,e=Hl(t.length,r),u=Tu(n);e--;){var i=t[e];n[e]=Ci(i,r)?u[i]:X}return n}function Ji(n,t){if(("constructor"!==t||"function"!=typeof n[t])&&"__proto__"!=t)return n[t]}function Yi(n,t,r){var e=t+"";return Ls(n,Wi(e,ro(Ii(e),r)))}function Qi(n){var t=0,r=0;return function(){var e=Jl(),u=In-(e-r);if(r=e,u>0){if(++t>=On)return arguments[0]}else t=0;
return n.apply(X,arguments)}}function Xi(n,t){var r=-1,e=n.length,u=e-1;for(t=t===X?e:t;++r<t;){var i=tu(r,u),o=n[i];n[i]=n[r],n[r]=o}return n.length=t,n}function no(n){if("string"==typeof n||bc(n))return n;var t=n+"";return"0"==t&&1/n==-Sn?"-0":t}function to(n){if(null!=n){try{return dl.call(n)}catch(n){}try{return n+""}catch(n){}}return""}function ro(n,t){return r($n,function(r){var e="_."+r[0];t&r[1]&&!o(n,e)&&n.push(e)}),n.sort()}function eo(n){if(n instanceof Ct)return n.clone();var t=new Y(n.__wrapped__,n.__chain__);
return t.__actions__=Tu(n.__actions__),t.__index__=n.__index__,t.__values__=n.__values__,t}function uo(n,t,r){t=(r?Ui(n,t,r):t===X)?1:Gl(kc(t),0);var e=null==n?0:n.length;if(!e||t<1)return[];for(var u=0,i=0,o=il(Fl(e/t));u<e;)o[i++]=au(n,u,u+=t);return o}function io(n){for(var t=-1,r=null==n?0:n.length,e=0,u=[];++t<r;){var i=n[t];i&&(u[e++]=i)}return u}function oo(){var n=arguments.length;if(!n)return[];for(var t=il(n-1),r=arguments[0],e=n;e--;)t[e-1]=arguments[e];return a(bh(r)?Tu(r):[r],ee(t,1));
}function fo(n,t,r){var e=null==n?0:n.length;return e?(t=r||t===X?1:kc(t),au(n,t<0?0:t,e)):[]}function co(n,t,r){var e=null==n?0:n.length;return e?(t=r||t===X?1:kc(t),t=e-t,au(n,0,t<0?0:t)):[]}function ao(n,t){return n&&n.length?bu(n,mi(t,3),!0,!0):[]}function lo(n,t){return n&&n.length?bu(n,mi(t,3),!0):[]}function so(n,t,r,e){var u=null==n?0:n.length;return u?(r&&"number"!=typeof r&&Ui(n,t,r)&&(r=0,e=u),ne(n,t,r,e)):[]}function ho(n,t,r){var e=null==n?0:n.length;if(!e)return-1;var u=null==r?0:kc(r);
return u<0&&(u=Gl(e+u,0)),g(n,mi(t,3),u)}function po(n,t,r){var e=null==n?0:n.length;if(!e)return-1;var u=e-1;return r!==X&&(u=kc(r),u=r<0?Gl(e+u,0):Hl(u,e-1)),g(n,mi(t,3),u,!0)}function _o(n){return(null==n?0:n.length)?ee(n,1):[]}function vo(n){return(null==n?0:n.length)?ee(n,Sn):[]}function go(n,t){return(null==n?0:n.length)?(t=t===X?1:kc(t),ee(n,t)):[]}function yo(n){for(var t=-1,r=null==n?0:n.length,e={};++t<r;){var u=n[t];e[u[0]]=u[1]}return e}function bo(n){return n&&n.length?n[0]:X}function wo(n,t,r){
var e=null==n?0:n.length;if(!e)return-1;var u=null==r?0:kc(r);return u<0&&(u=Gl(e+u,0)),y(n,t,u)}function mo(n){return(null==n?0:n.length)?au(n,0,-1):[]}function xo(n,t){return null==n?"":Kl.call(n,t)}function jo(n){var t=null==n?0:n.length;return t?n[t-1]:X}function Ao(n,t,r){var e=null==n?0:n.length;if(!e)return-1;var u=e;return r!==X&&(u=kc(r),u=u<0?Gl(e+u,0):Hl(u,e-1)),t===t?K(n,t,u):g(n,b,u,!0)}function ko(n,t){return n&&n.length?Ge(n,kc(t)):X}function Oo(n,t){return n&&n.length&&t&&t.length?Xe(n,t):n;
}function Io(n,t,r){return n&&n.length&&t&&t.length?Xe(n,t,mi(r,2)):n}function Ro(n,t,r){return n&&n.length&&t&&t.length?Xe(n,t,X,r):n}function zo(n,t){var r=[];if(!n||!n.length)return r;var e=-1,u=[],i=n.length;for(t=mi(t,3);++e<i;){var o=n[e];t(o,e,n)&&(r.push(o),u.push(e))}return nu(n,u),r}function Eo(n){return null==n?n:Xl.call(n)}function So(n,t,r){var e=null==n?0:n.length;return e?(r&&"number"!=typeof r&&Ui(n,t,r)?(t=0,r=e):(t=null==t?0:kc(t),r=r===X?e:kc(r)),au(n,t,r)):[]}function Wo(n,t){
return su(n,t)}function Lo(n,t,r){return hu(n,t,mi(r,2))}function Co(n,t){var r=null==n?0:n.length;if(r){var e=su(n,t);if(e<r&&Gf(n[e],t))return e}return-1}function Uo(n,t){return su(n,t,!0)}function Bo(n,t,r){return hu(n,t,mi(r,2),!0)}function To(n,t){if(null==n?0:n.length){var r=su(n,t,!0)-1;if(Gf(n[r],t))return r}return-1}function $o(n){return n&&n.length?pu(n):[]}function Do(n,t){return n&&n.length?pu(n,mi(t,2)):[]}function Mo(n){var t=null==n?0:n.length;return t?au(n,1,t):[]}function Fo(n,t,r){
return n&&n.length?(t=r||t===X?1:kc(t),au(n,0,t<0?0:t)):[]}function No(n,t,r){var e=null==n?0:n.length;return e?(t=r||t===X?1:kc(t),t=e-t,au(n,t<0?0:t,e)):[]}function Po(n,t){return n&&n.length?bu(n,mi(t,3),!1,!0):[]}function qo(n,t){return n&&n.length?bu(n,mi(t,3)):[]}function Zo(n){return n&&n.length?gu(n):[]}function Ko(n,t){return n&&n.length?gu(n,mi(t,2)):[]}function Vo(n,t){return t="function"==typeof t?t:X,n&&n.length?gu(n,X,t):[]}function Go(n){if(!n||!n.length)return[];var t=0;return n=i(n,function(n){
if(Jf(n))return t=Gl(n.length,t),!0}),O(t,function(t){return c(n,m(t))})}function Ho(t,r){if(!t||!t.length)return[];var e=Go(t);return null==r?e:c(e,function(t){return n(r,X,t)})}function Jo(n,t){return xu(n||[],t||[],Sr)}function Yo(n,t){return xu(n||[],t||[],fu)}function Qo(n){var t=Z(n);return t.__chain__=!0,t}function Xo(n,t){return t(n),n}function nf(n,t){return t(n)}function tf(){return Qo(this)}function rf(){return new Y(this.value(),this.__chain__)}function ef(){this.__values__===X&&(this.__values__=jc(this.value()));
var n=this.__index__>=this.__values__.length;return{done:n,value:n?X:this.__values__[this.__index__++]}}function uf(){return this}function of(n){for(var t,r=this;r instanceof J;){var e=eo(r);e.__index__=0,e.__values__=X,t?u.__wrapped__=e:t=e;var u=e;r=r.__wrapped__}return u.__wrapped__=n,t}function ff(){var n=this.__wrapped__;if(n instanceof Ct){var t=n;return this.__actions__.length&&(t=new Ct(this)),t=t.reverse(),t.__actions__.push({func:nf,args:[Eo],thisArg:X}),new Y(t,this.__chain__)}return this.thru(Eo);
}function cf(){return wu(this.__wrapped__,this.__actions__)}function af(n,t,r){var e=bh(n)?u:Jr;return r&&Ui(n,t,r)&&(t=X),e(n,mi(t,3))}function lf(n,t){return(bh(n)?i:te)(n,mi(t,3))}function sf(n,t){return ee(yf(n,t),1)}function hf(n,t){return ee(yf(n,t),Sn)}function pf(n,t,r){return r=r===X?1:kc(r),ee(yf(n,t),r)}function _f(n,t){return(bh(n)?r:ys)(n,mi(t,3))}function vf(n,t){return(bh(n)?e:ds)(n,mi(t,3))}function gf(n,t,r,e){n=Hf(n)?n:ra(n),r=r&&!e?kc(r):0;var u=n.length;return r<0&&(r=Gl(u+r,0)),
dc(n)?r<=u&&n.indexOf(t,r)>-1:!!u&&y(n,t,r)>-1}function yf(n,t){return(bh(n)?c:Pe)(n,mi(t,3))}function df(n,t,r,e){return null==n?[]:(bh(t)||(t=null==t?[]:[t]),r=e?X:r,bh(r)||(r=null==r?[]:[r]),He(n,t,r))}function bf(n,t,r){var e=bh(n)?l:j,u=arguments.length<3;return e(n,mi(t,4),r,u,ys)}function wf(n,t,r){var e=bh(n)?s:j,u=arguments.length<3;return e(n,mi(t,4),r,u,ds)}function mf(n,t){return(bh(n)?i:te)(n,Uf(mi(t,3)))}function xf(n){return(bh(n)?Ir:iu)(n)}function jf(n,t,r){return t=(r?Ui(n,t,r):t===X)?1:kc(t),
(bh(n)?Rr:ou)(n,t)}function Af(n){return(bh(n)?zr:cu)(n)}function kf(n){if(null==n)return 0;if(Hf(n))return dc(n)?V(n):n.length;var t=zs(n);return t==Gn||t==tt?n.size:Me(n).length}function Of(n,t,r){var e=bh(n)?h:lu;return r&&Ui(n,t,r)&&(t=X),e(n,mi(t,3))}function If(n,t){if("function"!=typeof t)throw new pl(en);return n=kc(n),function(){if(--n<1)return t.apply(this,arguments)}}function Rf(n,t,r){return t=r?X:t,t=n&&null==t?n.length:t,ai(n,mn,X,X,X,X,t)}function zf(n,t){var r;if("function"!=typeof t)throw new pl(en);
return n=kc(n),function(){return--n>0&&(r=t.apply(this,arguments)),n<=1&&(t=X),r}}function Ef(n,t,r){t=r?X:t;var e=ai(n,yn,X,X,X,X,X,t);return e.placeholder=Ef.placeholder,e}function Sf(n,t,r){t=r?X:t;var e=ai(n,dn,X,X,X,X,X,t);return e.placeholder=Sf.placeholder,e}function Wf(n,t,r){function e(t){var r=h,e=p;return h=p=X,d=t,v=n.apply(e,r)}function u(n){return d=n,g=Ws(f,t),b?e(n):v}function i(n){var r=n-y,e=n-d,u=t-r;return w?Hl(u,_-e):u}function o(n){var r=n-y,e=n-d;return y===X||r>=t||r<0||w&&e>=_;
}function f(){var n=fh();return o(n)?c(n):(g=Ws(f,i(n)),X)}function c(n){return g=X,m&&h?e(n):(h=p=X,v)}function a(){g!==X&&As(g),d=0,h=y=p=g=X}function l(){return g===X?v:c(fh())}function s(){var n=fh(),r=o(n);if(h=arguments,p=this,y=n,r){if(g===X)return u(y);if(w)return As(g),g=Ws(f,t),e(y)}return g===X&&(g=Ws(f,t)),v}var h,p,_,v,g,y,d=0,b=!1,w=!1,m=!0;if("function"!=typeof n)throw new pl(en);return t=Ic(t)||0,fc(r)&&(b=!!r.leading,w="maxWait"in r,_=w?Gl(Ic(r.maxWait)||0,t):_,m="trailing"in r?!!r.trailing:m),
s.cancel=a,s.flush=l,s}function Lf(n){return ai(n,jn)}function Cf(n,t){if("function"!=typeof n||null!=t&&"function"!=typeof t)throw new pl(en);var r=function(){var e=arguments,u=t?t.apply(this,e):e[0],i=r.cache;if(i.has(u))return i.get(u);var o=n.apply(this,e);return r.cache=i.set(u,o)||i,o};return r.cache=new(Cf.Cache||sr),r}function Uf(n){if("function"!=typeof n)throw new pl(en);return function(){var t=arguments;switch(t.length){case 0:return!n.call(this);case 1:return!n.call(this,t[0]);case 2:
return!n.call(this,t[0],t[1]);case 3:return!n.call(this,t[0],t[1],t[2])}return!n.apply(this,t)}}function Bf(n){return zf(2,n)}function Tf(n,t){if("function"!=typeof n)throw new pl(en);return t=t===X?t:kc(t),uu(n,t)}function $f(t,r){if("function"!=typeof t)throw new pl(en);return r=null==r?0:Gl(kc(r),0),uu(function(e){var u=e[r],i=Ou(e,0,r);return u&&a(i,u),n(t,this,i)})}function Df(n,t,r){var e=!0,u=!0;if("function"!=typeof n)throw new pl(en);return fc(r)&&(e="leading"in r?!!r.leading:e,u="trailing"in r?!!r.trailing:u),
Wf(n,t,{leading:e,maxWait:t,trailing:u})}function Mf(n){return Rf(n,1)}function Ff(n,t){return ph(Au(t),n)}function Nf(){if(!arguments.length)return[];var n=arguments[0];return bh(n)?n:[n]}function Pf(n){return Fr(n,sn)}function qf(n,t){return t="function"==typeof t?t:X,Fr(n,sn,t)}function Zf(n){return Fr(n,an|sn)}function Kf(n,t){return t="function"==typeof t?t:X,Fr(n,an|sn,t)}function Vf(n,t){return null==t||Pr(n,t,Pc(t))}function Gf(n,t){return n===t||n!==n&&t!==t}function Hf(n){return null!=n&&oc(n.length)&&!uc(n);
}function Jf(n){return cc(n)&&Hf(n)}function Yf(n){return n===!0||n===!1||cc(n)&&we(n)==Nn}function Qf(n){return cc(n)&&1===n.nodeType&&!gc(n)}function Xf(n){if(null==n)return!0;if(Hf(n)&&(bh(n)||"string"==typeof n||"function"==typeof n.splice||mh(n)||Oh(n)||dh(n)))return!n.length;var t=zs(n);if(t==Gn||t==tt)return!n.size;if(Mi(n))return!Me(n).length;for(var r in n)if(bl.call(n,r))return!1;return!0}function nc(n,t){return Se(n,t)}function tc(n,t,r){r="function"==typeof r?r:X;var e=r?r(n,t):X;return e===X?Se(n,t,X,r):!!e;
}function rc(n){if(!cc(n))return!1;var t=we(n);return t==Zn||t==qn||"string"==typeof n.message&&"string"==typeof n.name&&!gc(n)}function ec(n){return"number"==typeof n&&Zl(n)}function uc(n){if(!fc(n))return!1;var t=we(n);return t==Kn||t==Vn||t==Fn||t==Xn}function ic(n){return"number"==typeof n&&n==kc(n)}function oc(n){return"number"==typeof n&&n>-1&&n%1==0&&n<=Wn}function fc(n){var t=typeof n;return null!=n&&("object"==t||"function"==t)}function cc(n){return null!=n&&"object"==typeof n}function ac(n,t){
return n===t||Ce(n,t,ji(t))}function lc(n,t,r){return r="function"==typeof r?r:X,Ce(n,t,ji(t),r)}function sc(n){return vc(n)&&n!=+n}function hc(n){if(Es(n))throw new fl(rn);return Ue(n)}function pc(n){return null===n}function _c(n){return null==n}function vc(n){return"number"==typeof n||cc(n)&&we(n)==Hn}function gc(n){if(!cc(n)||we(n)!=Yn)return!1;var t=El(n);if(null===t)return!0;var r=bl.call(t,"constructor")&&t.constructor;return"function"==typeof r&&r instanceof r&&dl.call(r)==jl}function yc(n){
return ic(n)&&n>=-Wn&&n<=Wn}function dc(n){return"string"==typeof n||!bh(n)&&cc(n)&&we(n)==rt}function bc(n){return"symbol"==typeof n||cc(n)&&we(n)==et}function wc(n){return n===X}function mc(n){return cc(n)&&zs(n)==it}function xc(n){return cc(n)&&we(n)==ot}function jc(n){if(!n)return[];if(Hf(n))return dc(n)?G(n):Tu(n);if(Ul&&n[Ul])return D(n[Ul]());var t=zs(n);return(t==Gn?M:t==tt?P:ra)(n)}function Ac(n){if(!n)return 0===n?n:0;if(n=Ic(n),n===Sn||n===-Sn){return(n<0?-1:1)*Ln}return n===n?n:0}function kc(n){
var t=Ac(n),r=t%1;return t===t?r?t-r:t:0}function Oc(n){return n?Mr(kc(n),0,Un):0}function Ic(n){if("number"==typeof n)return n;if(bc(n))return Cn;if(fc(n)){var t="function"==typeof n.valueOf?n.valueOf():n;n=fc(t)?t+"":t}if("string"!=typeof n)return 0===n?n:+n;n=R(n);var r=qt.test(n);return r||Kt.test(n)?Xr(n.slice(2),r?2:8):Pt.test(n)?Cn:+n}function Rc(n){return $u(n,qc(n))}function zc(n){return n?Mr(kc(n),-Wn,Wn):0===n?n:0}function Ec(n){return null==n?"":vu(n)}function Sc(n,t){var r=gs(n);return null==t?r:Cr(r,t);
}function Wc(n,t){return v(n,mi(t,3),ue)}function Lc(n,t){return v(n,mi(t,3),oe)}function Cc(n,t){return null==n?n:bs(n,mi(t,3),qc)}function Uc(n,t){return null==n?n:ws(n,mi(t,3),qc)}function Bc(n,t){return n&&ue(n,mi(t,3))}function Tc(n,t){return n&&oe(n,mi(t,3))}function $c(n){return null==n?[]:fe(n,Pc(n))}function Dc(n){return null==n?[]:fe(n,qc(n))}function Mc(n,t,r){var e=null==n?X:_e(n,t);return e===X?r:e}function Fc(n,t){return null!=n&&Ri(n,t,xe)}function Nc(n,t){return null!=n&&Ri(n,t,je);
}function Pc(n){return Hf(n)?Or(n):Me(n)}function qc(n){return Hf(n)?Or(n,!0):Fe(n)}function Zc(n,t){var r={};return t=mi(t,3),ue(n,function(n,e,u){Br(r,t(n,e,u),n)}),r}function Kc(n,t){var r={};return t=mi(t,3),ue(n,function(n,e,u){Br(r,e,t(n,e,u))}),r}function Vc(n,t){return Gc(n,Uf(mi(t)))}function Gc(n,t){if(null==n)return{};var r=c(di(n),function(n){return[n]});return t=mi(t),Ye(n,r,function(n,r){return t(n,r[0])})}function Hc(n,t,r){t=ku(t,n);var e=-1,u=t.length;for(u||(u=1,n=X);++e<u;){var i=null==n?X:n[no(t[e])];
i===X&&(e=u,i=r),n=uc(i)?i.call(n):i}return n}function Jc(n,t,r){return null==n?n:fu(n,t,r)}function Yc(n,t,r,e){return e="function"==typeof e?e:X,null==n?n:fu(n,t,r,e)}function Qc(n,t,e){var u=bh(n),i=u||mh(n)||Oh(n);if(t=mi(t,4),null==e){var o=n&&n.constructor;e=i?u?new o:[]:fc(n)&&uc(o)?gs(El(n)):{}}return(i?r:ue)(n,function(n,r,u){return t(e,n,r,u)}),e}function Xc(n,t){return null==n||yu(n,t)}function na(n,t,r){return null==n?n:du(n,t,Au(r))}function ta(n,t,r,e){return e="function"==typeof e?e:X,
null==n?n:du(n,t,Au(r),e)}function ra(n){return null==n?[]:E(n,Pc(n))}function ea(n){return null==n?[]:E(n,qc(n))}function ua(n,t,r){return r===X&&(r=t,t=X),r!==X&&(r=Ic(r),r=r===r?r:0),t!==X&&(t=Ic(t),t=t===t?t:0),Mr(Ic(n),t,r)}function ia(n,t,r){return t=Ac(t),r===X?(r=t,t=0):r=Ac(r),n=Ic(n),Ae(n,t,r)}function oa(n,t,r){if(r&&"boolean"!=typeof r&&Ui(n,t,r)&&(t=r=X),r===X&&("boolean"==typeof t?(r=t,t=X):"boolean"==typeof n&&(r=n,n=X)),n===X&&t===X?(n=0,t=1):(n=Ac(n),t===X?(t=n,n=0):t=Ac(t)),n>t){
var e=n;n=t,t=e}if(r||n%1||t%1){var u=Ql();return Hl(n+u*(t-n+Qr("1e-"+((u+"").length-1))),t)}return tu(n,t)}function fa(n){return Qh(Ec(n).toLowerCase())}function ca(n){return n=Ec(n),n&&n.replace(Gt,ve).replace(Dr,"")}function aa(n,t,r){n=Ec(n),t=vu(t);var e=n.length;r=r===X?e:Mr(kc(r),0,e);var u=r;return r-=t.length,r>=0&&n.slice(r,u)==t}function la(n){return n=Ec(n),n&&At.test(n)?n.replace(xt,ge):n}function sa(n){return n=Ec(n),n&&Wt.test(n)?n.replace(St,"\\$&"):n}function ha(n,t,r){n=Ec(n),t=kc(t);
var e=t?V(n):0;if(!t||e>=t)return n;var u=(t-e)/2;return ri(Nl(u),r)+n+ri(Fl(u),r)}function pa(n,t,r){n=Ec(n),t=kc(t);var e=t?V(n):0;return t&&e<t?n+ri(t-e,r):n}function _a(n,t,r){n=Ec(n),t=kc(t);var e=t?V(n):0;return t&&e<t?ri(t-e,r)+n:n}function va(n,t,r){return r||null==t?t=0:t&&(t=+t),Yl(Ec(n).replace(Lt,""),t||0)}function ga(n,t,r){return t=(r?Ui(n,t,r):t===X)?1:kc(t),eu(Ec(n),t)}function ya(){var n=arguments,t=Ec(n[0]);return n.length<3?t:t.replace(n[1],n[2])}function da(n,t,r){return r&&"number"!=typeof r&&Ui(n,t,r)&&(t=r=X),
(r=r===X?Un:r>>>0)?(n=Ec(n),n&&("string"==typeof t||null!=t&&!Ah(t))&&(t=vu(t),!t&&T(n))?Ou(G(n),0,r):n.split(t,r)):[]}function ba(n,t,r){return n=Ec(n),r=null==r?0:Mr(kc(r),0,n.length),t=vu(t),n.slice(r,r+t.length)==t}function wa(n,t,r){var e=Z.templateSettings;r&&Ui(n,t,r)&&(t=X),n=Ec(n),t=Sh({},t,e,li);var u,i,o=Sh({},t.imports,e.imports,li),f=Pc(o),c=E(o,f),a=0,l=t.interpolate||Ht,s="__p += '",h=sl((t.escape||Ht).source+"|"+l.source+"|"+(l===It?Ft:Ht).source+"|"+(t.evaluate||Ht).source+"|$","g"),p="//# sourceURL="+(bl.call(t,"sourceURL")?(t.sourceURL+"").replace(/\s/g," "):"lodash.templateSources["+ ++Zr+"]")+"\n";
n.replace(h,function(t,r,e,o,f,c){return e||(e=o),s+=n.slice(a,c).replace(Jt,U),r&&(u=!0,s+="' +\n__e("+r+") +\n'"),f&&(i=!0,s+="';\n"+f+";\n__p += '"),e&&(s+="' +\n((__t = ("+e+")) == null ? '' : __t) +\n'"),a=c+t.length,t}),s+="';\n";var _=bl.call(t,"variable")&&t.variable;if(_){if(Dt.test(_))throw new fl(un)}else s="with (obj) {\n"+s+"\n}\n";s=(i?s.replace(dt,""):s).replace(bt,"$1").replace(wt,"$1;"),s="function("+(_||"obj")+") {\n"+(_?"":"obj || (obj = {});\n")+"var __t, __p = ''"+(u?", __e = _.escape":"")+(i?", __j = Array.prototype.join;\nfunction print() { __p += __j.call(arguments, '') }\n":";\n")+s+"return __p\n}";
var v=Xh(function(){return cl(f,p+"return "+s).apply(X,c)});if(v.source=s,rc(v))throw v;return v}function ma(n){return Ec(n).toLowerCase()}function xa(n){return Ec(n).toUpperCase()}function ja(n,t,r){if(n=Ec(n),n&&(r||t===X))return R(n);if(!n||!(t=vu(t)))return n;var e=G(n),u=G(t);return Ou(e,W(e,u),L(e,u)+1).join("")}function Aa(n,t,r){if(n=Ec(n),n&&(r||t===X))return n.slice(0,H(n)+1);if(!n||!(t=vu(t)))return n;var e=G(n);return Ou(e,0,L(e,G(t))+1).join("")}function ka(n,t,r){if(n=Ec(n),n&&(r||t===X))return n.replace(Lt,"");
if(!n||!(t=vu(t)))return n;var e=G(n);return Ou(e,W(e,G(t))).join("")}function Oa(n,t){var r=An,e=kn;if(fc(t)){var u="separator"in t?t.separator:u;r="length"in t?kc(t.length):r,e="omission"in t?vu(t.omission):e}n=Ec(n);var i=n.length;if(T(n)){var o=G(n);i=o.length}if(r>=i)return n;var f=r-V(e);if(f<1)return e;var c=o?Ou(o,0,f).join(""):n.slice(0,f);if(u===X)return c+e;if(o&&(f+=c.length-f),Ah(u)){if(n.slice(f).search(u)){var a,l=c;for(u.global||(u=sl(u.source,Ec(Nt.exec(u))+"g")),u.lastIndex=0;a=u.exec(l);)var s=a.index;
c=c.slice(0,s===X?f:s)}}else if(n.indexOf(vu(u),f)!=f){var h=c.lastIndexOf(u);h>-1&&(c=c.slice(0,h))}return c+e}function Ia(n){return n=Ec(n),n&&jt.test(n)?n.replace(mt,ye):n}function Ra(n,t,r){return n=Ec(n),t=r?X:t,t===X?$(n)?Q(n):_(n):n.match(t)||[]}function za(t){var r=null==t?0:t.length,e=mi();return t=r?c(t,function(n){if("function"!=typeof n[1])throw new pl(en);return[e(n[0]),n[1]]}):[],uu(function(e){for(var u=-1;++u<r;){var i=t[u];if(n(i[0],this,e))return n(i[1],this,e)}})}function Ea(n){
return Nr(Fr(n,an))}function Sa(n){return function(){return n}}function Wa(n,t){return null==n||n!==n?t:n}function La(n){return n}function Ca(n){return De("function"==typeof n?n:Fr(n,an))}function Ua(n){return qe(Fr(n,an))}function Ba(n,t){return Ze(n,Fr(t,an))}function Ta(n,t,e){var u=Pc(t),i=fe(t,u);null!=e||fc(t)&&(i.length||!u.length)||(e=t,t=n,n=this,i=fe(t,Pc(t)));var o=!(fc(e)&&"chain"in e&&!e.chain),f=uc(n);return r(i,function(r){var e=t[r];n[r]=e,f&&(n.prototype[r]=function(){var t=this.__chain__;
if(o||t){var r=n(this.__wrapped__);return(r.__actions__=Tu(this.__actions__)).push({func:e,args:arguments,thisArg:n}),r.__chain__=t,r}return e.apply(n,a([this.value()],arguments))})}),n}function $a(){return re._===this&&(re._=Al),this}function Da(){}function Ma(n){return n=kc(n),uu(function(t){return Ge(t,n)})}function Fa(n){return Bi(n)?m(no(n)):Qe(n)}function Na(n){return function(t){return null==n?X:_e(n,t)}}function Pa(){return[]}function qa(){return!1}function Za(){return{}}function Ka(){return"";
}function Va(){return!0}function Ga(n,t){if(n=kc(n),n<1||n>Wn)return[];var r=Un,e=Hl(n,Un);t=mi(t),n-=Un;for(var u=O(e,t);++r<n;)t(r);return u}function Ha(n){return bh(n)?c(n,no):bc(n)?[n]:Tu(Cs(Ec(n)))}function Ja(n){var t=++wl;return Ec(n)+t}function Ya(n){return n&&n.length?Yr(n,La,me):X}function Qa(n,t){return n&&n.length?Yr(n,mi(t,2),me):X}function Xa(n){return w(n,La)}function nl(n,t){return w(n,mi(t,2))}function tl(n){return n&&n.length?Yr(n,La,Ne):X}function rl(n,t){return n&&n.length?Yr(n,mi(t,2),Ne):X;
}function el(n){return n&&n.length?k(n,La):0}function ul(n,t){return n&&n.length?k(n,mi(t,2)):0}x=null==x?re:be.defaults(re.Object(),x,be.pick(re,qr));var il=x.Array,ol=x.Date,fl=x.Error,cl=x.Function,al=x.Math,ll=x.Object,sl=x.RegExp,hl=x.String,pl=x.TypeError,_l=il.prototype,vl=cl.prototype,gl=ll.prototype,yl=x["__core-js_shared__"],dl=vl.toString,bl=gl.hasOwnProperty,wl=0,ml=function(){var n=/[^.]+$/.exec(yl&&yl.keys&&yl.keys.IE_PROTO||"");return n?"Symbol(src)_1."+n:""}(),xl=gl.toString,jl=dl.call(ll),Al=re._,kl=sl("^"+dl.call(bl).replace(St,"\\$&").replace(/hasOwnProperty|(function).*?(?=\\\()| for .+?(?=\\\])/g,"$1.*?")+"$"),Ol=ie?x.Buffer:X,Il=x.Symbol,Rl=x.Uint8Array,zl=Ol?Ol.allocUnsafe:X,El=F(ll.getPrototypeOf,ll),Sl=ll.create,Wl=gl.propertyIsEnumerable,Ll=_l.splice,Cl=Il?Il.isConcatSpreadable:X,Ul=Il?Il.iterator:X,Bl=Il?Il.toStringTag:X,Tl=function(){
try{var n=Ai(ll,"defineProperty");return n({},"",{}),n}catch(n){}}(),$l=x.clearTimeout!==re.clearTimeout&&x.clearTimeout,Dl=ol&&ol.now!==re.Date.now&&ol.now,Ml=x.setTimeout!==re.setTimeout&&x.setTimeout,Fl=al.ceil,Nl=al.floor,Pl=ll.getOwnPropertySymbols,ql=Ol?Ol.isBuffer:X,Zl=x.isFinite,Kl=_l.join,Vl=F(ll.keys,ll),Gl=al.max,Hl=al.min,Jl=ol.now,Yl=x.parseInt,Ql=al.random,Xl=_l.reverse,ns=Ai(x,"DataView"),ts=Ai(x,"Map"),rs=Ai(x,"Promise"),es=Ai(x,"Set"),us=Ai(x,"WeakMap"),is=Ai(ll,"create"),os=us&&new us,fs={},cs=to(ns),as=to(ts),ls=to(rs),ss=to(es),hs=to(us),ps=Il?Il.prototype:X,_s=ps?ps.valueOf:X,vs=ps?ps.toString:X,gs=function(){
function n(){}return function(t){if(!fc(t))return{};if(Sl)return Sl(t);n.prototype=t;var r=new n;return n.prototype=X,r}}();Z.templateSettings={escape:kt,evaluate:Ot,interpolate:It,variable:"",imports:{_:Z}},Z.prototype=J.prototype,Z.prototype.constructor=Z,Y.prototype=gs(J.prototype),Y.prototype.constructor=Y,Ct.prototype=gs(J.prototype),Ct.prototype.constructor=Ct,Xt.prototype.clear=nr,Xt.prototype.delete=tr,Xt.prototype.get=rr,Xt.prototype.has=er,Xt.prototype.set=ur,ir.prototype.clear=or,ir.prototype.delete=fr,
ir.prototype.get=cr,ir.prototype.has=ar,ir.prototype.set=lr,sr.prototype.clear=hr,sr.prototype.delete=pr,sr.prototype.get=_r,sr.prototype.has=vr,sr.prototype.set=gr,yr.prototype.add=yr.prototype.push=dr,yr.prototype.has=br,wr.prototype.clear=mr,wr.prototype.delete=xr,wr.prototype.get=jr,wr.prototype.has=Ar,wr.prototype.set=kr;var ys=Pu(ue),ds=Pu(oe,!0),bs=qu(),ws=qu(!0),ms=os?function(n,t){return os.set(n,t),n}:La,xs=Tl?function(n,t){return Tl(n,"toString",{configurable:!0,enumerable:!1,value:Sa(t),
writable:!0})}:La,js=uu,As=$l||function(n){return re.clearTimeout(n)},ks=es&&1/P(new es([,-0]))[1]==Sn?function(n){return new es(n)}:Da,Os=os?function(n){return os.get(n)}:Da,Is=Pl?function(n){return null==n?[]:(n=ll(n),i(Pl(n),function(t){return Wl.call(n,t)}))}:Pa,Rs=Pl?function(n){for(var t=[];n;)a(t,Is(n)),n=El(n);return t}:Pa,zs=we;(ns&&zs(new ns(new ArrayBuffer(1)))!=ct||ts&&zs(new ts)!=Gn||rs&&zs(rs.resolve())!=Qn||es&&zs(new es)!=tt||us&&zs(new us)!=it)&&(zs=function(n){var t=we(n),r=t==Yn?n.constructor:X,e=r?to(r):"";
if(e)switch(e){case cs:return ct;case as:return Gn;case ls:return Qn;case ss:return tt;case hs:return it}return t});var Es=yl?uc:qa,Ss=Qi(ms),Ws=Ml||function(n,t){return re.setTimeout(n,t)},Ls=Qi(xs),Cs=Pi(function(n){var t=[];return 46===n.charCodeAt(0)&&t.push(""),n.replace(Et,function(n,r,e,u){t.push(e?u.replace(Mt,"$1"):r||n)}),t}),Us=uu(function(n,t){return Jf(n)?Hr(n,ee(t,1,Jf,!0)):[]}),Bs=uu(function(n,t){var r=jo(t);return Jf(r)&&(r=X),Jf(n)?Hr(n,ee(t,1,Jf,!0),mi(r,2)):[]}),Ts=uu(function(n,t){
var r=jo(t);return Jf(r)&&(r=X),Jf(n)?Hr(n,ee(t,1,Jf,!0),X,r):[]}),$s=uu(function(n){var t=c(n,ju);return t.length&&t[0]===n[0]?ke(t):[]}),Ds=uu(function(n){var t=jo(n),r=c(n,ju);return t===jo(r)?t=X:r.pop(),r.length&&r[0]===n[0]?ke(r,mi(t,2)):[]}),Ms=uu(function(n){var t=jo(n),r=c(n,ju);return t="function"==typeof t?t:X,t&&r.pop(),r.length&&r[0]===n[0]?ke(r,X,t):[]}),Fs=uu(Oo),Ns=gi(function(n,t){var r=null==n?0:n.length,e=Tr(n,t);return nu(n,c(t,function(n){return Ci(n,r)?+n:n}).sort(Lu)),e}),Ps=uu(function(n){
return gu(ee(n,1,Jf,!0))}),qs=uu(function(n){var t=jo(n);return Jf(t)&&(t=X),gu(ee(n,1,Jf,!0),mi(t,2))}),Zs=uu(function(n){var t=jo(n);return t="function"==typeof t?t:X,gu(ee(n,1,Jf,!0),X,t)}),Ks=uu(function(n,t){return Jf(n)?Hr(n,t):[]}),Vs=uu(function(n){return mu(i(n,Jf))}),Gs=uu(function(n){var t=jo(n);return Jf(t)&&(t=X),mu(i(n,Jf),mi(t,2))}),Hs=uu(function(n){var t=jo(n);return t="function"==typeof t?t:X,mu(i(n,Jf),X,t)}),Js=uu(Go),Ys=uu(function(n){var t=n.length,r=t>1?n[t-1]:X;return r="function"==typeof r?(n.pop(),
r):X,Ho(n,r)}),Qs=gi(function(n){var t=n.length,r=t?n[0]:0,e=this.__wrapped__,u=function(t){return Tr(t,n)};return!(t>1||this.__actions__.length)&&e instanceof Ct&&Ci(r)?(e=e.slice(r,+r+(t?1:0)),e.__actions__.push({func:nf,args:[u],thisArg:X}),new Y(e,this.__chain__).thru(function(n){return t&&!n.length&&n.push(X),n})):this.thru(u)}),Xs=Fu(function(n,t,r){bl.call(n,r)?++n[r]:Br(n,r,1)}),nh=Ju(ho),th=Ju(po),rh=Fu(function(n,t,r){bl.call(n,r)?n[r].push(t):Br(n,r,[t])}),eh=uu(function(t,r,e){var u=-1,i="function"==typeof r,o=Hf(t)?il(t.length):[];
return ys(t,function(t){o[++u]=i?n(r,t,e):Ie(t,r,e)}),o}),uh=Fu(function(n,t,r){Br(n,r,t)}),ih=Fu(function(n,t,r){n[r?0:1].push(t)},function(){return[[],[]]}),oh=uu(function(n,t){if(null==n)return[];var r=t.length;return r>1&&Ui(n,t[0],t[1])?t=[]:r>2&&Ui(t[0],t[1],t[2])&&(t=[t[0]]),He(n,ee(t,1),[])}),fh=Dl||function(){return re.Date.now()},ch=uu(function(n,t,r){var e=_n;if(r.length){var u=N(r,wi(ch));e|=bn}return ai(n,e,t,r,u)}),ah=uu(function(n,t,r){var e=_n|vn;if(r.length){var u=N(r,wi(ah));e|=bn;
}return ai(t,e,n,r,u)}),lh=uu(function(n,t){return Gr(n,1,t)}),sh=uu(function(n,t,r){return Gr(n,Ic(t)||0,r)});Cf.Cache=sr;var hh=js(function(t,r){r=1==r.length&&bh(r[0])?c(r[0],z(mi())):c(ee(r,1),z(mi()));var e=r.length;return uu(function(u){for(var i=-1,o=Hl(u.length,e);++i<o;)u[i]=r[i].call(this,u[i]);return n(t,this,u)})}),ph=uu(function(n,t){return ai(n,bn,X,t,N(t,wi(ph)))}),_h=uu(function(n,t){return ai(n,wn,X,t,N(t,wi(_h)))}),vh=gi(function(n,t){return ai(n,xn,X,X,X,t)}),gh=ii(me),yh=ii(function(n,t){
return n>=t}),dh=Re(function(){return arguments}())?Re:function(n){return cc(n)&&bl.call(n,"callee")&&!Wl.call(n,"callee")},bh=il.isArray,wh=ce?z(ce):ze,mh=ql||qa,xh=ae?z(ae):Ee,jh=le?z(le):Le,Ah=se?z(se):Be,kh=he?z(he):Te,Oh=pe?z(pe):$e,Ih=ii(Ne),Rh=ii(function(n,t){return n<=t}),zh=Nu(function(n,t){if(Mi(t)||Hf(t))return $u(t,Pc(t),n),X;for(var r in t)bl.call(t,r)&&Sr(n,r,t[r])}),Eh=Nu(function(n,t){$u(t,qc(t),n)}),Sh=Nu(function(n,t,r,e){$u(t,qc(t),n,e)}),Wh=Nu(function(n,t,r,e){$u(t,Pc(t),n,e);
}),Lh=gi(Tr),Ch=uu(function(n,t){n=ll(n);var r=-1,e=t.length,u=e>2?t[2]:X;for(u&&Ui(t[0],t[1],u)&&(e=1);++r<e;)for(var i=t[r],o=qc(i),f=-1,c=o.length;++f<c;){var a=o[f],l=n[a];(l===X||Gf(l,gl[a])&&!bl.call(n,a))&&(n[a]=i[a])}return n}),Uh=uu(function(t){return t.push(X,si),n(Mh,X,t)}),Bh=Xu(function(n,t,r){null!=t&&"function"!=typeof t.toString&&(t=xl.call(t)),n[t]=r},Sa(La)),Th=Xu(function(n,t,r){null!=t&&"function"!=typeof t.toString&&(t=xl.call(t)),bl.call(n,t)?n[t].push(r):n[t]=[r]},mi),$h=uu(Ie),Dh=Nu(function(n,t,r){
Ke(n,t,r)}),Mh=Nu(function(n,t,r,e){Ke(n,t,r,e)}),Fh=gi(function(n,t){var r={};if(null==n)return r;var e=!1;t=c(t,function(t){return t=ku(t,n),e||(e=t.length>1),t}),$u(n,di(n),r),e&&(r=Fr(r,an|ln|sn,hi));for(var u=t.length;u--;)yu(r,t[u]);return r}),Nh=gi(function(n,t){return null==n?{}:Je(n,t)}),Ph=ci(Pc),qh=ci(qc),Zh=Vu(function(n,t,r){return t=t.toLowerCase(),n+(r?fa(t):t)}),Kh=Vu(function(n,t,r){return n+(r?"-":"")+t.toLowerCase()}),Vh=Vu(function(n,t,r){return n+(r?" ":"")+t.toLowerCase()}),Gh=Ku("toLowerCase"),Hh=Vu(function(n,t,r){
return n+(r?"_":"")+t.toLowerCase()}),Jh=Vu(function(n,t,r){return n+(r?" ":"")+Qh(t)}),Yh=Vu(function(n,t,r){return n+(r?" ":"")+t.toUpperCase()}),Qh=Ku("toUpperCase"),Xh=uu(function(t,r){try{return n(t,X,r)}catch(n){return rc(n)?n:new fl(n)}}),np=gi(function(n,t){return r(t,function(t){t=no(t),Br(n,t,ch(n[t],n))}),n}),tp=Yu(),rp=Yu(!0),ep=uu(function(n,t){return function(r){return Ie(r,n,t)}}),up=uu(function(n,t){return function(r){return Ie(n,r,t)}}),ip=ti(c),op=ti(u),fp=ti(h),cp=ui(),ap=ui(!0),lp=ni(function(n,t){
return n+t},0),sp=fi("ceil"),hp=ni(function(n,t){return n/t},1),pp=fi("floor"),_p=ni(function(n,t){return n*t},1),vp=fi("round"),gp=ni(function(n,t){return n-t},0);return Z.after=If,Z.ary=Rf,Z.assign=zh,Z.assignIn=Eh,Z.assignInWith=Sh,Z.assignWith=Wh,Z.at=Lh,Z.before=zf,Z.bind=ch,Z.bindAll=np,Z.bindKey=ah,Z.castArray=Nf,Z.chain=Qo,Z.chunk=uo,Z.compact=io,Z.concat=oo,Z.cond=za,Z.conforms=Ea,Z.constant=Sa,Z.countBy=Xs,Z.create=Sc,Z.curry=Ef,Z.curryRight=Sf,Z.debounce=Wf,Z.defaults=Ch,Z.defaultsDeep=Uh,
Z.defer=lh,Z.delay=sh,Z.difference=Us,Z.differenceBy=Bs,Z.differenceWith=Ts,Z.drop=fo,Z.dropRight=co,Z.dropRightWhile=ao,Z.dropWhile=lo,Z.fill=so,Z.filter=lf,Z.flatMap=sf,Z.flatMapDeep=hf,Z.flatMapDepth=pf,Z.flatten=_o,Z.flattenDeep=vo,Z.flattenDepth=go,Z.flip=Lf,Z.flow=tp,Z.flowRight=rp,Z.fromPairs=yo,Z.functions=$c,Z.functionsIn=Dc,Z.groupBy=rh,Z.initial=mo,Z.intersection=$s,Z.intersectionBy=Ds,Z.intersectionWith=Ms,Z.invert=Bh,Z.invertBy=Th,Z.invokeMap=eh,Z.iteratee=Ca,Z.keyBy=uh,Z.keys=Pc,Z.keysIn=qc,
Z.map=yf,Z.mapKeys=Zc,Z.mapValues=Kc,Z.matches=Ua,Z.matchesProperty=Ba,Z.memoize=Cf,Z.merge=Dh,Z.mergeWith=Mh,Z.method=ep,Z.methodOf=up,Z.mixin=Ta,Z.negate=Uf,Z.nthArg=Ma,Z.omit=Fh,Z.omitBy=Vc,Z.once=Bf,Z.orderBy=df,Z.over=ip,Z.overArgs=hh,Z.overEvery=op,Z.overSome=fp,Z.partial=ph,Z.partialRight=_h,Z.partition=ih,Z.pick=Nh,Z.pickBy=Gc,Z.property=Fa,Z.propertyOf=Na,Z.pull=Fs,Z.pullAll=Oo,Z.pullAllBy=Io,Z.pullAllWith=Ro,Z.pullAt=Ns,Z.range=cp,Z.rangeRight=ap,Z.rearg=vh,Z.reject=mf,Z.remove=zo,Z.rest=Tf,
Z.reverse=Eo,Z.sampleSize=jf,Z.set=Jc,Z.setWith=Yc,Z.shuffle=Af,Z.slice=So,Z.sortBy=oh,Z.sortedUniq=$o,Z.sortedUniqBy=Do,Z.split=da,Z.spread=$f,Z.tail=Mo,Z.take=Fo,Z.takeRight=No,Z.takeRightWhile=Po,Z.takeWhile=qo,Z.tap=Xo,Z.throttle=Df,Z.thru=nf,Z.toArray=jc,Z.toPairs=Ph,Z.toPairsIn=qh,Z.toPath=Ha,Z.toPlainObject=Rc,Z.transform=Qc,Z.unary=Mf,Z.union=Ps,Z.unionBy=qs,Z.unionWith=Zs,Z.uniq=Zo,Z.uniqBy=Ko,Z.uniqWith=Vo,Z.unset=Xc,Z.unzip=Go,Z.unzipWith=Ho,Z.update=na,Z.updateWith=ta,Z.values=ra,Z.valuesIn=ea,
Z.without=Ks,Z.words=Ra,Z.wrap=Ff,Z.xor=Vs,Z.xorBy=Gs,Z.xorWith=Hs,Z.zip=Js,Z.zipObject=Jo,Z.zipObjectDeep=Yo,Z.zipWith=Ys,Z.entries=Ph,Z.entriesIn=qh,Z.extend=Eh,Z.extendWith=Sh,Ta(Z,Z),Z.add=lp,Z.attempt=Xh,Z.camelCase=Zh,Z.capitalize=fa,Z.ceil=sp,Z.clamp=ua,Z.clone=Pf,Z.cloneDeep=Zf,Z.cloneDeepWith=Kf,Z.cloneWith=qf,Z.conformsTo=Vf,Z.deburr=ca,Z.defaultTo=Wa,Z.divide=hp,Z.endsWith=aa,Z.eq=Gf,Z.escape=la,Z.escapeRegExp=sa,Z.every=af,Z.find=nh,Z.findIndex=ho,Z.findKey=Wc,Z.findLast=th,Z.findLastIndex=po,
Z.findLastKey=Lc,Z.floor=pp,Z.forEach=_f,Z.forEachRight=vf,Z.forIn=Cc,Z.forInRight=Uc,Z.forOwn=Bc,Z.forOwnRight=Tc,Z.get=Mc,Z.gt=gh,Z.gte=yh,Z.has=Fc,Z.hasIn=Nc,Z.head=bo,Z.identity=La,Z.includes=gf,Z.indexOf=wo,Z.inRange=ia,Z.invoke=$h,Z.isArguments=dh,Z.isArray=bh,Z.isArrayBuffer=wh,Z.isArrayLike=Hf,Z.isArrayLikeObject=Jf,Z.isBoolean=Yf,Z.isBuffer=mh,Z.isDate=xh,Z.isElement=Qf,Z.isEmpty=Xf,Z.isEqual=nc,Z.isEqualWith=tc,Z.isError=rc,Z.isFinite=ec,Z.isFunction=uc,Z.isInteger=ic,Z.isLength=oc,Z.isMap=jh,
Z.isMatch=ac,Z.isMatchWith=lc,Z.isNaN=sc,Z.isNative=hc,Z.isNil=_c,Z.isNull=pc,Z.isNumber=vc,Z.isObject=fc,Z.isObjectLike=cc,Z.isPlainObject=gc,Z.isRegExp=Ah,Z.isSafeInteger=yc,Z.isSet=kh,Z.isString=dc,Z.isSymbol=bc,Z.isTypedArray=Oh,Z.isUndefined=wc,Z.isWeakMap=mc,Z.isWeakSet=xc,Z.join=xo,Z.kebabCase=Kh,Z.last=jo,Z.lastIndexOf=Ao,Z.lowerCase=Vh,Z.lowerFirst=Gh,Z.lt=Ih,Z.lte=Rh,Z.max=Ya,Z.maxBy=Qa,Z.mean=Xa,Z.meanBy=nl,Z.min=tl,Z.minBy=rl,Z.stubArray=Pa,Z.stubFalse=qa,Z.stubObject=Za,Z.stubString=Ka,
Z.stubTrue=Va,Z.multiply=_p,Z.nth=ko,Z.noConflict=$a,Z.noop=Da,Z.now=fh,Z.pad=ha,Z.padEnd=pa,Z.padStart=_a,Z.parseInt=va,Z.random=oa,Z.reduce=bf,Z.reduceRight=wf,Z.repeat=ga,Z.replace=ya,Z.result=Hc,Z.round=vp,Z.runInContext=p,Z.sample=xf,Z.size=kf,Z.snakeCase=Hh,Z.some=Of,Z.sortedIndex=Wo,Z.sortedIndexBy=Lo,Z.sortedIndexOf=Co,Z.sortedLastIndex=Uo,Z.sortedLastIndexBy=Bo,Z.sortedLastIndexOf=To,Z.startCase=Jh,Z.startsWith=ba,Z.subtract=gp,Z.sum=el,Z.sumBy=ul,Z.template=wa,Z.times=Ga,Z.toFinite=Ac,Z.toInteger=kc,
Z.toLength=Oc,Z.toLower=ma,Z.toNumber=Ic,Z.toSafeInteger=zc,Z.toString=Ec,Z.toUpper=xa,Z.trim=ja,Z.trimEnd=Aa,Z.trimStart=ka,Z.truncate=Oa,Z.unescape=Ia,Z.uniqueId=Ja,Z.upperCase=Yh,Z.upperFirst=Qh,Z.each=_f,Z.eachRight=vf,Z.first=bo,Ta(Z,function(){var n={};return ue(Z,function(t,r){bl.call(Z.prototype,r)||(n[r]=t)}),n}(),{chain:!1}),Z.VERSION=nn,r(["bind","bindKey","curry","curryRight","partial","partialRight"],function(n){Z[n].placeholder=Z}),r(["drop","take"],function(n,t){Ct.prototype[n]=function(r){
r=r===X?1:Gl(kc(r),0);var e=this.__filtered__&&!t?new Ct(this):this.clone();return e.__filtered__?e.__takeCount__=Hl(r,e.__takeCount__):e.__views__.push({size:Hl(r,Un),type:n+(e.__dir__<0?"Right":"")}),e},Ct.prototype[n+"Right"]=function(t){return this.reverse()[n](t).reverse()}}),r(["filter","map","takeWhile"],function(n,t){var r=t+1,e=r==Rn||r==En;Ct.prototype[n]=function(n){var t=this.clone();return t.__iteratees__.push({iteratee:mi(n,3),type:r}),t.__filtered__=t.__filtered__||e,t}}),r(["head","last"],function(n,t){
var r="take"+(t?"Right":"");Ct.prototype[n]=function(){return this[r](1).value()[0]}}),r(["initial","tail"],function(n,t){var r="drop"+(t?"":"Right");Ct.prototype[n]=function(){return this.__filtered__?new Ct(this):this[r](1)}}),Ct.prototype.compact=function(){return this.filter(La)},Ct.prototype.find=function(n){return this.filter(n).head()},Ct.prototype.findLast=function(n){return this.reverse().find(n)},Ct.prototype.invokeMap=uu(function(n,t){return"function"==typeof n?new Ct(this):this.map(function(r){
return Ie(r,n,t)})}),Ct.prototype.reject=function(n){return this.filter(Uf(mi(n)))},Ct.prototype.slice=function(n,t){n=kc(n);var r=this;return r.__filtered__&&(n>0||t<0)?new Ct(r):(n<0?r=r.takeRight(-n):n&&(r=r.drop(n)),t!==X&&(t=kc(t),r=t<0?r.dropRight(-t):r.take(t-n)),r)},Ct.prototype.takeRightWhile=function(n){return this.reverse().takeWhile(n).reverse()},Ct.prototype.toArray=function(){return this.take(Un)},ue(Ct.prototype,function(n,t){var r=/^(?:filter|find|map|reject)|While$/.test(t),e=/^(?:head|last)$/.test(t),u=Z[e?"take"+("last"==t?"Right":""):t],i=e||/^find/.test(t);
u&&(Z.prototype[t]=function(){var t=this.__wrapped__,o=e?[1]:arguments,f=t instanceof Ct,c=o[0],l=f||bh(t),s=function(n){var t=u.apply(Z,a([n],o));return e&&h?t[0]:t};l&&r&&"function"==typeof c&&1!=c.length&&(f=l=!1);var h=this.__chain__,p=!!this.__actions__.length,_=i&&!h,v=f&&!p;if(!i&&l){t=v?t:new Ct(this);var g=n.apply(t,o);return g.__actions__.push({func:nf,args:[s],thisArg:X}),new Y(g,h)}return _&&v?n.apply(this,o):(g=this.thru(s),_?e?g.value()[0]:g.value():g)})}),r(["pop","push","shift","sort","splice","unshift"],function(n){
var t=_l[n],r=/^(?:push|sort|unshift)$/.test(n)?"tap":"thru",e=/^(?:pop|shift)$/.test(n);Z.prototype[n]=function(){var n=arguments;if(e&&!this.__chain__){var u=this.value();return t.apply(bh(u)?u:[],n)}return this[r](function(r){return t.apply(bh(r)?r:[],n)})}}),ue(Ct.prototype,function(n,t){var r=Z[t];if(r){var e=r.name+"";bl.call(fs,e)||(fs[e]=[]),fs[e].push({name:t,func:r})}}),fs[Qu(X,vn).name]=[{name:"wrapper",func:X}],Ct.prototype.clone=$t,Ct.prototype.reverse=Yt,Ct.prototype.value=Qt,Z.prototype.at=Qs,
Z.prototype.chain=tf,Z.prototype.commit=rf,Z.prototype.next=ef,Z.prototype.plant=of,Z.prototype.reverse=ff,Z.prototype.toJSON=Z.prototype.valueOf=Z.prototype.value=cf,Z.prototype.first=Z.prototype.head,Ul&&(Z.prototype[Ul]=uf),Z},be=de();"function"==typeof define&&"object"==typeof define.amd&&define.amd?(re._=be,define(function(){return be})):ue?((ue.exports=be)._=be,ee._=be):re._=be}).call(this);
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+95
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?e(exports,require("@antv/x6")):"function"==typeof define&&define.amd?define(["exports","@antv/x6"],e):e((t="undefined"!=typeof globalThis?globalThis:t||self).X6PluginClipboard={},t.X6)}(this,(function(t,e){"use strict";class i{constructor(){this.cells=[]}copy(t,i,s={}){this.options=Object.assign({},s);const o=(e.Model.isModel(i)?i:i.model).cloneSubGraph(t,s);this.cells=e.ArrayExt.sortBy(Object.keys(o).map((t=>o[t])),(t=>t.isEdge()?2:1)),this.serialize(s)}cut(t,i,s={}){this.copy(t,i,s);(e.Graph.isGraph(i)?i.model:i).batchUpdate("cut",(()=>{t.forEach((t=>t.remove()))}))}paste(t,i={}){const s=Object.assign(Object.assign({},this.options),i),{offset:o,edgeProps:n,nodeProps:l}=s;let r=20,a=20;o&&(r="number"==typeof o?o:o.dx,a="number"==typeof o?o:o.dy),this.deserialize(s);const c=this.cells;c.forEach((t=>{t.model=null,t.removeProp("zIndex"),(r||a)&&t.translate(r,a),l&&t.isNode()&&t.prop(l),n&&t.isEdge()&&t.prop(n)}));const p=e.Graph.isGraph(t)?t.model:t;return p.batchUpdate("paste",(()=>{p.addCells(this.cells)})),this.copy(c,t,i),c}serialize(t){!1!==t.useLocalStorage&&s.save(this.cells)}deserialize(t){if(t.useLocalStorage){const t=s.fetch();t&&(this.cells=t)}}isEmpty(t={}){return t.useLocalStorage&&this.deserialize(t),this.cells.length<=0}clean(){this.options={},this.cells=[],s.clean()}}var s;!function(t){const i=`${e.Config.prefixCls}.clipboard.cells`;t.save=function(t){if(window.localStorage){const e=t.map((t=>t.toJSON()));localStorage.setItem(i,JSON.stringify(e))}},t.fetch=function(){if(window.localStorage){const t=localStorage.getItem(i),s=t?JSON.parse(t):[];if(s)return e.Model.fromJSON(s)}},t.clean=function(){window.localStorage&&localStorage.removeItem(i)}}(s||(s={})),e.Graph.prototype.isClipboardEnabled=function(){const t=this.getPlugin("clipboard");return!!t&&t.isEnabled()},e.Graph.prototype.enableClipboard=function(){const t=this.getPlugin("clipboard");return t&&t.enable(),this},e.Graph.prototype.disableClipboard=function(){const t=this.getPlugin("clipboard");return t&&t.disable(),this},e.Graph.prototype.toggleClipboard=function(t){const e=this.getPlugin("clipboard");return e&&e.toggleEnabled(t),this},e.Graph.prototype.isClipboardEmpty=function(t){const e=this.getPlugin("clipboard");return!e||e.isEmpty(t)},e.Graph.prototype.getCellsInClipboard=function(){const t=this.getPlugin("clipboard");return t?t.getCellsInClipboard():[]},e.Graph.prototype.cleanClipboard=function(){const t=this.getPlugin("clipboard");return t&&t.clean(),this},e.Graph.prototype.copy=function(t,e){const i=this.getPlugin("clipboard");return i&&i.copy(t,e),this},e.Graph.prototype.cut=function(t,e){const i=this.getPlugin("clipboard");return i&&i.cut(t,e),this},e.Graph.prototype.paste=function(t,e){const i=this.getPlugin("clipboard");return i?i.paste(t,e):[]};class o extends e.Basecoat{get disabled(){return!0!==this.options.enabled}get cells(){return this.clipboardImpl.cells}constructor(t={}){super(),this.name="clipboard",this.options=Object.assign({enabled:!0},t)}init(t){this.graph=t,this.clipboardImpl=new i,this.clipboardImpl.deserialize(this.options)}isEnabled(){return!this.disabled}enable(){this.disabled&&(this.options.enabled=!0)}disable(){this.disabled||(this.options.enabled=!1)}toggleEnabled(t){return null!=t?t!==this.isEnabled()&&(t?this.enable():this.disable()):this.isEnabled()?this.disable():this.enable(),this}isEmpty(t={}){return this.clipboardImpl.isEmpty(t)}getCellsInClipboard(){return this.cells}clean(t){return this.disabled&&!t||(this.clipboardImpl.clean(),this.notify("clipboard:changed",{cells:[]})),this}copy(t,e={}){return this.disabled||(this.clipboardImpl.copy(t,this.graph,Object.assign(Object.assign({},this.commonOptions),e)),this.notify("clipboard:changed",{cells:t})),this}cut(t,e={}){return this.disabled||(this.clipboardImpl.cut(t,this.graph,Object.assign(Object.assign({},this.commonOptions),e)),this.notify("clipboard:changed",{cells:t})),this}paste(t={},e=this.graph){return this.disabled?[]:this.clipboardImpl.paste(e,Object.assign(Object.assign({},this.commonOptions),t))}get commonOptions(){return function(t,e){var i={};for(var s in t)Object.prototype.hasOwnProperty.call(t,s)&&e.indexOf(s)<0&&(i[s]=t[s]);if(null!=t&&"function"==typeof Object.getOwnPropertySymbols){var o=0;for(s=Object.getOwnPropertySymbols(t);o<s.length;o++)e.indexOf(s[o])<0&&Object.prototype.propertyIsEnumerable.call(t,s[o])&&(i[s[o]]=t[s[o]])}return i}(this.options,["enabled"])}notify(t,e){this.trigger(t,e),this.graph.trigger(t,e)}dispose(){this.clean(!0),this.off()}}!function(t,e,i,s){var o,n=arguments.length,l=n<3?e:null===s?s=Object.getOwnPropertyDescriptor(e,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)l=Reflect.decorate(t,e,i,s);else for(var r=t.length-1;r>=0;r--)(o=t[r])&&(l=(n<3?o(l):n>3?o(e,i,l):o(e,i))||l);n>3&&l&&Object.defineProperty(e,i,l)}([e.Basecoat.dispose()],o.prototype,"dispose",null),t.Clipboard=o}));
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+17
View File
@@ -0,0 +1,17 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* fetchAssets - Fetches the complete asset and pack list from the server
*/
export async function fetchAssets() {
try {
const response = await fetch('/assets');
const data = await response.json();
state.assetsData = data.assets || [];
return data;
} catch (e) {
logger.critical("Failed to fetch assets:", e);
return { assets: [], packs: [] };
}
}
+110
View File
@@ -0,0 +1,110 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
export const FIXED_ASSETS = [
{ id: 'fixed-group', type: 'group', label: 'Group Container', icon: 'group.png', is_img: true },
{ id: 'fixed-rect', type: 'rect', label: 'Rectangle', icon: 'far fa-square', is_img: false },
{ id: 'fixed-rounded-rect', type: 'rounded-rect', label: 'Rounded Rectangle', icon: 'fas fa-vector-square', is_img: false },
{ id: 'fixed-circle', type: 'circle', label: 'Circle', icon: 'far fa-circle', is_img: false },
{ id: 'fixed-text', type: 'text-box', label: 'Text Box', icon: 'fas fa-font', is_img: false },
{ id: 'fixed-label', type: 'label', label: 'Label', icon: 'fas fa-tag', is_img: false },
{ id: 'fixed-blank', type: 'blank', label: 'Blank Point (Invisible)', icon: 'fas fa-crosshairs', is_img: false, extra_style: 'opacity: 0.5;' },
{ id: 'fixed-dot', type: 'dot', label: 'Dot (Small visible point)', icon: 'fas fa-circle', is_img: false, extra_style: 'font-size: 6px;' },
{ id: 'fixed-ellipse', type: 'ellipse', label: 'Ellipse', icon: 'fas fa-egg', is_img: false, extra_style: 'transform: rotate(-45deg);' },
{ id: 'fixed-polyline', type: 'polyline', label: 'Polyline', icon: 'fas fa-chart-line', is_img: false },
{ id: 'fixed-browser', type: 'browser', label: 'Browser', icon: 'fas fa-window-maximize', is_img: false },
{ id: 'fixed-user', type: 'user', label: 'User', icon: 'fas fa-user', is_img: false },
{ id: 'fixed-admin', type: 'admin', label: 'Admin', icon: 'fas fa-user-shield', is_img: false },
{ id: 'fixed-triangle', type: 'triangle', label: 'Triangle', icon: 'fas fa-caret-up', is_img: false, extra_style: 'font-size: 22px; margin-top: -2px;' },
{ id: 'fixed-diamond', type: 'diamond', label: 'Diamond', icon: 'fas fa-square', is_img: false, extra_style: 'transform: rotate(45deg) scale(0.8);' },
{ id: 'fixed-parallelogram', type: 'parallelogram', label: 'Parallelogram', icon: 'fas fa-square', is_img: false, extra_style: 'transform: skewX(-20deg) scaleX(1.2);' },
{ id: 'fixed-cylinder', type: 'cylinder', label: 'Cylinder', icon: 'fas fa-database', is_img: false },
{ id: 'fixed-document', type: 'document', label: 'Document', icon: 'fas fa-file-alt', is_img: false },
{ id: 'fixed-manual-input', type: 'manual-input', label: 'Manual Input', icon: 'fas fa-keyboard', is_img: false },
{ id: 'fixed-table', type: 'table', label: 'Table', icon: 'fas fa-table', is_img: false },
{ id: 'fixed-rack', type: 'rack', label: 'Rack', icon: 'fas fa-columns', is_img: false },
{ id: 'fixed-hourglass', type: 'hourglass', label: 'Hourglass', icon: 'fas fa-hourglass-half', is_img: false },
{ id: 'fixed-arrow-up', type: 'arrow-up', label: 'Arrow Up', icon: 'fas fa-arrow-up', is_img: false },
{ id: 'fixed-arrow-down', type: 'arrow-down', label: 'Arrow Down', icon: 'fas fa-arrow-down', is_img: false },
{ id: 'fixed-arrow-left', type: 'arrow-left', label: 'Arrow Left', icon: 'fas fa-arrow-left', is_img: false },
{ id: 'fixed-arrow-right', type: 'arrow-right', label: 'Arrow Right', icon: 'fas fa-arrow-right', is_img: false },
{ id: 'fixed-rich-card', type: 'rich-card', label: t('rich_card') || 'Rich Text Card', icon: 'fas fa-address-card', is_img: false }
];
export function renderFixedSection(container) {
const fixedSection = document.createElement('div');
fixedSection.className = 'asset-category fixed-category';
const title = t('fixed_objects') || 'Fixed Objects';
fixedSection.innerHTML = `
<div class="category-header">
<span data-i18n="fixed_objects">${title}</span>
<i class="fas fa-chevron-down"></i>
</div>
<div class="category-collapsed-icon" title="${title}">
<i class="fas fa-layer-group" style="font-size: 20px; color: #64748b;"></i>
</div>
<div class="category-content">
<div class="asset-grid"></div>
</div>
<div class="category-divider" style="height: 1px; background: rgba(255,255,255,0.1); margin: 15px 10px;"></div>
`;
// Toggle Logic
const header = fixedSection.querySelector('.category-header');
header.addEventListener('click', () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
fixedSection.classList.toggle('active');
}
});
// Handle Sidebar Collapsed State (Flyout)
const collapsedIcon = fixedSection.querySelector('.category-collapsed-icon');
collapsedIcon.addEventListener('click', (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
// Since we don't want to import renderFlyout here to avoid circular dependencies
// we'll dispatch a custom event or check if it's available globally (though not recommended)
// or just let assets.js handle it if we restructure.
// For now, let's trigger a click on a hidden flyout handler or use state.
const event = new CustomEvent('render-fixed-flyout', {
detail: {
container: fixedSection,
assets: FIXED_ASSETS,
meta: { id: 'fixed_objects', label: title }
}
});
document.dispatchEvent(event);
}
});
const grid = fixedSection.querySelector('.asset-grid');
FIXED_ASSETS.forEach(asset => {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type;
item.dataset.assetId = asset.id;
item.title = asset.label;
if (asset.is_img) {
item.innerHTML = `<img src="/static/assets/${asset.icon}" width="22" height="22" loading="lazy">`;
} else {
item.innerHTML = `<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', asset.id);
state.assetMap[asset.id] = {
id: asset.id,
type: asset.type,
path: asset.is_img ? asset.icon : '',
label: asset.label
};
});
grid.appendChild(item);
});
container.appendChild(fixedSection);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { fetchAssets } from './api.js';
import { renderLibrary, createAssetElement, renderFlyout } from './renderer.js';
import { renderPackSelector } from './selector.js';
import { initSearch } from './search.js';
import { renderFixedSection } from './fixed_objects.js';
import { logger } from '../utils/logger.js';
/**
* initAssets - Main initialization for the asset library sidebar
*/
export async function initAssets() {
try {
const data = await fetchAssets();
const library = document.querySelector('.asset-library');
if (!library) return;
// 1. Initial Header & Global UI
library.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; margin: 24px 16px 12px; border-bottom: 1px solid rgba(255,255,255,0.05); padding-bottom: 8px;">
<h3 style="margin:0; font-size: 10px; text-transform: uppercase; letter-spacing: 1.5px; color: var(--sub-text); font-weight: 800;">
${t('asset_library') || 'Asset Library'}
</h3>
<button id="manage-packs-btn" style="background:none; border:none; color: var(--sub-text); cursor:pointer; font-size: 12px; transition: color 0.2s;" title="Manage Packages">
<i class="fas fa-filter"></i>
</button>
</div>
`;
// 2. Setup Manage Packs Button
document.getElementById('manage-packs-btn').onclick = (e) => {
e.stopPropagation();
renderPackSelector(data.packs || []);
};
// 3. Initialize selection if empty
if (state.selectedPackIds.length === 0 && data.packs && data.packs.length > 0) {
state.selectedPackIds = data.packs.map(p => p.id);
localStorage.setItem('selectedPackIds', JSON.stringify(state.selectedPackIds));
}
// 4. Render Sections
renderFixedSection(library);
renderLibrary({ ...data, state }, library, renderPackSelector);
// 5. Handle Flyouts for Fixed/Custom Sections via Event
document.addEventListener('render-fixed-flyout', (e) => {
const { container, assets, meta } = e.detail;
renderFlyout(container, assets, meta);
});
initSearch();
} catch (e) {
logger.critical("Asset Initialization Error:", e);
}
}
export { createAssetElement, renderFlyout, renderPackSelector };
+197
View File
@@ -0,0 +1,197 @@
import { t } from '../i18n.js';
import { DEFAULTS } from '../constants.js';
/**
* createAssetElement - Creates a single asset item DOM element
* @param {Object} asset
* @returns {HTMLElement}
*/
export function createAssetElement(asset, compositeId = null) {
const item = document.createElement('div');
item.className = 'asset-item';
item.draggable = true;
item.dataset.type = asset.type || asset.id;
item.dataset.assetId = compositeId || asset.id;
item.title = asset.label;
// Support legacy 'path', new 'views.icon' or FontAwesome icon class
const isFontAwesome = asset.is_img === false || (!asset.path && !asset.views && asset.icon && asset.icon.includes('fa-'));
if (isFontAwesome) {
item.innerHTML = `
<i class="${asset.icon}" style="font-size: 18px; color: #64748b; ${asset.extra_style || ''}"></i>
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
} else {
const iconPath = (asset.views && asset.views.icon) ? asset.views.icon : (asset.path || DEFAULTS.DEFAULT_ICON);
item.innerHTML = `
<img src="/static/assets/${iconPath}" width="24" height="24" loading="lazy">
<span>${t(asset.id) || asset.label || asset.id}</span>
`;
}
item.addEventListener('dragstart', (e) => {
e.dataTransfer.setData('assetId', item.dataset.assetId);
});
return item;
}
/**
* renderFlyout - Renders the floating category window when sidebar is collapsed
* @param {HTMLElement} container
* @param {Array} assets
* @param {Object} meta
*/
export function renderFlyout(container, assets, meta) {
document.querySelectorAll('.category-flyout').forEach(f => f.remove());
const flyout = document.createElement('div');
flyout.className = 'category-flyout glass-panel active';
const rect = container.getBoundingClientRect();
flyout.style.top = `${Math.max(10, Math.min(window.innerHeight - 400, rect.top))}px`;
flyout.style.left = '90px';
flyout.innerHTML = `
<div class="flyout-header">
<strong>${t(meta.id) || meta.name || meta.label}</strong>
<small>${assets.length} items</small>
</div>
<div class="flyout-content" style="max-height: 400px; overflow-y: auto;"></div>
`;
const content = flyout.querySelector('.flyout-content');
// Group by category for flyout as well
const groups = assets.reduce((acc, a) => {
const cat = a.category || 'Other';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(a);
return acc;
}, {});
Object.keys(groups).sort().forEach(catName => {
if (Object.keys(groups).length > 1 || catName !== 'Other') {
const label = document.createElement('div');
label.className = 'asset-group-label';
label.textContent = catName;
content.appendChild(label);
}
const grid = document.createElement('div');
grid.className = 'asset-grid flyout-grid';
groups[catName].forEach(asset => grid.appendChild(createAssetElement(asset)));
content.appendChild(grid);
});
document.body.appendChild(flyout);
const closeFlyout = (e) => {
if (!flyout.contains(e.target)) {
flyout.remove();
document.removeEventListener('click', closeFlyout);
}
};
setTimeout(() => document.addEventListener('click', closeFlyout), 10);
}
/**
* renderLibrary - The main loop that renders the entire asset library
*/
export function renderLibrary(data, library, renderPackSelector) {
const { state } = data; // We expect state to be passed or accessible
const packs = data.packs || [];
const filteredPacks = packs.filter(p => state.selectedPackIds.includes(p.id));
// 1. Clear Library (except fixed categories and header)
const dynamicPacks = library.querySelectorAll('.asset-category:not(.fixed-category)');
dynamicPacks.forEach(p => p.remove());
// 2. Group Custom Assets by Pack -> Category
const packGroups = state.assetsData.reduce((acc, asset) => {
const packId = asset.pack_id || 'legacy';
if (packId !== 'legacy' && !state.selectedPackIds.includes(packId)) return acc;
const category = asset.category || 'Other';
if (!acc[packId]) acc[packId] = {};
if (!acc[packId][category]) acc[packId][category] = [];
acc[packId][category].push(asset);
return acc;
}, {});
// 3. Render each visible Pack
Object.keys(packGroups).forEach((packId, index) => {
const packMeta = packs.find(p => p.id === packId) || { id: packId, name: (packId === 'legacy' ? t('other_assets') : packId) };
const categories = packGroups[packId];
const allAssetsInPack = Object.values(categories).flat();
// Ensure proper path is set for pack-based assets
if (packId !== 'legacy') {
allAssetsInPack.forEach(asset => {
if (asset.views && asset.views.icon) {
const icon = asset.views.icon;
// Only prepend packs/ if not already present
if (!icon.startsWith('packs/')) {
asset.path = `packs/${packId}/${icon}`;
} else {
asset.path = icon;
}
}
});
}
const firstAsset = allAssetsInPack[0] || {};
const iconPath = (firstAsset.views && firstAsset.views.icon) ? firstAsset.views.icon : (firstAsset.path || DEFAULTS.DEFAULT_ICON);
const catContainer = document.createElement('div');
catContainer.className = `asset-category shadow-sm`;
const header = document.createElement('div');
header.className = 'category-header';
header.innerHTML = `<span>${packMeta.name || packMeta.id}</span><i class="fas fa-chevron-down"></i>`;
header.onclick = () => {
if (!document.getElementById('sidebar').classList.contains('collapsed')) {
catContainer.classList.toggle('active');
}
};
const collapsedIcon = document.createElement('div');
collapsedIcon.className = 'category-collapsed-icon';
collapsedIcon.title = packMeta.name || packMeta.id;
collapsedIcon.innerHTML = `<img src="/static/assets/${iconPath}" width="28" height="28">`;
collapsedIcon.onclick = (e) => {
if (document.getElementById('sidebar').classList.contains('collapsed')) {
e.stopPropagation();
renderFlyout(catContainer, allAssetsInPack, packMeta);
}
};
const content = document.createElement('div');
content.className = 'category-content';
// Render each Category within the Pack
Object.keys(categories).sort().forEach(catName => {
const assets = categories[catName];
if (Object.keys(categories).length > 1 || catName !== 'Other') {
const subHeader = document.createElement('div');
subHeader.className = 'asset-group-label';
subHeader.textContent = catName;
content.appendChild(subHeader);
}
const itemGrid = document.createElement('div');
itemGrid.className = 'asset-grid';
assets.forEach(asset => {
const compositeId = `${packId}|${asset.id}`;
state.assetMap[compositeId] = asset;
itemGrid.appendChild(createAssetElement(asset, compositeId));
});
content.appendChild(itemGrid);
});
catContainer.append(header, collapsedIcon, content);
library.appendChild(catContainer);
});
}
+35
View File
@@ -0,0 +1,35 @@
import { state } from '../state.js';
/**
* initSearch - Initializes the asset search input listener
*/
export function initSearch() {
const searchInput = document.getElementById('asset-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
const categories = document.querySelectorAll('.asset-category');
categories.forEach(cat => {
const items = cat.querySelectorAll('.asset-item');
let hasVisibleItem = false;
items.forEach(item => {
const assetId = item.dataset.assetId;
const asset = state.assetMap[assetId];
if (!asset) return;
const matches = (asset.type && asset.type.toLowerCase().includes(query)) ||
(asset.label && asset.label.toLowerCase().includes(query)) ||
(asset.vendor && asset.vendor.toLowerCase().includes(query));
item.style.display = matches ? 'flex' : 'none';
if (matches) hasVisibleItem = true;
});
cat.style.display = (hasVisibleItem || query === '') ? 'block' : 'none';
if (query !== '' && hasVisibleItem) cat.classList.add('active');
});
});
}
+64
View File
@@ -0,0 +1,64 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
/**
* renderPackSelector - Show a modal to enable/disable asset packages
*/
export function renderPackSelector(packs) {
let modal = document.getElementById('pack-selector-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'pack-selector-modal';
modal.className = 'modal-overlay';
document.body.appendChild(modal);
}
const selectedIds = state.selectedPackIds;
modal.innerHTML = `
<div class="modal-content" style="width: 400px; max-height: 80vh; overflow-y: auto;">
<div class="modal-header">
<h3>${t('manage_packages') || 'Manage Packages'}</h3>
<i class="fas fa-times close-modal"></i>
</div>
<div class="modal-body" style="padding: 15px;">
<p style="font-size: 12px; color: #64748b; margin-bottom: 15px;">Select which asset packages to load in your sidebar.</p>
<div class="pack-list">
${packs.map(p => `
<div class="pack-item" style="display: flex; align-items: center; justify-content: space-between; padding: 10px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<div style="display: flex; align-items: center; gap: 10px;">
<i class="fas fa-box" style="color: #38bdf8; font-size: 14px;"></i>
<span style="font-size: 13px; font-weight: 600;">${p.name || p.id}</span>
</div>
<input type="checkbox" data-id="${p.id}" ${selectedIds.includes(p.id) ? 'checked' : ''} style="width: 18px; height: 18px; cursor: pointer;">
</div>
`).join('')}
</div>
</div>
<div class="modal-footer" style="padding: 15px; border-top: 1px solid rgba(255,255,255,0.05); text-align: right;">
<button class="btn-primary apply-packs" style="background: #38bdf8; color: #0f172a; border: none; padding: 8px 16px; border-radius: 6px; font-weight: 700; cursor: pointer;">Apply & Refresh</button>
</div>
</div>
`;
modal.classList.add('active');
// Close handlers
modal.querySelector('.close-modal').onclick = () => modal.classList.remove('active');
modal.onclick = (e) => { if (e.target === modal) modal.classList.remove('active'); };
// Apply handler
modal.querySelector('.apply-packs').onclick = () => {
const checkboxes = modal.querySelectorAll('input[type="checkbox"]');
const newSelectedIds = Array.from(checkboxes)
.filter(cb => cb.checked)
.map(cb => cb.dataset.id);
state.selectedPackIds = newSelectedIds;
localStorage.setItem('selectedPackIds', JSON.stringify(newSelectedIds));
// This is a global refresh to re-init everything with new filters
// For a more seamless experience, we could re-call initAssets()
window.location.reload();
};
}
+29
View File
@@ -0,0 +1,29 @@
/**
* constants.js - Central repository for magic strings and app configuration
*/
// LocalStorage Keys
export const STORAGE_KEYS = {
SETTINGS: 'drawNET-settings',
THEME: 'drawNET-theme',
AUTOSAVE: 'drawNET_autosave',
LANGUAGE: 'drawNET_lang'
};
// Default Configuration
export const DEFAULTS = {
GRID_SPACING: 20,
GRID_COLOR: '#e2e8f0',
MAJOR_GRID_COLOR: '#cbd5e1',
MAJOR_GRID_INTERVAL: 5,
CANVAS_PRESET: 'A4',
CANVAS_ORIENTATION: 'portrait',
APP_VERSION: '2.0.0-pro',
DEFAULT_ICON: 'router.svg'
};
// UI Toggles & States
export const UI_STATES = {
QUAKE_COLLAPSED_HEIGHT: '40px',
QUAKE_EXPANDED_HEIGHT: '300px'
};
+34
View File
@@ -0,0 +1,34 @@
/**
* graph.js - Main Entry for AntV X6 Graph Module
* Modularized version splitting styles, events, interactions, and IO.
*/
import { state } from './state.js';
import { registerCustomShapes } from './graph/styles.js';
import { initGraphEvents } from './graph/events/index.js';
import { initInteractions } from './graph/interactions/index.js';
import { initGraphIO } from './graph/io/index.js';
import { initPropertiesSidebar } from './properties_sidebar/index.js';
import { initContextMenu } from './ui/context_menu/index.js';
import { getGraphConfig } from './graph/config.js';
import { initGraphPlugins } from './graph/plugins.js';
import { logger } from './utils/logger.js';
export function initGraph() {
const container = document.getElementById('graph-container');
if (!container) return;
// 1. Register Custom Shapes
registerCustomShapes();
// 2. Initialize AntV X6 Graph Instance
state.graph = new X6.Graph(getGraphConfig(container));
// 3. Enable Plugins
initGraphPlugins(state.graph);
// 4. Initialize Sub-modules
initGraphEvents();
initInteractions();
logger.high("AntV X6 Graph initialized with modules.");
}
+139
View File
@@ -0,0 +1,139 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
/**
* Alignment and Distribution tools for drawNET (X6 version).
*/
/**
* alignNodes - Aligns selected nodes based on the FIRST selected node
* @param {'top'|'bottom'|'left'|'right'|'middle'|'center'} type
*/
export function alignNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 2) return;
// First selected object is the reference per user request
const reference = selected[0];
const refPos = reference.getPosition();
const refSize = reference.getSize();
selected.slice(1).forEach(node => {
const pos = node.getPosition();
const size = node.getSize();
switch (type) {
case 'top':
node.setPosition(pos.x, refPos.y);
break;
case 'bottom':
node.setPosition(pos.x, refPos.y + refSize.height - size.height);
break;
case 'left':
node.setPosition(refPos.x, pos.y);
break;
case 'right':
node.setPosition(refPos.x + refSize.width - size.width, pos.y);
break;
case 'middle':
node.setPosition(pos.x, refPos.y + (refSize.height / 2) - (size.height / 2));
break;
case 'center':
node.setPosition(refPos.x + (refSize.width / 2) - (size.width / 2), pos.y);
break;
}
});
notifyChanges();
logger.info(`drawNET: Aligned ${selected.length} nodes (${type}).`);
}
/**
* moveNodes - Moves selected nodes by dx, dy
*/
export function moveNodes(dx, dy) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length === 0) return;
selected.forEach(node => {
const pos = node.getPosition();
node.setPosition(pos.x + dx, pos.y + dy);
});
// Sync with persistence after move (with debounce)
clearTimeout(state.moveSyncTimer);
state.moveSyncTimer = setTimeout(() => {
notifyChanges();
}, 500);
}
export function distributeNodes(type) {
if (!state.graph) return;
const selected = state.graph.getSelectedCells().filter(c => c.isNode());
if (selected.length < 3) return;
const bboxes = selected.map(node => ({
node,
bbox: node.getBBox()
}));
if (type === 'horizontal') {
// Sort nodes from left to right
bboxes.sort((a, b) => a.bbox.x - b.bbox.x);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
// Calculate Total Span (Right edge of last - Left edge of first)
const totalSpan = (last.bbox.x + last.bbox.width) - first.bbox.x;
// Calculate Sum of Node Widths
const sumWidths = bboxes.reduce((sum, b) => sum + b.bbox.width, 0);
// Total Gap Space
const totalGapSpace = totalSpan - sumWidths;
if (totalGapSpace < 0) return; // Overlapping too much? Skip for now
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentX = first.bbox.x;
bboxes.forEach((b, i) => {
if (i > 0) {
currentX += bboxes[i-1].bbox.width + gapSize;
b.node.setPosition(currentX, b.bbox.y);
}
});
} else if (type === 'vertical') {
// Sort nodes from top to bottom
bboxes.sort((a, b) => a.bbox.y - b.bbox.y);
const first = bboxes[0];
const last = bboxes[bboxes.length - 1];
const totalSpan = (last.bbox.y + last.bbox.height) - first.bbox.y;
const sumHeights = bboxes.reduce((sum, b) => sum + b.bbox.height, 0);
const totalGapSpace = totalSpan - sumHeights;
if (totalGapSpace < 0) return;
const gapSize = totalGapSpace / (bboxes.length - 1);
let currentY = first.bbox.y;
bboxes.forEach((b, i) => {
if (i > 0) {
currentY += bboxes[i-1].bbox.height + gapSize;
b.node.setPosition(b.bbox.x, currentY);
}
});
}
notifyChanges();
}
/**
* notifyChanges - Marks dirty for persistence
*/
function notifyChanges() {
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
+167
View File
@@ -0,0 +1,167 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
import { makeDraggable } from '../ui/utils.js';
/**
* initInventory - Initializes the real-time inventory counting panel
*/
export function initInventory() {
const inventoryPanel = document.getElementById('inventory-panel');
const inventoryList = document.getElementById('inventory-list');
if (!inventoryPanel || !inventoryList) return;
// Make Draggable
makeDraggable(inventoryPanel, '.inventory-header');
// Initial update
updateInventory(inventoryList);
// Listen for graph changes
if (state.graph) {
state.graph.on('node:added', () => updateInventory(inventoryList));
state.graph.on('node:removed', () => updateInventory(inventoryList));
state.graph.on('cell:changed', ({ cell }) => {
if (cell.isNode()) updateInventory(inventoryList);
});
// project:restored 이벤트 수신 (복구 시점)
state.graph.on('project:restored', () => {
logger.info("Project restored. Updating inventory.");
updateInventory(inventoryList);
});
}
}
/**
* toggleInventory - Toggles the visibility of the inventory panel
*/
export function toggleInventory() {
const panel = document.getElementById('inventory-panel');
if (!panel) return;
// Use getComputedStyle for more reliable check
const currentDisplay = window.getComputedStyle(panel).display;
if (currentDisplay === 'none') {
panel.style.display = 'flex';
panel.classList.add('animate-up');
} else {
panel.style.display = 'none';
}
}
/**
* updateInventory - Aggregates node counts by type and updates the UI
*/
export function updateInventory(container) {
if (!state.graph) return;
// Use default container if not provided
if (!container) {
container = document.getElementById('inventory-list');
}
if (!container) return;
const nodes = state.graph.getNodes();
const counts = {};
nodes.forEach(node => {
const data = node.getData() || {};
const type = data.type || 'Unknown';
counts[type] = (counts[type] || 0) + 1;
});
// Render list
container.innerHTML = '';
const sortedTypes = Object.keys(counts).sort();
if (sortedTypes.length === 0) {
container.innerHTML = `<div style="font-size: 10px; color: #94a3b8; text-align: center; margin-top: 10px;">${t('no_objects_found') || 'No objects found'}</div>`;
return;
}
sortedTypes.forEach(type => {
const item = document.createElement('div');
item.className = 'inventory-item';
const displayType = t(type.toLowerCase()) || type;
item.innerHTML = `
<span class="lbl">${displayType}</span>
<span class="val">${counts[type]}</span>
`;
container.appendChild(item);
});
// BOM 내보내기 버튼 추가 (푸터)
let footer = document.querySelector('.inventory-footer');
if (!footer) {
footer = document.createElement('div');
footer.className = 'inventory-footer';
}
footer.innerHTML = `
<button id="btn-export-bom" class="btn-export-bom">
<i class="fas fa-file-excel"></i>
<span>${t('export_bom') || 'Export BOM (Excel)'}</span>
</button>
`;
container.appendChild(footer);
const btn = document.getElementById('btn-export-bom');
if (btn) btn.onclick = downloadBOM;
}
/**
* downloadBOM - 가시화된 모든 노드 정보를 엑셀/CSV 형태로 추출
*/
function downloadBOM() {
if (!state.graph) return;
const nodes = state.graph.getNodes();
if (nodes.length === 0) return;
// 헤더 정의
const headers = [
t('prop_id'), t('display_name'), t('prop_type'),
t('prop_vendor'), t('prop_model'), t('prop_ip'),
t('prop_status'), t('prop_asset_tag'), t('prop_project'), t('prop_env')
];
// 데이터 행 생성
const rows = nodes.map(node => {
const data = node.getData() || {};
const label = node.attr('label/text') || '';
return [
node.id,
label,
data.type || '',
data.vendor || '',
data.model || '',
data.ip || '',
data.status || '',
data.asset_tag || '',
data.project || '',
data.env || ''
].map(val => `"${String(val).replace(/"/g, '""')}"`); // CSV 이스케이프
});
// CSV 문자열 합치기
const csvContent = "\uFEFF" + // UTF-8 BOM for Excel kor
headers.join(',') + "\n" +
rows.map(r => r.join(',')).join('\n');
// 다운로드 트리거
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);
link.setAttribute("href", url);
link.setAttribute("download", `drawNET_BOM_${timestamp}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
logger.info("BOM exported successfully.");
}
+125
View File
@@ -0,0 +1,125 @@
import { DEFAULTS } from '../constants.js';
import { state } from '../state.js';
import { calculateCellZIndex } from './layers.js';
/**
* graph/config.js - X6 Graph configuration options
*/
export const getGraphConfig = (container) => ({
// ... (rest of the top part)
container: container,
autoResize: true,
background: { color: document.documentElement.getAttribute('data-theme') === 'dark' ? '#1e293b' : '#f8fafc' },
grid: {
visible: true,
type: 'dot',
size: DEFAULTS.GRID_SPACING,
args: { color: DEFAULTS.GRID_COLOR, thickness: 1 },
},
panning: {
enabled: true,
modifiers: 'space',
},
history: {
enabled: true,
},
mousewheel: {
enabled: true,
modifiers: ['ctrl', 'meta'],
factor: state.appConfig?.mousewheel?.factor || 1.05,
minScale: state.appConfig?.mousewheel?.minScale || 0.1,
maxScale: state.appConfig?.mousewheel?.maxScale || 10,
},
connecting: {
router: null,
connector: { name: 'jumpover', args: { type: 'arc', size: 6 } },
anchor: 'orth',
connectionPoint: 'boundary',
allowNode: false,
allowBlank: false,
snap: { radius: 20 },
createEdge() {
const edgeData = {
layerId: state.activeLayerId || 'l1',
routing_offset: 20,
direction: 'none',
description: "",
asset_tag: ""
};
return new X6.Shape.Edge({
attrs: {
line: {
stroke: '#94a3b8',
strokeWidth: 2,
targetMarker: null
},
},
zIndex: calculateCellZIndex(edgeData, 0, false),
data: edgeData
})
},
validateConnection({ sourceMagnet, targetMagnet }) {
return !!sourceMagnet && !!targetMagnet;
},
highlight: true,
},
embedding: {
enabled: true,
findParent({ node }) {
const bbox = node.getBBox();
const candidates = this.getNodes().filter((n) => {
const data = n.getData();
if (data && data.is_group && n.id !== node.id) {
const targetBBox = n.getBBox();
return targetBBox.containsRect(bbox);
}
return false;
});
if (candidates.length === 0) return [];
if (candidates.length === 1) return [candidates[0]];
// Return the one with the smallest area (the innermost one)
const bestParent = candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
});
return [bestParent];
},
validate({ child, parent }) {
return parent.getData()?.is_group;
},
},
interacting: {
nodeMovable: (view) => {
if (state.isCtrlPressed) return false; // Block default move during Ctrl+Drag
return view.cell.getProp('movable') !== false;
},
edgeMovable: (view) => {
if (state.isCtrlPressed) return false;
return view.cell.getProp('movable') !== false;
},
edgeLabelMovable: (view) => view.cell.getProp('movable') !== false,
arrowheadMovable: (view) => view.cell.getProp('movable') !== false,
vertexMovable: (view) => view.cell.getProp('movable') !== false,
vertexAddable: (view) => {
if (view.cell.getProp('movable') === false) return false;
// Only allow adding vertices if the edge is ALREADY selected.
// This prevents accidental addition on the first click (which selects the edge).
return view.graph.isSelected(view.cell);
},
vertexDeletable: (view) => view.cell.getProp('movable') !== false,
},
});
+18
View File
@@ -0,0 +1,18 @@
import { initSystemEvents } from './system.js';
import { initViewportEvents } from './viewport.js';
import { initSelectionEvents } from './selection.js';
import { initNodeEvents } from './nodes.js';
import { initRoutingEvents } from './routing.js';
export { updateGridBackground } from './system.js';
/**
* initGraphEvents - Orchestrates the initialization of all specialized event modules.
*/
export function initGraphEvents() {
initSystemEvents();
initViewportEvents();
initSelectionEvents();
initNodeEvents();
initRoutingEvents();
}
+98
View File
@@ -0,0 +1,98 @@
import { state } from '../../state.js';
import { generateRackUnitsPath } from '../styles/utils.js';
export function initNodeEvents() {
if (!state.graph) return;
// Hover Guides for specific objects
state.graph.on('node:mouseenter', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'rgba(59, 130, 246, 0.4)');
node.attr('hit/strokeDasharray', '2,2');
}
});
state.graph.on('node:mouseleave', ({ node }) => {
if (node.shape === 'drawnet-dot') {
node.attr('hit/stroke', 'transparent');
node.attr('hit/strokeDasharray', '0');
}
});
// Rack Drill Down Interaction
state.graph.on('node:dblclick', ({ node }) => {
const data = node.getData() || {};
if (data.layerId !== state.activeLayerId) return; // Guard: Block drills into other layers
if (node.shape === 'drawnet-rack') {
state.graph.zoomToCell(node, {
padding: { top: 60, bottom: 60, left: 100, right: 100 },
animation: { duration: 600 }
});
state.graph.getCells().forEach(cell => {
if (cell.id !== node.id && !node.getDescendants().some(d => d.id === cell.id)) {
cell.setAttrs({ body: { opacity: 0.1 }, image: { opacity: 0.1 }, label: { opacity: 0.1 }, line: { opacity: 0.1 } });
} else {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
}
});
showBackToTopBtn();
}
});
// Parent/Structural Changes
state.graph.on('node:change:parent', ({ node, current }) => {
const parentId = current || null;
const currentData = node.getData() || {};
node.setData({ ...currentData, parent: parentId });
import('../layers.js').then(m => m.applyLayerFilters());
import('/static/js/modules/properties_sidebar/index.js').then(m => m.renderProperties());
import('/static/js/modules/persistence.js').then(m => m.markDirty());
});
// Data Syncing (Rack-specific)
state.graph.on('node:change:data', ({ node, current }) => {
if (node.shape === 'drawnet-rack') {
const slots = current.slots || 42;
const bbox = node.getBBox();
node.attr('units/d', generateRackUnitsPath(bbox.height, slots));
if (!current.label || current.label.startsWith('Rack (')) {
node.attr('label/text', `Rack (${slots}U)`);
}
}
// Rich Card Content Refresh on Data Change
if (node.shape === 'drawnet-rich-card') {
import('../styles/utils.js').then(m => m.renderRichContent(node));
}
});
}
function showBackToTopBtn() {
let btn = document.getElementById('rack-back-btn');
if (btn) btn.remove();
btn = document.createElement('div');
btn.id = 'rack-back-btn';
btn.innerHTML = '<i class="fas fa-arrow-left"></i> Back to Topology';
btn.style.cssText = `
position: absolute; top: 100px; left: 300px; z-index: 1000;
padding: 10px 20px; background: #3b82f6; color: white;
border-radius: 8px; cursor: pointer; font-weight: bold;
box-shadow: 0 4px 12px rgba(0,0,0,0.2); transition: all 0.2s;
`;
btn.onclick = () => {
state.graph.zoomToFit({ padding: 100, animation: { duration: 500 } });
state.graph.getCells().forEach(cell => {
cell.setAttrs({ body: { opacity: 1 }, image: { opacity: 1 }, label: { opacity: 1 }, line: { opacity: 1 } });
});
btn.remove();
};
document.getElementById('graph-container').parentElement.appendChild(btn);
}
+60
View File
@@ -0,0 +1,60 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
export function initRoutingEvents() {
if (!state.graph) return;
// Re-evaluate routing when nodes are moved to handle adaptive fallback (Phase 1.18)
state.graph.on('node:change:position', ({ node }) => {
const edges = state.graph.getConnectedEdges(node);
if (edges.length === 0) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
edges.forEach(edge => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
if (config.router !== edge.getRouter()) {
edge.setRouter(config.router || null);
logger.info(`drawNET: Dynamic Routing Update for ${edge.id}`);
}
if (config.connector !== edge.getConnector()) {
edge.setConnector(config.connector);
}
// Sync physical connections during move (especially for manual anchors)
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
});
});
});
// Unify styling when an edge is newly connected via mouse (Phase 1.19)
state.graph.on('edge:connected', ({ edge }) => {
if (!edge || !edge.isEdge()) return;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
const data = edge.getData() || {};
const config = getX6EdgeConfig({
source: edge.getSource().cell,
target: edge.getTarget().cell,
...data
});
// Apply official style & configuration
if (config.source) edge.setSource(config.source);
if (config.target) edge.setTarget(config.target);
edge.setRouter(config.router || null);
edge.setConnector(config.connector);
edge.setZIndex(config.zIndex || 5);
if (config.attrs) {
edge.setAttrs(config.attrs);
}
logger.info(`drawNET: Unified Style applied to new edge ${edge.id}`);
});
});
}
@@ -0,0 +1,55 @@
import { state } from '../../state.js';
export function initSelectionEvents() {
if (!state.graph) return;
state.graph.on('cell:selected', ({ cell }) => {
if (cell.isNode()) {
if (!state.selectionOrder.includes(cell.id)) {
state.selectionOrder.push(cell.id);
}
} else if (cell.isEdge()) {
// Add vertices tool for manual routing control (unless locked)
if (!cell.getData()?.locked) {
cell.addTools([{ name: 'vertices' }]);
}
// ADD S/T labels for clear identification of Start and Target
const currentLabels = cell.getLabels() || [];
cell.setLabels([
...currentLabels,
{ id: 'selection-source-label', position: { distance: 0.05 }, attrs: { text: { text: 'S', fill: '#10b981', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#10b981', strokeWidth: 1, rx: 2, ry: 2 } } },
{ id: 'selection-target-label', position: { distance: 0.95 }, attrs: { text: { text: 'T', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' }, rect: { fill: '#ffffff', stroke: '#ef4444', strokeWidth: 1, rx: 2, ry: 2 } } }
]);
}
});
state.graph.on('cell:unselected', ({ cell }) => {
const isLocked = cell.getData()?.locked;
if (cell.isEdge()) {
if (!isLocked) {
cell.removeTools();
} else {
// Keep boundary if locked, but remove selection-only tools if any
cell.removeTools(['vertices']);
}
// Remove selection labels safely
const labels = cell.getLabels() || [];
const filteredLabels = labels.filter(l =>
l.id !== 'selection-source-label' && l.id !== 'selection-target-label'
);
cell.setLabels(filteredLabels);
} else if (cell.isNode()) {
if (!isLocked) {
cell.removeTools();
}
}
state.selectionOrder = state.selectionOrder.filter(id => id !== cell.id);
});
state.graph.on('blank:click', () => {
state.selectionOrder = [];
});
}
+125
View File
@@ -0,0 +1,125 @@
import { state } from '../../state.js';
import { updateGraphTheme } from '../styles.js';
import { DEFAULTS } from '../../constants.js';
export function initSystemEvents() {
if (!state.graph) return;
// Theme changes
window.addEventListener('themeChanged', () => {
updateGraphTheme();
const isDark = document.documentElement.getAttribute('data-theme') === 'dark';
state.graph.drawBackground({ color: isDark ? '#1e293b' : '#f8fafc' });
});
// Canvas resize event
window.addEventListener('canvasResize', (e) => {
const { width, height } = e.detail;
state.canvasSize = { width, height };
const container = document.getElementById('graph-container');
if (width === '100%') {
container.style.width = '100%';
container.style.height = '100%';
container.style.boxShadow = 'none';
container.style.margin = '0';
state.graph.resize();
} else {
container.style.width = `${width}px`;
container.style.height = `${height}px`;
container.style.boxShadow = '0 10px 30px rgba(0,0,0,0.1)';
container.style.margin = '40px auto';
container.style.backgroundColor = '#fff';
state.graph.resize(width, height);
}
});
// Grid type change
window.addEventListener('gridChanged', (e) => {
state.gridStyle = e.detail.style;
if (!state.graph) return;
if (e.detail.style === 'none') {
state.graph.hideGrid();
state.isSnapEnabled = false;
} else {
state.graph.showGrid();
state.isSnapEnabled = true;
if (e.detail.showMajor) {
state.graph.drawGrid({
type: 'doubleMesh',
args: [
{
color: e.detail.majorColor || DEFAULTS.MAJOR_GRID_COLOR,
thickness: (e.detail.thickness || 1) * 1.5,
factor: e.detail.majorInterval || DEFAULTS.MAJOR_GRID_INTERVAL
},
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
} else {
const gridType = e.detail.style === 'solid' ? 'mesh' : (e.detail.style === 'dashed' ? 'doubleMesh' : 'dot');
state.graph.drawGrid({
type: gridType,
args: [
{
color: e.detail.color || DEFAULTS.GRID_COLOR,
thickness: e.detail.thickness || 1
},
],
});
}
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
}
});
// Grid spacing change
window.addEventListener('gridSpacingChanged', (e) => {
state.gridSpacing = e.detail.spacing;
if (state.graph) {
state.graph.setGridSize(state.gridSpacing);
}
});
// Global Data Sanitizer & Enforcer (Ensures all objects have latest schema defaults)
// This handles all creation paths: Drag-and-Drop, Copy-Paste, Grouping, and programatic addEdge.
state.graph.on('cell:added', ({ cell }) => {
const data = cell.getData() || {};
let updated = false;
// 1. Common Fields for all Node/Edge objects
if (data.description === undefined) { data.description = ""; updated = true; }
if (data.asset_tag === undefined) { data.asset_tag = ""; updated = true; }
if (!data.tags) { data.tags = []; updated = true; }
if (data.locked === undefined) { data.locked = false; updated = true; }
// 2. Ensure label_pos exists to prevent display reset
if (data.label_pos === undefined) {
data.label_pos = data.is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label'].includes((data.type || '').toLowerCase()) ? 'center' : 'bottom');
updated = true;
}
// 3. Edge Specific Defaults
if (cell.isEdge()) {
if (data.routing_offset === undefined) { data.routing_offset = 20; updated = true; }
}
if (updated) {
// Use silent: true to prevent triggering another 'cell:added' if someone is listening to data changes
cell.setData(data, { silent: true });
}
// Keep existing layer logic
import('../layers.js').then(m => m.applyLayerFilters());
});
}
export function updateGridBackground() {
if (!state.graph) return;
state.graph.setGridSize(state.gridSpacing || DEFAULTS.GRID_SPACING);
state.graph.showGrid();
}
@@ -0,0 +1,80 @@
import { state } from '../../state.js';
let isRightDragging = false;
let startX = 0;
let startY = 0;
const threshold = 5;
/**
* Handle mousedown on the graph to initialize right-click panning.
*/
const handleGraphMouseDown = ({ e }) => {
if (e.button === 2) { // Right Click
isRightDragging = false;
startX = e.clientX;
startY = e.clientY;
state.isRightDragging = false;
}
};
/**
* Global event handler for mousemove to support panning.
* Registered only once on document level.
*/
const onMouseMove = (e) => {
if (e.buttons === 2 && state.graph) {
const dx = e.clientX - startX;
const dy = e.clientY - startY;
// Detect dragging start (beyond threshold)
if (!isRightDragging && (Math.abs(dx) > threshold || Math.abs(dy) > threshold)) {
isRightDragging = true;
state.isRightDragging = true;
// Sync start positions to current mouse position to avoid the initial jump
startX = e.clientX;
startY = e.clientY;
return;
}
if (isRightDragging) {
state.graph.translateBy(dx, dy);
startX = e.clientX;
startY = e.clientY;
}
}
};
/**
* Global event handler for mouseup to finish panning.
* Registered only once on document level.
*/
const onMouseUp = () => {
if (isRightDragging) {
// Reset after a short delay to allow contextmenu event to check the flag
setTimeout(() => {
state.isRightDragging = false;
isRightDragging = false;
}, 100);
}
};
/**
* Initializes viewport-related events for the current graph instance.
* Should be called whenever a new graph is created.
*/
export function initViewportEvents() {
if (!state.graph) return;
// Attach to the specific graph instance, ensuring no duplicates
state.graph.off('blank:mousedown', handleGraphMouseDown);
state.graph.on('blank:mousedown', handleGraphMouseDown);
state.graph.off('cell:mousedown', handleGraphMouseDown);
state.graph.on('cell:mousedown', handleGraphMouseDown);
// Register document-level listeners only once per page session
if (!window._drawNetViewportEventsInitialized) {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
window._drawNetViewportEventsInitialized = true;
}
}
@@ -0,0 +1,95 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { t } from '../../i18n.js';
import { getX6NodeConfig } from '../styles.js';
/**
* initDropHandling - Sets up drag and drop from the asset library to the graph
*/
export function initDropHandling(container) {
container.addEventListener('dragover', (e) => e.preventDefault());
container.addEventListener('drop', (e) => {
e.preventDefault();
const assetId = e.dataTransfer.getData('assetId');
const asset = state.assetMap[assetId];
const pos = state.graph.clientToLocal(e.clientX, e.clientY);
logger.info(`Drop Event at {${pos.x}, ${pos.y}}`, {
assetId: assetId,
found: !!asset,
id: asset?.id,
path: asset?.path,
type: asset?.type,
rawAsset: asset
});
if (!asset) {
logger.high(`Asset [${assetId}] not found in map`);
return;
}
// Logical Layer Guard: Block dropping new nodes
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
return;
}
const isPrimitive = ['rect', 'rounded-rect', 'circle', 'text-box', 'label'].includes(asset.type);
const prefix = asset.id === 'fixed-group' ? 'group_' : (asset.type === 'blank' || asset.type === 'dot' ? 'p_' : (isPrimitive ? 'shp_' : 'node_'));
const id = prefix + Math.random().toString(36).substr(2, 4);
// Use translate keys or asset label first, or fallback to generic name
let initialLabel = asset.label || t(asset.id) || (asset.id === 'fixed-group' ? 'New Group' : 'New ' + (asset.type || 'Object'));
if (asset.id === 'fixed-group') initialLabel = t('group') || 'New Group';
// Find if dropped over a group (prefer the smallest/innermost one)
const candidates = state.graph.getNodes().filter(n => {
if (!n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(pos);
});
const parent = candidates.length > 0
? candidates.reduce((smallest, current) => {
const smallestBBox = smallest.getBBox();
const currentBBox = current.getBBox();
const currentArea = currentBBox.width * currentBBox.height;
const smallestArea = smallestBBox.width * smallestBBox.height;
if (currentArea < smallestArea) return current;
if (currentArea > smallestArea) return smallest;
// If areas are equal, pick the one that is a descendant of the other
if (current.getAncestors().some(a => a.id === smallest.id)) return current;
return smallest;
})
: undefined;
// 2. Add to X6 immediately
const config = getX6NodeConfig({
id: id,
label: initialLabel,
type: asset.type || asset.category || asset.label || 'Unknown',
assetId: assetId, // Store unique composite ID for lookup
assetPath: asset.id === 'fixed-group' ? undefined : asset.path,
is_group: asset.id === 'fixed-group' || asset.is_group === true,
pos: { x: pos.x, y: pos.y },
parent: parent ? parent.id : undefined,
layerId: state.activeLayerId,
description: "",
asset_tag: "",
tags: []
});
const newNode = state.graph.addNode(config);
if (parent) {
parent.addChild(newNode);
}
// Ensure the new node follows the active layer's interaction rules immediately
import('../layers.js').then(m => m.applyLayerFilters());
});
}
@@ -0,0 +1,47 @@
/**
* graph/interactions/edges.js - Handles edge connection logic and metadata assignment.
*/
import { state } from '../../state.js';
/**
* initEdgeHandling - Sets up edge connection events
*/
export function initEdgeHandling() {
state.graph.on('edge:connected', ({ edge }) => {
const sourceId = edge.getSourceCellId();
const targetId = edge.getTargetCellId();
if (!sourceId || !targetId) return;
// Assign to current active layer and apply standard styling
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
// [6개월 뒤의 나를 위한 메모]
// 단순 유추가 아닌 명시적 기록을 통해, 도면 전체를 전송하지 않고도 연결선 단독으로
// 크로스 레이어 여부를 파악할수 있게 하여 외부 도구/AI 연동 안정성을 높임.
const sourceNode = state.graph.getCellById(sourceId);
const targetNode = state.graph.getCellById(targetId);
const sourceLayer = sourceNode?.getData()?.layerId || state.activeLayerId;
const targetLayer = targetNode?.getData()?.layerId || state.activeLayerId;
const isCrossLayer = (sourceLayer !== targetLayer);
const edgeData = {
source: sourceId,
target: targetId,
layerId: state.activeLayerId,
source_layer: sourceLayer,
target_layer: targetLayer,
is_cross_layer: isCrossLayer,
...edge.getData()
};
const config = getX6EdgeConfig(edgeData);
// Apply standardized config to the new edge
edge.setData(edgeData, { silent: true });
if (config.router) edge.setRouter(config.router);
else edge.setRouter(null);
if (config.connector) edge.setConnector(config.connector);
edge.setAttrs(config.attrs);
});
});
}
@@ -0,0 +1,23 @@
import { state } from '../../state.js';
import { initDropHandling } from './drop.js';
import { initEdgeHandling } from './edges.js';
import { initSnapping } from './snapping.js';
import { initSpecialShortcuts } from './shortcuts.js';
import { logger } from '../../utils/logger.js';
/**
* initInteractions - Entry point for all graph interactions
*/
export function initInteractions() {
if (!state.graph) return;
const container = state.graph.container;
// 1. Initialize Sub-modules
initDropHandling(container);
initEdgeHandling();
initSnapping();
initSpecialShortcuts();
logger.info("Graph interactions initialized via modules.");
}
@@ -0,0 +1,125 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* initSpecialShortcuts - Sets up specialized mouse/keyboard combined gestures
*/
export function initSpecialShortcuts() {
if (!state.graph) return;
let dragStartData = null;
// Ctrl + Drag to Copy: Improved Ghost Logic
state.graph.on('node:mousedown', ({ e, node }) => {
// Support both Ctrl (Windows) and Cmd (Mac)
const isModifier = e.ctrlKey || e.metaKey;
if (isModifier && e.button === 0) {
const selected = state.graph.getSelectedCells();
const targets = selected.includes(node) ? selected : [node];
// 1. Initial State for tracking
const mouseStart = state.graph.clientToLocal(e.clientX, e.clientY);
dragStartData = {
targets: targets,
mouseStart: mouseStart,
initialNodesPos: targets.map(t => ({ ...t.position() })),
ghost: null
};
const onMouseMove = (moveEvt) => {
if (!dragStartData) return;
const mouseNow = state.graph.clientToLocal(moveEvt.clientX, moveEvt.clientY);
const dx = mouseNow.x - dragStartData.mouseStart.x;
const dy = mouseNow.y - dragStartData.mouseStart.y;
// Create Ghost on movement
if (!dragStartData.ghost && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
const cellsBBox = state.graph.getCellsBBox(dragStartData.targets);
dragStartData.ghost = state.graph.addNode({
shape: 'rect',
x: cellsBBox.x,
y: cellsBBox.y,
width: cellsBBox.width,
height: cellsBBox.height,
attrs: {
body: {
fill: 'rgba(59, 130, 246, 0.1)',
stroke: '#3b82f6',
strokeWidth: 2,
strokeDasharray: '5,5',
pointerEvents: 'none'
}
},
zIndex: 1000
});
dragStartData.initialGhostPos = { x: cellsBBox.x, y: cellsBBox.y };
}
if (dragStartData.ghost) {
dragStartData.ghost.position(
dragStartData.initialGhostPos.x + dx,
dragStartData.initialGhostPos.y + dy
);
}
};
const onMouseUp = (upEvt) => {
cleanup(upEvt.button === 0);
};
const onKeyDown = (keyEvt) => {
if (keyEvt.key === 'Escape') {
cleanup(false); // Cancel on ESC
}
};
function cleanup(doPaste) {
if (dragStartData) {
if (doPaste && dragStartData.ghost) {
const finalPos = dragStartData.ghost.position();
const dx = finalPos.x - dragStartData.initialGhostPos.x;
const dy = finalPos.y - dragStartData.initialGhostPos.y;
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) {
state.graph.copy(dragStartData.targets, { deep: true });
let clones = state.graph.paste({ offset: { dx, dy }, select: true });
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
if (activeLayer && activeLayer.type === 'logical') {
clones.forEach(clone => {
if (clone.isNode()) clone.remove();
});
clones = clones.filter(c => !c.isNode());
if (clones.length === 0) {
import('/static/js/modules/ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
}
}
clones.forEach(clone => {
if (clone.isNode()) {
const d = clone.getData() || {};
clone.setData({ ...d, id: clone.id }, { silent: true });
}
});
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
}
if (dragStartData.ghost) dragStartData.ghost.remove();
}
dragStartData = null;
window.removeEventListener('mousemove', onMouseMove, true);
window.removeEventListener('mouseup', onMouseUp, true);
window.removeEventListener('keydown', onKeyDown, true);
}
window.addEventListener('mousemove', onMouseMove, true);
window.addEventListener('mouseup', onMouseUp, true);
window.addEventListener('keydown', onKeyDown, true);
}
});
logger.info("initSpecialShortcuts (Ctrl+Drag with ESC Failsafe) initialized.");
}
@@ -0,0 +1,48 @@
import { state } from '../../state.js';
/**
* initSnapping - Sets up snapping and grouping interactions during movement
*/
export function initSnapping() {
// 1. Rack Snapping Logic
state.graph.on('node:moving', ({ node }) => {
const parent = node.getParent();
if (parent && parent.shape === 'drawnet-rack') {
const rackBBox = parent.getBBox();
const nodeSize = node.size();
const pos = node.getPosition();
const headerHeight = 30;
const rackBodyHeight = rackBBox.height - headerHeight;
const unitCount = parent.getData()?.slots || 42;
const unitHeight = rackBodyHeight / unitCount;
const centerX = rackBBox.x + (rackBBox.width - nodeSize.width) / 2;
const relativeY = pos.y - rackBBox.y - headerHeight;
const snapY = rackBBox.y + headerHeight + Math.round(relativeY / unitHeight) * unitHeight;
node.setPosition(centerX, snapY, { silent: true });
}
});
// 2. Parent-Child Relationship Update on Move End
state.graph.on('node:moved', ({ node }) => {
if (node.getData()?.is_group) return;
let parent = node.getParent();
if (!parent) {
const pos = node.getPosition();
const center = { x: pos.x + node.size().width / 2, y: pos.y + node.size().height / 2 };
parent = state.graph.getNodes().find(n => {
if (n.id === node.id || !n.getData()?.is_group) return false;
const bbox = n.getBBox();
return bbox.containsPoint(center);
});
}
if (parent && node.getParent()?.id !== parent.id) {
parent.addChild(node);
}
});
}
@@ -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);
}
}
+108
View File
@@ -0,0 +1,108 @@
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 dlAnchorElem = document.createElement('a');
dlAnchorElem.setAttribute("href", dataStr);
dlAnchorElem.setAttribute("download", `project_${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', () => exportToPPTX());
}
// Export Excel
const exportExcelBtn = document.getElementById('export-excel');
if (exportExcelBtn) {
exportExcelBtn.addEventListener('click', () => {
import('./excel_exporter.js').then(m => m.exportToExcel());
});
}
// Export PNG
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', () => m.exportToSVG());
});
}
// Export PDF
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 };
+145
View File
@@ -0,0 +1,145 @@
/**
* 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()
},
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;
}
state.graph.trigger('project:restored');
return true;
} catch (e) {
logger.critical("Failed to restore project data.", e);
return false;
}
}
+238
View File
@@ -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 || "drawNET Topology";
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') || 'Object Details & Descriptions', {
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));
}
}
+80
View File
@@ -0,0 +1,80 @@
import { state } from '../state.js';
/**
* calculateCellZIndex - Logic to determine the Z-Index of a cell based on its layer and type.
* Hierarchy: Nodes(50) > Edges(30) > Groups(1).
*/
export function calculateCellZIndex(data, ancestorsCount, isNode) {
const layerId = data.layerId || 'l1';
const layerIndex = state.layers.findIndex(l => l.id === layerId);
const layerOffset = layerIndex !== -1 ? (state.layers.length - layerIndex) * 100 : 0;
let baseZ = 0;
if (isNode) {
if (data.is_group) {
baseZ = 1 + ancestorsCount;
} else {
baseZ = 50 + ancestorsCount;
}
} else {
baseZ = 30; // Edges: Always above groups
}
return layerOffset + baseZ;
}
/**
* applyLayerFilters - Updates the visibility of all cells in the graph based on their layer.
* Also enforces a strict Z-Index hierarchy: Nodes(50) > Edges(30) > Groups(1).
* Auto-Repairs existing edges to the latest styling standards.
*/
export async function applyLayerFilters() {
if (!state.graph) return;
// Pre-import style mapping for efficiency
const { getX6EdgeConfig, getLabelAttributes } = await import('./styles.js');
const cells = state.graph.getCells();
const visibleLayerIds = new Set(
state.layers
.filter(l => l.visible)
.map(l => l.id)
);
state.graph.batchUpdate(() => {
cells.forEach(cell => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
const isActive = (cellLayerId === state.activeLayerId);
const ancestorsCount = cell.getAncestors().length;
const zIndex = calculateCellZIndex(data, ancestorsCount, cell.isNode());
cell.setZIndex(zIndex);
const isVisible = visibleLayerIds.has(cellLayerId);
if (isVisible) {
cell.show();
const opacity = isActive ? 1 : (state.inactiveLayerOpacity || 0.3);
cell.attr('body/opacity', opacity);
cell.attr('image/opacity', opacity);
cell.attr('label/opacity', opacity);
cell.attr('line/opacity', opacity);
// Re-enable pointer events to allow "Ghost Snapping" (Connecting to other layers)
// We rely on Selection Plugin Filter and interacting rules for isolation.
cell.attr('root/style/pointer-events', 'auto');
const isManuallyLocked = data.locked === true;
const shouldBeInteractable = isActive && !isManuallyLocked;
cell.setProp('movable', shouldBeInteractable);
cell.setProp('deletable', shouldBeInteractable);
if (cell.isNode()) cell.setProp('rotatable', shouldBeInteractable);
} else {
cell.hide();
}
});
});
}
+67
View File
@@ -0,0 +1,67 @@
/**
* graph/plugins.js - X6 Plugin initialization
*/
export function initGraphPlugins(graph) {
if (window.X6PluginSelection) {
graph.use(new window.X6PluginSelection.Selection({
enabled: true,
multiple: true,
modifiers: 'shift',
rubberband: true,
showNodeSelectionBox: true,
showEdgeSelectionBox: false,
pointerEvents: 'none',
filter: (cell) => {
const data = cell.getData() || {};
const cellLayerId = data.layerId || 'l1';
return cellLayerId === state.activeLayerId;
}
}));
}
if (window.X6PluginSnapline) {
graph.use(new window.X6PluginSnapline.Snapline({
enabled: true,
sharp: true,
}));
}
/* Disable X6 Keyboard plugin to prevent interception of global hotkeys */
/*
if (window.X6PluginKeyboard) {
graph.use(new window.X6PluginKeyboard.Keyboard({
enabled: true,
global: false,
}));
}
*/
if (window.X6PluginClipboard) {
graph.use(new window.X6PluginClipboard.Clipboard({
enabled: true,
}));
}
if (window.X6PluginHistory) {
graph.use(new window.X6PluginHistory.History({
enabled: true,
}));
}
if (window.X6PluginTransform) {
graph.use(new window.X6PluginTransform.Transform({
resizing: { enabled: true, orthographic: false },
rotating: { enabled: true },
// 선(Edge) 제외 및 현재 활성 레이어만 허용
filter: (cell) => {
if (!cell.isNode()) return false;
const data = cell.getData() || {};
return (data.layerId || 'l1') === state.activeLayerId;
},
}));
}
if (window.X6PluginExport) {
graph.use(new window.X6PluginExport.Export());
}
}
+99
View File
@@ -0,0 +1,99 @@
import { state } from '../state.js';
/**
* initGlobalSearch - Initializes the canvas-wide search and highlight functionality
*/
export function initGlobalSearch() {
const searchInput = document.getElementById('global-search');
if (!searchInput) return;
searchInput.addEventListener('input', (e) => {
const query = e.target.value.toLowerCase().trim();
if (!state.graph) return;
const allNodes = state.graph.getNodes();
if (query === '') {
allNodes.forEach(node => {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
node.removeTools();
});
return;
}
allNodes.forEach(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const id = node.id.toLowerCase();
// PM 전용 표준 속성 검색 대상 포함
const searchFields = [
id, label,
(data.type || '').toLowerCase(),
(data.ip || '').toLowerCase(),
(data.model || '').toLowerCase(),
(data.vendor || '').toLowerCase(),
(data.asset_tag || '').toLowerCase(),
(data.serial || '').toLowerCase(),
(data.project || '').toLowerCase(),
(data.tags || []).join(' ').toLowerCase()
];
const isMatch = searchFields.some(field => field.includes(query));
if (isMatch) {
node.attr('body/opacity', 1);
node.attr('image/opacity', 1);
node.attr('label/opacity', 1);
// 검색 강조 툴 추가
node.addTools({
name: 'boundary',
args: {
padding: 5,
attrs: {
fill: 'none',
stroke: '#3b82f6',
strokeWidth: 3,
strokeDasharray: '0',
},
},
});
} else {
node.attr('body/opacity', 0.1);
node.attr('image/opacity', 0.1);
node.attr('label/opacity', 0.1);
node.removeTools();
}
});
});
// 엔터 키 입력 시 검색된 첫 번째 항목으로 포커싱
searchInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
const query = e.target.value.toLowerCase().trim();
if (!query || !state.graph) return;
const matches = state.graph.getNodes().filter(node => {
const data = node.getData() || {};
const label = (node.attr('label/text') || '').toLowerCase();
const searchFields = [
node.id.toLowerCase(), label,
(data.ip || ''), (data.asset_tag || ''), (data.model || '')
];
return searchFields.some(f => f.toLowerCase().includes(query));
});
if (matches.length > 0) {
// 첫 번째 매치된 항목으로 줌인
state.graph.zoomToCell(matches[0], {
padding: 100,
animation: { duration: 600 }
});
state.graph.select(matches[0]);
}
}
});
}
+33
View File
@@ -0,0 +1,33 @@
/**
* graph/styles.js - Main Entry for Graph Styling (Plugin-based)
*/
import { shapeRegistry } from './styles/registry.js';
import { registerNodes } from './styles/node_shapes.js';
import { getX6NodeConfig, getX6EdgeConfig } from './styles/mapping/index.js';
import { getLabelAttributes } from './styles/utils.js';
/**
* registerCustomShapes - Initializes and registers all shape plugins
*/
export function registerCustomShapes() {
// 1. Initialize Registry (Load plugins)
registerNodes();
// 2. Install to X6
if (typeof X6 !== 'undefined') {
shapeRegistry.install(X6);
}
}
/**
* Re-exporting functions for compatibility with other modules
*/
export {
getX6NodeConfig,
getX6EdgeConfig,
getLabelAttributes,
shapeRegistry
};
// Default Theme Update
export function updateGraphTheme() {}
+52
View File
@@ -0,0 +1,52 @@
export function getEdgeConfig(data) {
let routingType = data.routing || 'manhattan';
if (routingType === 'u-shape') routingType = 'manhattan-u';
const srcAnchor = (data.routing === 'u-shape') ? 'top' : (data.source_anchor || 'orth');
const dstAnchor = (data.routing === 'u-shape') ? 'top' : (data.target_anchor || 'orth');
const routerArgs = {
step: 5, // Finer step for better pathfinding
padding: 10, // Reduced padding to avoid "unreachable" errors in tight spaces
};
const directions = ['top', 'bottom', 'left', 'right'];
if (directions.includes(srcAnchor)) routerArgs.startDirections = [srcAnchor];
if (directions.includes(dstAnchor)) routerArgs.endDirections = [dstAnchor];
// For U-shape, we need a bit more padding but not too much to fail
if (data.routing === 'u-shape') routerArgs.padding = 15;
const routerConfigs = {
manhattan: { name: 'manhattan', args: routerArgs },
'manhattan-u': { name: 'manhattan', args: routerArgs },
orthogonal: { name: 'orth' },
straight: null,
metro: { name: 'metro' }
};
return {
id: data.id || `e_${data.source}_${data.target}`,
source: { cell: data.source, anchor: srcAnchor, connectionPoint: 'boundary' },
target: { cell: data.target, anchor: dstAnchor, connectionPoint: 'boundary' },
router: routerConfigs[routingType] || routerConfigs.manhattan,
connector: { name: 'jumpover', args: { type: 'arc', size: 5, radius: 12 } },
attrs: {
line: {
stroke: data.is_tunnel ? (data.color || '#ef4444') : (data.color || '#94a3b8'),
strokeWidth: data.is_tunnel ? 2 : (data.width || 2),
strokeDasharray: data.flow ? '5,5' : (data.is_tunnel ? '6,3' : (data.style === 'dashed' ? '5,5' : '0')),
targetMarker: (data.direction === 'forward' || data.direction === 'both') ? 'block' : null,
sourceMarker: (data.direction === 'backward' || data.direction === 'both') ? 'block' : null,
class: data.flow ? 'flow-animation' : '',
}
},
labels: data.is_tunnel ? [{
attrs: {
text: { text: 'VPN', fill: '#ef4444', fontSize: 10, fontWeight: 'bold' },
rect: { fill: '#ffffff', rx: 4, ry: 4 }
}
}] : [],
data: data
};
}
@@ -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;
}
@@ -0,0 +1,338 @@
/**
* graph/styles/node_shapes.js - Registration of individual shape plugins
*/
import { shapeRegistry } from './registry.js';
import { commonPorts } from './ports.js';
import { generateRackUnitsPath, getLabelAttributes } from './utils.js';
import { DEFAULTS } from '../../constants.js';
/**
* Initialize all shape plugins into the registry
*/
export function registerNodes() {
// 1. Default Node
shapeRegistry.register('default', {
shape: 'drawnet-node',
definition: {
inherit: 'rect',
width: 60, height: 60,
attrs: {
body: { strokeWidth: 0, fill: 'transparent', rx: 4, ry: 4 },
image: { 'xlink:href': `/static/assets/${DEFAULTS.DEFAULT_ICON || 'router.svg'}`, width: 48, height: 48, refX: 6, refY: 6 },
label: {
text: '', fill: '#64748b', fontSize: 12, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'top', refX: 0.5, refY: '100%', refY2: 10,
},
},
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'image', selector: 'image' },
{ tagName: 'text', selector: 'label' },
],
ports: { ...commonPorts }
}
});
// 2. Group Node
shapeRegistry.register('group', {
shape: 'drawnet-group',
definition: {
inherit: 'rect',
width: 200, height: 200,
attrs: {
body: { strokeWidth: 2, stroke: '#3b82f6', fill: '#f1f5f9', fillOpacity: 0.2, rx: 8, ry: 8 },
label: {
text: '', fill: '#1e293b', fontSize: 14, fontFamily: 'Inter, sans-serif',
fontWeight: 'bold', textVerticalAnchor: 'bottom', refX: 0.5, refY: -15,
},
},
ports: { ...commonPorts }
}
});
// 3. Dummy/Blank Node
shapeRegistry.register('dummy', {
shape: 'drawnet-dummy',
definition: {
inherit: 'circle',
width: 8, height: 8,
attrs: {
body: { strokeWidth: 1, stroke: 'rgba(148, 163, 184, 0.2)', fill: 'rgba(255, 255, 255, 0.1)' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 4. Dot Node
shapeRegistry.register('dot', {
shape: 'drawnet-dot',
definition: {
inherit: 'circle',
width: 6, height: 6,
attrs: {
body: { strokeWidth: 1, stroke: '#64748b', fill: '#64748b' },
},
ports: {
groups: { ...commonPorts.groups, center: { position: 'absolute', attrs: { circle: { r: 3, magnet: true, stroke: '#31d0c6', strokeWidth: 1, fill: '#fff' } } } },
items: [{ id: 'center', group: 'center' }],
}
}
});
// 5. Basic Shapes (Primitives)
const basicAttrs = {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' }
};
shapeRegistry.register('rect', {
shape: 'drawnet-rect',
definition: { inherit: 'rect', width: 100, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('rounded-rect', {
shape: 'drawnet-rounded-rect',
definition: {
inherit: 'rect', width: 100, height: 60,
attrs: { ...basicAttrs, body: { ...basicAttrs.body, rx: 12, ry: 12 } },
ports: commonPorts
}
});
shapeRegistry.register('circle', {
shape: 'drawnet-circle',
definition: { inherit: 'circle', width: 60, height: 60, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('ellipse', {
shape: 'drawnet-ellipse',
definition: { inherit: 'ellipse', width: 80, height: 50, attrs: basicAttrs, ports: commonPorts }
});
shapeRegistry.register('text-box', {
shape: 'drawnet-text',
definition: {
inherit: 'rect', width: 100, height: 40,
attrs: { body: { strokeWidth: 0, fill: 'transparent' }, label: { ...basicAttrs.label, text: 'Text', fill: '#1e293b', fontSize: 14 } }
}
});
shapeRegistry.register('label', {
shape: 'drawnet-label',
definition: {
inherit: 'rect', width: 80, height: 30,
attrs: {
...basicAttrs,
body: { ...basicAttrs.body, rx: 4, ry: 4, strokeWidth: 1 },
label: { ...basicAttrs.label, fontSize: 13, fill: '#1e293b' }
},
ports: commonPorts
}
});
// 6. Practical Shapes
shapeRegistry.register('browser', {
shape: 'drawnet-browser',
definition: {
inherit: 'rect', width: 120, height: 80,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'circle', selector: 'btn1' },
{ tagName: 'circle', selector: 'btn2' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff', rx: 4, ry: 4 },
header: { strokeWidth: 2, stroke: '#94a3b8', fill: '#f1f5f9', height: 18, refWidth: '100%', rx: 4, ry: 4 },
btn1: { r: 3, fill: '#cbd5e1', refX: 10, refY: 9 },
btn2: { r: 3, fill: '#cbd5e1', refX: 20, refY: 9 },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: 0.6, textAnchor: 'middle', textVerticalAnchor: 'middle' }
},
ports: commonPorts
}
});
shapeRegistry.register('triangle', {
shape: 'drawnet-triangle',
definition: {
inherit: 'polygon', width: 60, height: 60,
attrs: {
body: { refPoints: '0,10 5,0 10,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
label: { ...basicAttrs.label, refY: 0.7 }
},
ports: commonPorts
}
});
shapeRegistry.register('diamond', {
shape: 'drawnet-diamond',
definition: {
inherit: 'polygon', width: 80, height: 80,
attrs: { body: { refPoints: '0,5 5,0 10,5 5,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('parallelogram', {
shape: 'drawnet-parallelogram',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '2,0 10,0 8,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('cylinder', {
shape: 'drawnet-cylinder',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'top' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 10 L 0 70 C 0 80 60 80 60 70 L 60 10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
top: { d: 'M 0 10 C 0 0 60 0 60 10 C 60 20 0 20 0 10 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('document', {
shape: 'drawnet-document',
definition: {
inherit: 'rect', width: 60, height: 80,
markup: [
{ tagName: 'path', selector: 'body' },
{ tagName: 'path', selector: 'fold' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { d: 'M 0 0 L 45 0 L 60 15 L 60 80 L 0 80 Z', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' },
fold: { d: 'M 45 0 L 45 15 L 60 15', strokeWidth: 2, stroke: '#94a3b8', fill: '#e2e8f0' },
label: basicAttrs.label
},
ports: commonPorts
}
});
shapeRegistry.register('manual-input', {
shape: 'drawnet-manual-input',
definition: {
inherit: 'polygon', width: 100, height: 60,
attrs: { body: { refPoints: '0,3 10,0 10,10 0,10', strokeWidth: 2, stroke: '#94a3b8', fill: '#ffffff' }, label: basicAttrs.label },
ports: commonPorts
}
});
shapeRegistry.register('table', {
// ... (previous table registration)
});
// --- 랙(Rack) 컨테이너 플러그인 ---
shapeRegistry.register('rack', {
shape: 'drawnet-rack',
definition: {
inherit: 'rect',
width: 160, height: 600,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'path', selector: 'units' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 3, stroke: '#334155', fill: '#f8fafc', rx: 4, ry: 4 },
header: { refWidth: '100%', height: 30, fill: '#1e293b', stroke: '#334155', strokeWidth: 2, rx: 4, ry: 4 },
units: {
d: generateRackUnitsPath(600, 42),
stroke: '#94a3b8', strokeWidth: 1
},
label: {
text: 'Rack', fill: '#ffffff', fontSize: 14, fontWeight: 'bold',
refX: 0.5, refY: 15, textAnchor: 'middle', textVerticalAnchor: 'middle'
}
},
ports: commonPorts
}
});
// 8. Rich Text Card (Header + Listing)
shapeRegistry.register('rich-card', {
shape: 'drawnet-rich-card',
definition: {
inherit: 'rect',
width: 280, height: 180,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'rect', selector: 'header' },
{ tagName: 'text', selector: 'headerLabel' },
{
tagName: 'foreignObject',
selector: 'fo',
children: [
{
tagName: 'div',
ns: 'http://www.w3.org/1999/xhtml',
selector: 'content',
style: {
width: '100%',
height: '100%',
padding: '10px',
fontSize: '12px',
color: '#334155',
overflow: 'hidden',
boxSizing: 'border-box',
lineHeight: '1.4'
}
}
]
}
],
attrs: {
body: { fill: '#ffffff', stroke: '#3b82f6', strokeWidth: 1, rx: 4, ry: 4 },
header: { refWidth: '100%', height: 32, fill: '#3b82f6', rx: 4, ry: 4 },
headerLabel: {
text: 'TITLE', fill: '#ffffff', fontSize: 13, fontWeight: 'bold',
refX: 10, refY: 16, textVerticalAnchor: 'middle'
},
fo: { refX: 0, refY: 32, refWidth: '100%', refHeight: 'calc(100% - 32)' }
},
ports: commonPorts
}
});
// 7. Dynamic Icon-based Plugins
const icons = {
'user': '\uf007', 'admin': '\uf505', 'hourglass': '\uf252',
'table': '\uf0ce', 'tag': '\uf02b', 'polyline': '\uf201',
'arrow-up': '\uf062', 'arrow-down': '\uf063', 'arrow-left': '\uf060', 'arrow-right': '\uf061'
};
Object.keys(icons).forEach(type => {
shapeRegistry.register(type, {
shape: 'drawnet-icon',
icon: icons[type],
definition: {
inherit: 'rect', width: 60, height: 60,
markup: [
{ tagName: 'rect', selector: 'body' },
{ tagName: 'text', selector: 'icon' },
{ tagName: 'text', selector: 'label' }
],
attrs: {
body: { strokeWidth: 0, fill: 'transparent' },
icon: { text: icons[type], fontFamily: 'FontAwesome', fontSize: 40, fill: '#64748b', refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle' },
label: { text: '', fill: '#64748b', fontSize: 12, refX: 0.5, refY: '100%', refY2: 12, textAnchor: 'middle', textVerticalAnchor: 'top' }
},
ports: commonPorts
}
});
});
}
+39
View File
@@ -0,0 +1,39 @@
/**
* graph/styles/ports.js - Core port configurations for X6 nodes
*/
export const commonPorts = {
groups: {
top: {
position: 'top',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
bottom: {
position: 'bottom',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
left: {
position: 'left',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
right: {
position: 'right',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
},
center: {
position: 'absolute',
attrs: { circle: { r: 4, magnet: true, stroke: '#31d0c6', strokeWidth: 2, fill: '#fff' } }
}
},
items: [
{ id: 'top', group: 'top' },
{ id: 'bottom', group: 'bottom' },
{ id: 'left', group: 'left' },
{ id: 'right', group: 'right' }
],
};
export const centerPort = {
...commonPorts,
items: [{ id: 'center', group: 'center' }]
};
@@ -0,0 +1,54 @@
import { logger } from '../../utils/logger.js';
/**
* graph/styles/registry.js - Central registry for custom X6 shapes (Plugin System)
*/
class ShapeRegistry {
constructor() {
this.shapes = new Map();
}
/**
* Registers a new shape plugin
* @param {string} type - Node type name (e.g., 'browser', 'triangle')
* @param {Object} options - Shape configuration
* @param {string} options.shape - The X6 shape name to register (e.g., 'drawnet-browser')
* @param {Object} options.definition - The X6 registration object (inherit, markup, attrs, etc.)
* @param {Object} options.mapping - Custom mapping logic or attribute overrides
* @param {string} options.icon - Default FontAwesome icon (if applicable)
*/
register(type, options) {
this.shapes.set(type.toLowerCase(), {
type,
...options
});
}
get(type) {
return this.shapes.get(type.toLowerCase());
}
getAll() {
return Array.from(this.shapes.values());
}
/**
* Internal X6 registration helper
*/
install(X6) {
if (!X6) return;
const registeredShapes = new Set();
this.shapes.forEach((plugin) => {
if (plugin.shape && plugin.definition && !registeredShapes.has(plugin.shape)) {
try {
X6.Graph.registerNode(plugin.shape, plugin.definition);
registeredShapes.add(plugin.shape);
} catch (e) {
logger.high(`Shape [${plugin.shape}] registration failed:`, e);
}
}
});
}
}
export const shapeRegistry = new ShapeRegistry();
+135
View File
@@ -0,0 +1,135 @@
/**
* graph/styles/utils.js - Utility functions for graph styling
*/
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* getLabelAttributes - Returns X6 label attributes based on position and node type
*/
export function getLabelAttributes(pos, nodeType, is_group) {
const defaultPos = is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label', 'browser', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input'].includes(nodeType) ? 'center' : 'bottom');
const actualPos = pos || defaultPos;
switch (actualPos) {
case 'top':
return { refX: 0.5, refY: 0, textAnchor: 'middle', textVerticalAnchor: 'bottom', refY2: -8, refX2: 0 };
case 'bottom':
return { refX: 0.5, refY: '100%', textAnchor: 'middle', textVerticalAnchor: 'top', refY2: 8, refX2: 0 };
case 'left':
return { refX: 0, refY: 0.5, textAnchor: 'end', textVerticalAnchor: 'middle', refY2: 0, refX2: -12 };
case 'right':
return { refX: '100%', refY: 0.5, textAnchor: 'start', textVerticalAnchor: 'middle', refY2: 0, refX2: 12 };
case 'center':
return { refX: 0.5, refY: 0.5, textAnchor: 'middle', textVerticalAnchor: 'middle', refY2: 0, refX2: 0 };
default:
return { refX: 0.5, refY: '100%', textAnchor: 'middle', textVerticalAnchor: 'top', refY2: 8, refX2: 0 };
}
}
/**
* doctorEdge - Debug assistant for checking cell data and attributes
*/
window.doctorEdge = function (id) {
if (!state.graph) return logger.critical("X6 Graph not initialized.");
const cell = state.graph.getCellById(id);
if (!cell) return logger.critical(`Cell [${id}] not found.`);
logger.info(`--- drawNET STYLE DOCTOR (X6) for [${id}] ---`);
logger.info("Data Properties:", cell.getData());
logger.info("Cell Attributes:", cell.getAttrs());
if (cell.isEdge()) {
logger.info("Router:", cell.getRouter());
logger.info("Connector:", cell.getConnector());
logger.info("Source/Target:", {
source: cell.getSource(),
target: cell.getTarget()
});
}
};
/**
* Rack의 높이와 슬롯 수에 따라 U-Unit 눈금 패스(SVG Path)를 생성합니다.
*/
export function generateRackUnitsPath(height, slots = 42) {
let path = '';
const headerHeight = 30; // node_shapes.js 고정값과 동기화
const rackBodyHeight = height - headerHeight;
const unitHeight = rackBodyHeight / slots;
// 단순화된 눈금 (좌우 레일에 작은 선)
for (let i = 0; i < slots; i++) {
const y = headerHeight + (i * unitHeight);
path += `M 5 ${y} L 15 ${y} M 145 ${y} L 155 ${y} `;
}
return path;
}
/**
* renderRichContent - Translates plain text to stylized HTML for the Rich Card
*/
export function renderRichContent(cell, updates) {
if (!state.graph) return;
const view = cell.findView(state.graph);
if (!view) return; // View might not be rendered yet
const container = view.container;
const contentDiv = container.querySelector('div[selector="content"]');
if (!contentDiv) return;
const data = updates || cell.getData() || {};
const content = data.content || '';
const cardType = data.cardType || 'standard';
const headerColor = data.headerColor || '#3b82f6';
const contentAlign = data.contentAlign || 'left';
contentDiv.style.textAlign = contentAlign;
const lines = content.split('\n').filter(line => line.trim() !== '');
let html = '';
if (cardType === 'numbered') {
html = lines.map((line, idx) => `
<div style="display: flex; margin-bottom: 8px; align-items: flex-start;">
<div style="background: ${headerColor}; color: white; width: 18px; height: 18px;
display: flex; align-items: center; justify-content: center;
font-size: 10px; font-weight: bold; border-radius: 2px; margin-right: 8px; flex-shrink: 0;">
${idx + 1}
</div>
<div style="font-size: 11px;">${line}</div>
</div>
`).join('');
} else if (cardType === 'bullet') {
html = lines.map(line => `
<div style="display: flex; margin-bottom: 5px; align-items: flex-start;">
<div style="color: ${headerColor}; margin-right: 8px; flex-shrink: 0;">●</div>
<div style="font-size: 11px;">${line}</div>
</div>
`).join('');
} else if (cardType === 'legend') {
html = lines.map(line => {
const match = line.match(/^-\s*\((.*?):(.*?)\)\s*(.*)/);
if (match) {
const [, shape, color, text] = match;
const iconHtml = shape === 'line'
? `<div style="width: 16px; height: 2px; background: ${color}; margin-top: 8px;"></div>`
: `<div style="width: 12px; height: 12px; background: ${color}; border-radius: ${shape === 'circle' ? '50%' : '2px'}; margin-top: 2px;"></div>`;
return `
<div style="display: flex; margin-bottom: 8px; align-items: flex-start;">
<div style="width: 20px; margin-right: 8px; display: flex; justify-content: center; flex-shrink: 0;">
${iconHtml}
</div>
<div style="font-size: 11px;">${text}</div>
</div>
`;
}
return `<div style="margin-bottom: 5px; font-size: 11px;">${line}</div>`;
}).join('');
} else {
html = lines.map(line => `<div style="margin-bottom: 5px;">${line}</div>`).join('');
}
contentDiv.innerHTML = html;
}
+8
View File
@@ -0,0 +1,8 @@
/**
* Snap a position to the nearest grid interval, accounting for the object's dimension
* to ensure its edges or center align properly with the grid.
*/
export function snapPosition(pos, dimension, spacing) {
const offset = (dimension / 2) % spacing;
return Math.round((pos - offset) / spacing) * spacing + offset;
}
+14
View File
@@ -0,0 +1,14 @@
import { graphHandlers } from './handlers/graph.js';
import { uiHandlers } from './handlers/ui.js';
import { clipboardHandlers } from './handlers/clipboard.js';
import { ioHandlers } from './handlers/io.js';
/**
* actionHandlers - 단축키 동작을 기능별 모듈에서 취합하여 제공하는 매니페스트 객체입니다.
*/
export const actionHandlers = {
...graphHandlers,
...uiHandlers,
...clipboardHandlers,
...ioHandlers
};
@@ -0,0 +1,190 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
const generateShortId = () => Math.random().toString(36).substring(2, 6);
export const clipboardHandlers = {
copyAttributes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
const cell = selected[0];
const data = cell.getData() || {};
if (cell.isNode()) {
const whitelistedData = {};
const dataKeys = ['vendor', 'model', 'status', 'project', 'env', 'tags', 'color', 'asset_path'];
dataKeys.forEach(key => {
if (data[key] !== undefined) whitelistedData[key] = data[key];
});
const whitelistedAttrs = {
label: {
fill: cell.attr('label/fill'),
fontSize: cell.attr('label/fontSize'),
},
body: {
fill: cell.attr('body/fill'),
stroke: cell.attr('body/stroke'),
strokeWidth: cell.attr('body/strokeWidth'),
}
};
state.clipboardNodeData = whitelistedData;
state.clipboardNodeAttrs = whitelistedAttrs;
state.clipboardEdgeData = null; // Clear edge clipboard
logger.info(`Node attributes copied from ${cell.id}`);
} else if (cell.isEdge()) {
const edgeKeys = [
'color', 'routing', 'style', 'source_anchor', 'target_anchor',
'direction', 'is_tunnel', 'routing_offset', 'width', 'flow'
];
const edgeData = {};
edgeKeys.forEach(key => {
if (data[key] !== undefined) edgeData[key] = data[key];
});
state.clipboardEdgeData = edgeData;
state.clipboardNodeData = null; // Clear node clipboard
logger.info(`Edge attributes (Style) copied from ${cell.id}`);
}
},
pasteAttributes: async () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
const hasNodeData = !!state.clipboardNodeData;
const hasEdgeData = !!state.clipboardEdgeData;
if (!hasNodeData && !hasEdgeData) return;
const { handleEdgeUpdate } = await import('../../properties_sidebar/handlers/edge.js');
state.graph.batchUpdate(async () => {
for (const cell of selected) {
if (cell.isNode() && hasNodeData) {
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...state.clipboardNodeData });
if (state.clipboardNodeAttrs) {
const attrs = state.clipboardNodeAttrs;
if (attrs.label) {
if (attrs.label.fill) cell.attr('label/fill', attrs.label.fill);
if (attrs.label.fontSize) cell.attr('label/fontSize', attrs.label.fontSize);
}
if (attrs.body) {
if (attrs.body.fill) cell.attr('body/fill', attrs.body.fill);
if (attrs.body.stroke) cell.attr('body/stroke', attrs.body.stroke);
if (attrs.body.strokeWidth) cell.attr('body/strokeWidth', attrs.body.strokeWidth);
}
}
} else if (cell.isEdge() && hasEdgeData) {
// Apply Edge Formatting
// We use handleEdgeUpdate to ensure physical routers/anchors are updated
await handleEdgeUpdate(cell, state.clipboardEdgeData);
}
}
});
logger.high(`Format Painter applied to ${selected.length} elements.`);
import('../../persistence.js').then(m => m.markDirty());
},
copyNodes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.copy(selected);
logger.high("Nodes copied to clipboard (X6).");
},
pasteNodes: () => {
if (!state.graph || state.graph.isClipboardEmpty()) return;
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const isLogical = activeLayer && activeLayer.type === 'logical';
let pasted = state.graph.paste({ offset: 20 });
if (pasted && pasted.length > 0) {
if (isLogical) {
pasted.forEach(cell => { if (cell.isNode()) cell.remove(); });
pasted = pasted.filter(c => !c.isNode());
if (pasted.length === 0) {
import('/static/js/modules/i18n.js').then(({ t }) => {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
});
}
}
state.graph.batchUpdate(() => {
pasted.forEach(cell => {
if (cell.isNode()) {
const currentData = cell.getData() || {};
const currentLabel = currentData.label || cell.attr('label/text');
if (currentLabel) {
const newLabel = `${currentLabel}_${generateShortId()}`;
cell.setData({ ...currentData, label: newLabel, id: cell.id });
cell.attr('label/text', newLabel);
} else {
cell.setData({ ...currentData, id: cell.id });
}
}
});
});
state.graph.cleanSelection();
state.graph.select(pasted);
}
logger.high("Nodes pasted and randomized (Logical Filter applied).");
import('../../persistence.js').then(m => m.markDirty());
},
duplicateNodes: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.copy(selected);
// Logical Layer Guard: Filter out nodes if target layer is logical
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const isLogical = activeLayer && activeLayer.type === 'logical';
let cloned = state.graph.paste({ offset: 30 });
if (cloned && cloned.length > 0) {
if (isLogical) {
cloned.forEach(cell => { if (cell.isNode()) cell.remove(); });
cloned = cloned.filter(c => !c.isNode());
if (cloned.length === 0) {
import('/static/js/modules/i18n.js').then(({ t }) => {
import('../../ui/utils.js').then(m => m.showToast(t('err_logical_layer_drop'), 'warning'));
});
}
}
state.graph.batchUpdate(() => {
cloned.forEach(cell => {
if (cell.isNode()) {
const currentData = cell.getData() || {};
const currentLabel = currentData.label || cell.attr('label/text');
if (currentLabel) {
const newLabel = `${currentLabel}_${generateShortId()}`;
cell.setData({ ...currentData, label: newLabel, id: cell.id });
cell.attr('label/text', newLabel);
} else {
cell.setData({ ...currentData, id: cell.id });
}
}
});
});
state.graph.cleanSelection();
state.graph.select(cloned);
}
logger.high("Nodes duplicated and randomized (Logical Filter applied).");
import('../../persistence.js').then(m => m.markDirty());
}
};
+111
View File
@@ -0,0 +1,111 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
import { alignNodes, moveNodes, distributeNodes } from '../../graph/alignment.js';
import { handleMenuAction } from '../../ui/context_menu/handlers.js';
export const graphHandlers = {
// ... cleanup ...
deleteSelected: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
state.graph.removeCells(selected);
import('../../persistence.js').then(m => m.markDirty());
},
fitScreen: () => {
if (state.graph) {
state.graph.zoomToFit({ padding: 50 });
logger.high("Graph fit to screen (X6).");
}
},
zoomIn: () => state.graph?.zoom(0.2),
zoomOut: () => state.graph?.zoom(-0.2),
connectNodes: () => handleMenuAction('connect-solid'),
connectStraight: () => handleMenuAction('connect-straight'),
disconnectNodes: () => handleMenuAction('disconnect'),
undo: () => {
if (!state.graph) return;
try {
if (typeof state.graph.undo === 'function') {
state.graph.undo();
logger.info("Undo performed via graph.undo()");
} else {
const history = state.graph.getPlugin('history');
if (history) history.undo();
logger.info("Undo performed via history plugin");
}
} catch (e) {
logger.critical("Undo failed", e);
}
},
redo: () => {
if (!state.graph) return;
try {
if (typeof state.graph.redo === 'function') {
state.graph.redo();
logger.info("Redo performed via graph.redo()");
} else {
const history = state.graph.getPlugin('history');
if (history) history.redo();
logger.info("Redo performed via history plugin");
}
} catch (e) {
logger.critical("Redo failed", e);
}
},
arrangeLayout: () => {
logger.high("Auto-layout is currently disabled (Refactoring).");
},
bringToFront: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
selected.forEach(cell => cell.toFront({ deep: true }));
import('../../persistence.js').then(m => m.markDirty());
},
sendToBack: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
selected.forEach(cell => cell.toBack({ deep: true }));
import('../../persistence.js').then(m => m.markDirty());
},
toggleLock: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
selected.forEach(cell => {
const data = cell.getData() || {};
const isLocked = !data.locked;
cell.setData({ ...data, locked: isLocked });
cell.setProp('movable', !isLocked);
cell.setProp('deletable', !isLocked);
cell.setProp('rotatable', !isLocked);
if (isLocked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
});
import('../../persistence.js').then(m => m.markDirty());
logger.high(`Toggled lock for ${selected.length} elements.`);
},
alignTop: () => alignNodes('top'),
alignBottom: () => alignNodes('bottom'),
alignLeft: () => alignNodes('left'),
alignRight: () => alignNodes('right'),
alignMiddle: () => alignNodes('middle'),
alignCenter: () => alignNodes('center'),
distributeHorizontal: () => distributeNodes('horizontal'),
distributeVertical: () => distributeNodes('vertical'),
groupNodes: () => handleMenuAction('group'),
moveUp: () => moveNodes(0, -5),
moveDown: () => moveNodes(0, 5),
moveLeft: () => moveNodes(-5, 0),
moveRight: () => moveNodes(5, 0),
moveUpLarge: () => moveNodes(0, -(state.gridSpacing || 20)),
moveDownLarge: () => moveNodes(0, (state.gridSpacing || 20)),
moveLeftLarge: () => moveNodes(-(state.gridSpacing || 20), 0),
moveRightLarge: () => moveNodes((state.gridSpacing || 20), 0)
};
+10
View File
@@ -0,0 +1,10 @@
export const ioHandlers = {
exportJson: () => {
const exportBtn = document.getElementById('export-json');
if (exportBtn) exportBtn.click();
},
importJson: () => {
const importBtn = document.getElementById('import-json');
if (importBtn) importBtn.click();
}
};
+34
View File
@@ -0,0 +1,34 @@
import { state } from '../../state.js';
export const uiHandlers = {
toggleInventory: () => {
import('../../graph/analysis.js').then(m => m.toggleInventory());
},
toggleProperties: () => {
import('../../properties_sidebar/index.js').then(m => m.toggleSidebar());
},
toggleLayers: () => {
import('../../ui/layer_panel.js').then(m => m.toggleLayerPanel());
},
editLabel: () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length === 0) return;
import('../../properties_sidebar/index.js').then(m => {
m.toggleSidebar(true);
setTimeout(() => {
const labelInput = document.getElementById('prop-label');
if (labelInput) {
labelInput.focus();
labelInput.select();
}
}, 50);
});
},
cancel: () => {
if (!state.graph) return;
state.graph.cleanSelection();
import('../../properties_sidebar/index.js').then(m => m.toggleSidebar(false));
}
};
+94
View File
@@ -0,0 +1,94 @@
import { state } from '../state.js';
import { actionHandlers } from './handlers.js';
import { logger } from '../utils/logger.js';
/**
* initHotkeys - Initializes the keyboard shortcut system.
*/
export async function initHotkeys() {
if (window.__drawNET_Hotkeys_Initialized) {
logger.high("Hotkeys already initialized. Skipping.");
return;
}
window.__drawNET_Hotkeys_Initialized = true;
try {
const response = await fetch('/static/hotkeys.json');
const config = await response.json();
const hotkeys = config.hotkeys;
window.addEventListener('keydown', (e) => {
if (e.repeat) return;
const editableElements = ['TEXTAREA', 'INPUT'];
const isTyping = editableElements.includes(e.target.tagName) && e.target.id !== 'dark-mode-toggle';
const keyPressed = e.key.toLowerCase();
const keyCode = e.code;
for (const hk of hotkeys) {
// Match by physical code (preferred) or key string
const matchCode = hk.code && hk.code === keyCode;
const matchKey = hk.key && hk.key.toLowerCase() === keyPressed;
if (!(matchCode || matchKey)) continue;
// Match modifiers strictly
const matchCtrl = !!hk.ctrl === e.ctrlKey;
const matchAlt = !!hk.alt === e.altKey;
const matchShift = !!hk.shift === e.shiftKey;
if (matchCtrl && matchAlt && matchShift) {
const allowWhileTyping = hk.alwaysEnabled === true;
if (isTyping && !allowWhileTyping) break;
logger.info(`Hotkey Match - ${hk.action} (${hk.key})`);
if (allowWhileTyping || !isTyping) {
e.preventDefault();
}
const handler = actionHandlers[hk.action];
if (handler) {
handler();
}
return;
}
}
});
logger.high("Hotkeys module initialized (X6).");
} catch (err) {
logger.critical("Failed to load hotkeys configuration (X6).", err);
}
// Ctrl + Wheel: Zoom handling
window.addEventListener('wheel', (e) => {
if (e.ctrlKey) {
e.preventDefault();
if (!state.graph) return;
const delta = e.deltaY > 0 ? -0.1 : 0.1;
state.graph.zoom(delta, { center: { x: e.clientX, y: e.clientY } });
}
}, { passive: false });
// Global Key State Tracking (for Ctrl+Drag copy)
// Extra guard: Check e.ctrlKey/metaKey on mouse events to auto-heal mismatched state
const syncCtrlState = (e) => {
const isModifier = e.ctrlKey || e.metaKey;
if (state.isCtrlPressed !== isModifier) {
state.isCtrlPressed = isModifier;
}
};
window.addEventListener('mousedown', syncCtrlState, true);
window.addEventListener('mousemove', syncCtrlState, true);
window.addEventListener('keydown', (e) => {
if (e.key === 'Control' || e.key === 'Meta') state.isCtrlPressed = true;
}, true);
window.addEventListener('keyup', (e) => {
if (e.key === 'Control' || e.key === 'Meta') state.isCtrlPressed = false;
}, true);
window.addEventListener('blur', () => {
state.isCtrlPressed = false; // Reset on window blur to avoid stuck state
});
}
+43
View File
@@ -0,0 +1,43 @@
import { state } from './state.js';
import { logger } from './utils/logger.js';
export async function initI18n() {
const lang = state.language;
try {
const response = await fetch(`/static/locales/${lang}.json`);
const translations = await response.json();
const enResponse = await fetch('/static/locales/en.json');
const enTranslations = await enResponse.json();
state.i18n = { ...enTranslations, ...translations };
applyTranslations();
logger.high(`i18n initialized for [${lang}]`);
} catch (err) {
logger.critical("Failed to load locales", err);
}
}
export function t(key) {
return (state.i18n && state.i18n[key]) || key;
}
export function applyTranslations() {
const elements = document.querySelectorAll('[data-i18n]');
elements.forEach(el => {
const key = el.getAttribute('data-i18n');
const translation = t(key);
if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') {
el.placeholder = translation;
} else if (el.title) {
el.title = translation;
} else {
el.innerText = translation;
}
});
// Handle Title specifically if needed
const searchInput = document.getElementById('asset-search');
if (searchInput) searchInput.placeholder = t('search_placeholder');
}
+102
View File
@@ -0,0 +1,102 @@
import { state } from './state.js';
import { getProjectData, restoreProjectData } from './graph/io/index.js';
import { getSettings } from './settings/store.js';
import { STORAGE_KEYS } from './constants.js';
import { logger } from './utils/logger.js';
import { t } from './i18n.js';
import { showToast } from './ui/toast.js';
const AUTOSAVE_KEY = STORAGE_KEYS.AUTOSAVE;
let isDirty = false;
/**
* initPersistence - 자동 저장 및 세션 복구 초기화
*/
export function initPersistence() {
// 1. 페이지 이탈 가드
window.addEventListener('beforeunload', (e) => {
if (isDirty) {
e.preventDefault();
e.returnValue = '';
}
});
// 2. 30초마다 자동 저장 (변경사항 있을 때만)
setInterval(() => {
if (isDirty) {
saveToLocal();
}
}, 30000);
// 3. X6 변경 감지
if (state.graph) {
state.graph.on('cell:added cell:removed cell:changed', () => {
markDirty();
});
}
// 4. 세션 복구 확인
checkRecovery();
}
export function markDirty() {
isDirty = true;
}
export function clearDirty() {
isDirty = false;
}
export function clearLastState() {
localStorage.removeItem(AUTOSAVE_KEY);
isDirty = false;
logger.info("Local auto-save session cleared.");
}
function saveToLocal() {
const data = getProjectData();
if (data) {
localStorage.setItem(AUTOSAVE_KEY, JSON.stringify(data));
clearDirty();
showToast(t('msg_autosaved'), 'success', 2000);
logger.info("Progress auto-saved to local storage (X6).");
}
}
async function checkRecovery() {
const saved = localStorage.getItem(AUTOSAVE_KEY);
if (!saved) return;
try {
const data = JSON.parse(saved);
// v3.0 포맷 확인 (graphJson 필드 존재 여부)
const isV3 = data.header?.app === "drawNET Premium" && data.graphJson;
if (!isV3) {
// 구버전 자동저장 데이터는 무효화하고 삭제
logger.high("Legacy auto-save format detected. Clearing.");
localStorage.removeItem(AUTOSAVE_KEY);
return;
}
const nodeCount = (data.graphJson?.cells || []).filter(c => c.shape !== 'edge').length;
if (nodeCount === 0) return;
const settings = getSettings();
setTimeout(() => {
if (settings.autoLoadLast) {
logger.high("Auto-loading last session per user settings.");
showToast(t('msg_restoring'), 'info', 3000);
restoreProjectData(data);
} else if (confirm(t('confirm_recovery'))) {
restoreProjectData(data);
} else {
localStorage.removeItem(AUTOSAVE_KEY);
}
}, 1200);
} catch (e) {
logger.critical("Failed to parse auto-save data.", e);
localStorage.removeItem(AUTOSAVE_KEY);
}
}
@@ -0,0 +1,88 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
/**
* handleEdgeUpdate - Synchronizes property sidebar changes to the X6 Edge cell.
* Ensures data, attributes (markers, colors), anchors, and routers are all updated.
*/
export async function handleEdgeUpdate(cell, data) {
if (cell.isNode()) return;
const updates = {
id: cell.id,
source: cell.getSource().cell,
target: cell.getTarget().cell,
source_port: cell.getSource().port,
target_port: cell.getTarget().port,
// Priority: DOM Element > Provided Data (Format Painter) > Default/Old Data
color: document.getElementById('prop-color')?.value || data?.color,
style: document.getElementById('prop-style')?.value || data?.style,
routing: document.getElementById('prop-routing')?.value || data?.routing,
source_anchor: document.getElementById('prop-src-anchor')?.value || data?.source_anchor || 'orth',
target_anchor: document.getElementById('prop-dst-anchor')?.value || data?.target_anchor || 'orth',
direction: document.getElementById('prop-direction')?.value || data?.direction || 'none',
is_tunnel: document.getElementById('prop-is-tunnel') ? !!document.getElementById('prop-is-tunnel').checked : !!data?.is_tunnel,
width: document.getElementById('prop-width') ? parseFloat(document.getElementById('prop-width').value) : (parseFloat(data?.width) || 2),
routing_offset: document.getElementById('prop-routing-offset') ? parseInt(document.getElementById('prop-routing-offset').value) : (parseInt(data?.routing_offset) || 20),
description: document.getElementById('prop-description') ? document.getElementById('prop-description').value : data?.description,
tags: document.getElementById('prop-tags') ? document.getElementById('prop-tags').value.split(',').map(t => t.trim()).filter(t => t) : (data?.tags || []),
label: document.getElementById('prop-label') ? document.getElementById('prop-label').value : data?.label,
locked: document.getElementById('prop-locked') ? !!document.getElementById('prop-locked').checked : !!data?.locked
};
// 1. Update Cell Data (Single Source of Truth)
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...updates });
// 2. Sync Visual Attributes (Arrows, Colors, Styles)
// We reuse the central mapping logic from styles.js for consistency
const { getX6EdgeConfig } = await import('/static/js/modules/graph/styles.js');
const edgeConfig = getX6EdgeConfig(updates);
// Apply Attributes (Markers, Stroke, Dasharray)
// Note: setAttrs merges by default, but we use it to apply the calculated style object
if (edgeConfig.attrs) {
cell.setAttrs(edgeConfig.attrs);
}
// Apply Labels
if (edgeConfig.labels) {
cell.setLabels(edgeConfig.labels);
}
// 3. Anchor & Router Updates (Physical Pathing)
if (edgeConfig.source) cell.setSource(edgeConfig.source);
if (edgeConfig.target) cell.setTarget(edgeConfig.target);
// Apply Router and Connector (Physical Pathing)
// Clear everything first to prevent old state persistence
cell.setVertices([]);
cell.setRouter(null);
cell.setConnector('normal');
if (edgeConfig.router) {
cell.setRouter(edgeConfig.router);
}
if (edgeConfig.connector) {
cell.setConnector(edgeConfig.connector);
}
// 4. Mark Project as Dirty and Apply Locking/Selection Tools
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
// Selection Tool Preservation: If currently selected, ensure vertices are visible
const isSelected = state.graph.isSelected(cell);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
if (isSelected) {
cell.addTools(['vertices']);
}
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
@@ -0,0 +1,136 @@
import { state } from '../../state.js';
import { logger } from '../../utils/logger.js';
export async function handleNodeUpdate(cell, data) {
const isNode = cell.isNode();
if (!isNode) return;
const updates = {
// Priority: DOM Element > Provided Data (Format Painter) > Default/Old Data
type: document.getElementById('prop-type')?.value || data?.type,
label: document.getElementById('prop-label') ? document.getElementById('prop-label').value : data?.label,
is_group: document.getElementById('prop-is-group') ? !!document.getElementById('prop-is-group').checked : !!data?.is_group,
padding: document.getElementById('prop-padding') ? parseInt(document.getElementById('prop-padding').value) : (data?.padding || undefined),
slots: document.getElementById('prop-slots') ? parseInt(document.getElementById('prop-slots').value) : (data?.slots || undefined),
color: document.getElementById('prop-label-color')?.value || data?.color,
fill: document.getElementById('prop-fill-color')?.value || data?.fill,
border: document.getElementById('prop-border-color')?.value || data?.border,
label_pos: document.getElementById('prop-label-pos')?.value || data?.label_pos,
// PM Standard Attributes
vendor: document.getElementById('prop-vendor') ? document.getElementById('prop-vendor').value : data?.vendor,
model: document.getElementById('prop-model') ? document.getElementById('prop-model').value : data?.model,
ip: document.getElementById('prop-ip') ? document.getElementById('prop-ip').value : data?.ip,
status: document.getElementById('prop-status')?.value || data?.status,
project: document.getElementById('prop-project') ? document.getElementById('prop-project').value : data?.project,
env: document.getElementById('prop-env')?.value || data?.env,
asset_tag: document.getElementById('prop-asset-tag') ? document.getElementById('prop-asset-tag').value : data?.asset_tag,
tags: document.getElementById('prop-tags') ? document.getElementById('prop-tags').value.split(',').map(t => t.trim()).filter(t => t) : (data?.tags || []),
description: document.getElementById('prop-description') ? document.getElementById('prop-description').value : data?.description,
locked: document.getElementById('prop-locked') ? !!document.getElementById('prop-locked').checked : !!data?.locked
};
const pureLabel = updates.label || '';
cell.attr('label/text', pureLabel);
if (updates.color) cell.attr('label/fill', updates.color);
// Apply Fill and Border to body (for primitives and groups)
if (updates.fill) {
cell.attr('body/fill', updates.fill);
if (updates.is_group) updates.background = updates.fill; // Sync for group logic
}
if (updates.border) {
cell.attr('body/stroke', updates.border);
if (updates.is_group) updates['border-color'] = updates.border; // Sync for group logic
}
if (updates.label_pos) {
const { getLabelAttributes } = await import('/static/js/modules/graph/styles.js');
const labelAttrs = getLabelAttributes(updates.label_pos, updates.type, updates.is_group);
Object.keys(labelAttrs).forEach(key => {
cell.attr(`label/${key}`, labelAttrs[key]);
});
}
// Handle group membership via selection (parentId is now a UUID)
const parentId = document.getElementById('prop-parent')?.value;
const currentParent = cell.getParent();
// Case 1: Parent added or changed
if (parentId && (!currentParent || currentParent.id !== parentId)) {
const parent = state.graph.getCellById(parentId);
if (parent) {
parent.addChild(cell);
updates.parent = parentId;
saveUpdates();
return;
}
}
// Case 2: Parent removed (Ungrouping)
if (!parentId && currentParent) {
// 부모 해제 시 경고 설정 확인
const triggerUngroup = async () => {
const { getSettings } = await import('../../settings/store.js');
const settings = getSettings();
if (settings.confirmUngroup !== false) {
const { showConfirmModal } = await import('../../ui/utils.js');
const { t } = await import('../../i18n.js');
showConfirmModal({
title: t('confirm_ungroup_title') || 'Disconnect from Group',
message: t('confirm_ungroup_msg') || 'Are you sure you want to remove this object from its parent group?',
showDontAskAgain: true,
checkboxKey: 'confirmUngroup',
onConfirm: () => {
currentParent.removeChild(cell);
updates.parent = null;
saveUpdates();
},
onCancel: () => {
import('../index.js').then(m => m.renderProperties());
}
});
} else {
currentParent.removeChild(cell);
updates.parent = null;
saveUpdates();
}
};
triggerUngroup();
return;
}
// Case 3: Parent unchanged or no parent (Regular update)
saveUpdates();
function saveUpdates() {
// 최종 데이터 병합 및 저장 (id, layerId 등 기존 메타데이터 보존)
const currentData = cell.getData() || {};
const finalData = { ...currentData, ...updates };
cell.setData(finalData);
// [Instance #2245] Recursive Propagation:
// If this is a group, "poke" all descendants to trigger their change events,
// ensuring the sidebar for any selected child reflects the new parent name.
if (updates.is_group) {
const descendants = cell.getDescendants();
descendants.forEach(child => {
const childData = child.getData() || {};
const syncTime = Date.now();
// We add a timestamp to child data to force X6 to fire 'change:data'
child.setData({ ...childData, _parent_updated: syncTime }, { silent: false });
});
}
// Apply Locking Constraints
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
cell.setProp('rotatable', !updates.locked);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
}
}
@@ -0,0 +1,50 @@
import { state } from '../../state.js';
import { renderRichContent } from '../../graph/styles/utils.js';
export async function handleRichCardUpdate(cell, data) {
const isNode = cell.isNode();
if (!isNode) return;
const updates = {
headerText: document.getElementById('prop-header-text')?.value,
headerColor: document.getElementById('prop-header-color')?.value,
headerAlign: document.getElementById('prop-header-align')?.value || 'left',
cardType: document.getElementById('prop-card-type')?.value,
contentAlign: document.getElementById('prop-content-align')?.value || 'left',
content: document.getElementById('prop-card-content')?.value,
locked: !!document.getElementById('prop-locked')?.checked
};
// 1. Sync to X6 Attributes
cell.attr('headerLabel/text', updates.headerText);
cell.attr('header/fill', updates.headerColor);
cell.attr('body/stroke', updates.headerColor);
// Apply header alignment
const anchor = updates.headerAlign === 'center' ? 'middle' : (updates.headerAlign === 'right' ? 'end' : 'start');
const x = updates.headerAlign === 'center' ? 0.5 : (updates.headerAlign === 'right' ? '100%' : 10);
const x2 = updates.headerAlign === 'right' ? -10 : 0;
cell.attr('headerLabel/textAnchor', anchor);
cell.attr('headerLabel/refX', x);
cell.attr('headerLabel/refX2', x2);
// 2. Render Content via HTML inside ForeignObject
renderRichContent(cell, updates);
// 3. Update Cell Data
const currentData = cell.getData() || {};
cell.setData({ ...currentData, ...updates });
// 4. Handle Lock State
cell.setProp('movable', !updates.locked);
cell.setProp('deletable', !updates.locked);
if (updates.locked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
@@ -0,0 +1,237 @@
import { state } from '../state.js';
import { logger } from '../utils/logger.js';
import { getNodeTemplate, getEdgeTemplate, getMultiSelectTemplate } from './templates.js';
import { getRichCardTemplate } from './templates/rich_card.js';
import { handleNodeUpdate } from './handlers/node.js';
import { handleEdgeUpdate } from './handlers/edge.js';
import { handleRichCardUpdate } from './handlers/rich_card.js';
// --- Utility: Simple Debounce to prevent excessive updates ---
function debounce(func, wait) {
let timeout;
return (...args) => {
clearTimeout(timeout);
timeout = setTimeout(() => func(...args), wait);
};
}
let sidebarElement = null;
export function initPropertiesSidebar() {
sidebarElement = document.getElementById('properties-sidebar');
if (!sidebarElement) return;
const closeBtn = sidebarElement.querySelector('.close-sidebar');
const footerBtn = sidebarElement.querySelector('#sidebar-apply');
const content = sidebarElement.querySelector('.sidebar-content');
if (closeBtn) {
closeBtn.addEventListener('click', () => {
unhighlight();
toggleSidebar(false);
});
}
if (footerBtn) {
footerBtn.addEventListener('click', () => {
unhighlight();
applyChangesGlobal();
toggleSidebar(false);
});
}
// Event Delegation: Register once at initialization to prevent memory leaks and redundant listeners
if (content) {
content.addEventListener('input', (e) => {
const input = e.target;
if (!input.classList.contains('prop-input')) return;
});
content.addEventListener('change', (e) => {
const input = e.target;
if (!input.classList.contains('prop-input')) return;
const isText = (input.type === 'text' || input.tagName === 'TEXTAREA' || input.type === 'number');
if (!isText) {
applyChangesGlobal();
}
});
content.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && e.target.tagName !== 'TEXTAREA') {
applyChangesGlobal();
toggleSidebar(false);
}
});
}
if (state.graph) {
state.graph.on('selection:changed', ({ selected }) => {
unhighlight();
renderProperties();
if (selected && selected.length > 0) toggleSidebar(true);
});
const safeRender = () => {
const active = document.activeElement;
const isEditing = active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA');
if (sidebarElement && sidebarElement.contains(active) && isEditing) {
return;
}
// Use setTimeout to ensure X6 data sync is complete before re-rendering
setTimeout(() => renderProperties(), 0);
};
state.graph.on('cell:change:data', ({ options }) => {
if (options && options.silent) return;
safeRender();
});
state.graph.on('cell:change:attrs', ({ options }) => {
if (options && options.silent) return;
safeRender();
});
}
}
const applyChangesGlobal = async () => {
if (!state.graph) return;
const selected = state.graph.getSelectedCells();
if (selected.length !== 1) return;
const cell = selected[0];
const data = cell.getData() || {};
if (cell.shape === 'drawnet-rich-card') {
await handleRichCardUpdate(cell, data);
} else if (cell.isNode()) {
await handleNodeUpdate(cell, data);
} else {
await handleEdgeUpdate(cell, data);
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
};
const debouncedApplyGlobal = debounce(applyChangesGlobal, 300);
export function toggleSidebar(open) {
if (!sidebarElement) return;
if (open === undefined) {
sidebarElement.classList.toggle('open');
} else if (open) {
sidebarElement.classList.add('open');
} else {
sidebarElement.classList.remove('open');
}
}
export function renderProperties() {
if (!state.graph || !sidebarElement) return;
const selected = state.graph.getSelectedCells();
const content = sidebarElement.querySelector('.sidebar-content');
if (!content) return;
logger.info(`[Sidebar] renderProperties start`, {
selectedCount: selected.length,
firstId: selected[0]?.id
});
if (!selected || selected.length === 0) {
content.innerHTML = `<div style="color: #64748b; text-align: center; margin-top: 40px;">Select an object to edit properties</div>`;
return;
}
if (selected.length > 1) {
content.innerHTML = getMultiSelectTemplate(selected.length);
content.querySelectorAll('.align-btn').forEach(btn => {
btn.addEventListener('click', () => {
const action = btn.getAttribute('data-action');
if (action) import('/static/js/modules/ui/context_menu/handlers.js').then(m => m.handleMenuAction(action));
});
});
const lockToggle = content.querySelector('#prop-locked');
if (lockToggle) {
lockToggle.addEventListener('change', () => {
const isLocked = lockToggle.checked;
selected.forEach(cell => {
const data = cell.getData() || {};
cell.setData({ ...data, locked: isLocked });
cell.setProp('movable', !isLocked);
cell.setProp('deletable', !isLocked);
if (cell.isNode()) cell.setProp('rotatable', !isLocked);
if (isLocked) {
cell.addTools([{ name: 'boundary', args: { attrs: { stroke: '#ef4444', 'stroke-dasharray': '5,5' } } }]);
} else {
cell.removeTools();
}
});
import('/static/js/modules/persistence.js').then(m => m.markDirty());
});
}
return;
}
const cell = selected[0];
const data = cell.getData() || {};
const isNode = cell.isNode();
if (isNode) {
const allGroups = state.graph.getNodes().filter(n => n.getData()?.is_group && n.id !== cell.id);
const liveParent = cell.getParent();
const uiData = { ...data, id: cell.id, parent: liveParent ? liveParent.id : null };
if (cell.shape === 'drawnet-rich-card') {
content.innerHTML = getRichCardTemplate(cell);
} else {
content.innerHTML = getNodeTemplate(uiData, allGroups);
}
} else {
content.innerHTML = getEdgeTemplate({ ...data, id: cell.id });
}
if (!isNode) {
initAnchorHighlighting(cell, content);
}
}
function unhighlight() {
if (!state.graph) return;
state.graph.getNodes().forEach(node => {
const data = node.getData() || {};
if (data._isHighlighting) {
node.attr('body/stroke', data._oldStroke, { silent: true });
node.attr('body/strokeWidth', data._oldWidth, { silent: true });
node.setData({ ...data, _isHighlighting: false }, { silent: true });
}
});
}
function initAnchorHighlighting(cell, container) {
const srcSelect = container.querySelector('#prop-src-anchor');
const dstSelect = container.querySelector('#prop-dst-anchor');
const highlight = (isSource) => {
const endpoint = isSource ? cell.getSource() : cell.getTarget();
if (endpoint && endpoint.cell) {
const targetCell = state.graph.getCellById(endpoint.cell);
if (targetCell && !targetCell.getData()?._isHighlighting) {
targetCell.setData({
_oldStroke: targetCell.attr('body/stroke'),
_oldWidth: targetCell.attr('body/strokeWidth'),
_isHighlighting: true
}, { silent: true });
targetCell.attr('body/stroke', isSource ? '#10b981' : '#ef4444', { silent: true });
targetCell.attr('body/strokeWidth', 6, { silent: true });
}
}
};
srcSelect?.addEventListener('mouseenter', () => highlight(true));
srcSelect?.addEventListener('mouseleave', unhighlight);
dstSelect?.addEventListener('mouseenter', () => highlight(false));
dstSelect?.addEventListener('mouseleave', unhighlight);
}
@@ -0,0 +1,84 @@
/**
* Synchronizes the changes made in the properties sidebar back to the DSL in the editor.
*/
export async function syncToDSL(ele, oldLabel) {
const editor = document.getElementById('editor');
if (!editor) return;
let dsl = editor.value;
const data = ele.getData() || {};
const isNode = ele.isNode();
if (isNode) {
if (data.is_group) {
// Update group definition: group Name { attrs }
const regex = new RegExp(`^group\\s+${oldLabel}(\\s*\\{.*?\\})?`, 'mi');
let attrParts = [];
if (data.background) attrParts.push(`background: ${data.background}`);
if (data.padding) attrParts.push(`padding: ${data.padding}`);
const attrs = attrParts.length > 0 ? `{ ${attrParts.join(', ')} }` : '';
if (regex.test(dsl)) {
dsl = dsl.replace(regex, `group ${data.label} ${attrs}`);
} else {
dsl += `\ngroup ${data.label} ${attrs}\n`;
}
// If label changed, update all 'in' lines
if (oldLabel !== data.label) {
const memberRegex = new RegExp(`\\s+in\\s+${oldLabel}\\b`, 'gi');
dsl = dsl.replace(memberRegex, ` in ${data.label}`);
}
} else {
// Update node definition: Label [Type] { attrs }
const regex = new RegExp(`^\\s*${oldLabel}(\\s*\\[.*?\\])?(\\s*\\{.*?\\})?\\s*(?!->)$`, 'm');
const typeStr = data.type && data.type !== 'default' ? ` [${data.type}]` : '';
const parentStr = data.parent ? ` { parent: ${data.parent} }` : '';
if (regex.test(dsl)) {
dsl = dsl.replace(regex, `${data.label}${typeStr}${parentStr}`);
} else {
dsl += `\n${data.label}${typeStr}${parentStr}\n`;
}
}
} else {
// Update edge definition: Source -> Target { attrs }
const sourceNode = ele.getSourceNode();
const targetNode = ele.getTargetNode();
const sourceLabel = sourceNode?.getData()?.label || sourceNode?.id;
const targetLabel = targetNode?.getData()?.label || targetNode?.id;
if (sourceLabel && targetLabel) {
const escSrc = sourceLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const escDst = targetLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Match the whole line for the edge
const edgeRegex = new RegExp(`^\\s*${escSrc}(?:\\s*\\[.*?\\])?(?:\\s*:[a-z]+)?\\s*->\\s*${escDst}(?:\\s*\\[.*?\\])?(?:\\s*:[a-z]+)?\\s*(\\{.*?\\})?\\s*$`, 'mi');
let attrParts = [];
if (data.color) attrParts.push(`color: ${data.color}`);
if (data.style) attrParts.push(`style: ${data.style}`);
if (data.source_anchor) attrParts.push(`source_anchor: ${data.source_anchor}`);
if (data.target_anchor) attrParts.push(`target_anchor: ${data.target_anchor}`);
if (data.direction && data.direction !== 'forward') attrParts.push(`direction: ${data.direction}`);
if (data.is_tunnel) attrParts.push(`is_tunnel: true`);
if (data.label) attrParts.push(`label: ${data.label}`);
const attrs = attrParts.length > 0 ? ` { ${attrParts.join(', ')} }` : '';
if (edgeRegex.test(dsl)) {
dsl = dsl.replace(edgeRegex, (match) => {
const baseMatch = match.split('{')[0].trim();
return `${baseMatch}${attrs}`;
});
} else {
dsl += `\n${sourceLabel} -> ${targetLabel}${attrs}\n`;
}
}
}
editor.value = dsl;
// Trigger analyze with force=true to avoid race
const editorModule = await import('../editor.js');
editorModule.analyzeTopology(true);
}
@@ -0,0 +1,5 @@
import { getNodeTemplate } from './templates/node.js';
import { getEdgeTemplate } from './templates/edge.js';
import { getMultiSelectTemplate } from './templates/multi.js';
export { getNodeTemplate, getEdgeTemplate, getMultiSelectTemplate };
@@ -0,0 +1,116 @@
import { t } from '../../i18n.js';
/**
* Generates the HTML for edge properties.
*/
export function getEdgeTemplate(data) {
return `
<div class="prop-group horizontal">
<label>${t('prop_id')}</label>
<input type="text" class="prop-input" value="${data.id}" readonly style="opacity: 0.5; font-size: 11px;">
</div>
<div class="prop-group">
<label>${t('prop_label')}</label>
<input type="text" class="prop-input" id="prop-label" value="${data.label || ''}">
</div>
<div class="prop-group">
<label>${t('prop_tags')}</label>
<input type="text" class="prop-input" id="prop-tags" value="${(data.tags || []).join(', ')}">
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_color')}</label>
<input type="color" class="prop-input" id="prop-color" value="${data.color || '#94a3b8'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_style')}</label>
<select class="prop-input" id="prop-style">
<option value="solid" ${data.style === 'solid' ? 'selected' : ''}>${t('solid')}</option>
<option value="dashed" ${data.style === 'dashed' ? 'selected' : ''}>${t('dashed')}</option>
<option value="dotted" ${data.style === 'dotted' ? 'selected' : ''}>${t('dotted')}</option>
<option value="double" ${data.style === 'double' ? 'selected' : ''}>${t('double')}</option>
</select>
</div>
</div>
<div class="prop-group horizontal">
<label>${t('prop_width') || 'Width'}</label>
<input type="number" class="prop-input" id="prop-width" value="${data.width || 2}" min="1" max="10" step="0.5">
</div>
<div class="prop-group">
<label>${t('prop_routing')}</label>
<select class="prop-input" id="prop-routing">
<option value="manhattan" ${data.routing === 'manhattan' || !data.routing ? 'selected' : ''}>${t('manhattan')}</option>
<option value="u-shape" ${data.routing === 'u-shape' ? 'selected' : ''}>${t('u_shape')}</option>
<option value="orthogonal" ${data.routing === 'orthogonal' ? 'selected' : ''}>${t('orthogonal')}</option>
<option value="straight" ${data.routing === 'straight' ? 'selected' : ''}>${t('straight')}</option>
<option value="metro" ${data.routing === 'metro' ? 'selected' : ''}>${t('metro')}</option>
</select>
</div>
<div class="prop-row">
<div class="prop-group">
<label style="color: #10b981; font-weight: bold;">● START</label>
<select class="prop-input" id="prop-src-anchor" style="border-left: 3px solid #10b981;">
<option value="orth" ${data.source_anchor === 'orth' || !data.source_anchor ? 'selected' : ''}>${t('orth')}</option>
<option value="center" ${data.source_anchor === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="left" ${data.source_anchor === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${data.source_anchor === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="top" ${data.source_anchor === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${data.source_anchor === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
</select>
</div>
<div class="prop-group">
<label style="color: #ef4444; font-weight: bold;">● TARGET</label>
<select class="prop-input" id="prop-dst-anchor" style="border-left: 3px solid #ef4444;">
<option value="orth" ${data.target_anchor === 'orth' || !data.target_anchor ? 'selected' : ''}>${t('orth')}</option>
<option value="center" ${data.target_anchor === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="left" ${data.target_anchor === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${data.target_anchor === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="top" ${data.target_anchor === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${data.target_anchor === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
</select>
</div>
</div>
<div class="prop-group horizontal">
<label>${t('prop_direction')}</label>
<select class="prop-input" id="prop-direction">
<option value="forward" ${data.direction === 'forward' ? 'selected' : ''}>${t('forward')}</option>
<option value="backward" ${data.direction === 'backward' ? 'selected' : ''}>${t('backward')}</option>
<option value="both" ${data.direction === 'both' ? 'selected' : ''}>${t('both')}</option>
<option value="none" ${data.direction === 'none' || !data.direction ? 'selected' : ''}>${t('none')}</option>
</select>
</div>
<div class="prop-row">
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch" style="padding: 10px;">
<span style="font-size: 11px;">TUNNEL</span>
<input type="checkbox" id="prop-is-tunnel" class="prop-input" ${data.is_tunnel ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px;">
<span style="color: #ef4444; font-size: 11px;"><i class="fas fa-lock"></i> LOCK</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border);">
<div class="prop-group horizontal">
<label>${t('prop_routing_offset')}</label>
<input type="number" class="prop-input" id="prop-routing-offset" value="${data.routing_offset || 20}" min="0" max="200" step="5">
</div>
<div class="prop-group">
<label>${t('prop_description')}</label>
<textarea class="prop-input" id="prop-description" rows="2" style="resize: vertical; min-height: 48px; font-size: 12px;">${data.description || ''}</textarea>
</div>
</div>
`;
}
@@ -0,0 +1,50 @@
import { state } from '../../state.js';
import { t } from '../../i18n.js';
export function getMultiSelectTemplate(count) {
const t_title = (state.i18n && state.i18n['multi_select_title']) || 'Multiple Selected';
const t_align = (state.i18n && state.i18n['alignment']) || 'Alignment';
const t_dist = (state.i18n && state.i18n['distribution']) || 'Distribution';
return `
<div class="multi-select-header">
<i class="fas fa-layer-group"></i>
<span>${count} ${t_title}</span>
</div>
<div class="prop-group">
<label>${t_align}</label>
<div class="alignment-grid">
<button class="align-btn" data-action="alignTop" title="${t('align_top')} (Shift+1)"><i class="fas fa-align-left fa-rotate-90"></i></button>
<button class="align-btn" data-action="alignBottom" title="${t('align_bottom')} (Shift+2)"><i class="fas fa-align-right fa-rotate-90"></i></button>
<button class="align-btn" data-action="alignLeft" title="${t('align_left')} (Shift+3)"><i class="fas fa-align-left"></i></button>
<button class="align-btn" data-action="alignRight" title="${t('align_right')} (Shift+4)"><i class="fas fa-align-right"></i></button>
<button class="align-btn" data-action="alignMiddle" title="${t('align_middle')} (Shift+5)"><i class="fas fa-grip-lines"></i></button>
<button class="align-btn" data-action="alignCenter" title="${t('align_center')} (Shift+6)"><i class="fas fa-grip-lines-vertical"></i></button>
</div>
</div>
<div class="prop-group">
<label>${t_dist}</label>
<div class="alignment-grid">
<button class="align-btn" data-action="distributeHorizontal" title="${t('distribute_horizontal')} (Shift+7)"><i class="fas fa-arrows-alt-h"></i></button>
<button class="align-btn" data-action="distributeVertical" title="${t('distribute_vertical')} (Shift+8)"><i class="fas fa-arrows-alt-v"></i></button>
</div>
</div>
<div class="toggle-group" style="margin-top: 16px; border-top: 1px solid var(--panel-border); padding-top: 16px;">
<label class="toggle-switch danger">
<span style="color: #ef4444; font-weight: 800;"><i class="fas fa-lock"></i> ${t('prop_locked')} (Bulk)</span>
<input type="checkbox" id="prop-locked" class="prop-input">
<div class="switch-slider"></div>
</label>
</div>
<div style="margin-top: 20px; padding: 12px; background: rgba(59, 130, 246, 0.1); border-radius: 8px; border: 1px dashed rgba(59, 130, 246, 0.3);">
<p style="font-size: 11px; color: #94a3b8; margin: 0; line-height: 1.4;">
<i class="fas fa-lightbulb" style="color: #fbbf24; margin-right: 4px;"></i>
Tip: Use <b>Shift + 1~8</b> to quickly <b>align and distribute</b> selected nodes without opening the sidebar.
</p>
</div>
`;
}
@@ -0,0 +1,163 @@
import { t } from '../../i18n.js';
/**
* Generates the HTML for node properties.
*/
export function getNodeTemplate(data, allGroups) {
return `
<div class="prop-group horizontal">
<label>${t('prop_id')}</label>
<input type="text" class="prop-input" value="${data.id}" readonly style="opacity: 0.5; font-size: 11px;">
</div>
<div class="prop-group">
<label>${t('prop_label')}</label>
<input type="text" class="prop-input" id="prop-label" value="${data.label || ''}">
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_type')}</label>
<input type="text" class="prop-input" id="prop-type" value="${data.type || ''}">
</div>
<div class="prop-group">
<label>${t('prop_parent')}</label>
<select class="prop-input" id="prop-parent">
<option value="">${t('none')}</option>
${allGroups.map(g => {
const gData = g.getData() || {};
const label = gData.label || g.id;
// Debug log to confirm what the template sees
// console.log(`[Template:Node] Parent Option: ID=${g.id}, Label=${label}`);
return `<option value="${g.id}" ${data.parent === g.id ? 'selected' : ''}>${label}</option>`;
}).join('')}
</select>
</div>
</div>
<div class="prop-row" style="margin-bottom: 8px;">
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch" style="padding: 10px;">
<span style="font-size: 11px;">GROUP</span>
<input type="checkbox" id="prop-is-group" class="prop-input" ${data.is_group ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
<div class="toggle-group" style="margin-bottom: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px;">
<span style="color: #ef4444; font-size: 11px;"><i class="fas fa-lock"></i> LOCK</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_label_color')}</label>
<input type="color" class="prop-input" id="prop-label-color" value="${data.color || data['text-color'] || '#64748b'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_label_pos')}</label>
<select class="prop-input" id="prop-label-pos">
${(() => {
const nodeType = (data.type || '').toLowerCase();
const defaultPos = data.is_group ? 'top' : (['rect', 'circle', 'rounded-rect', 'text-box', 'label'].includes(nodeType) ? 'center' : 'bottom');
const currentPos = data.label_pos || defaultPos;
return `
<option value="top" ${currentPos === 'top' ? 'selected' : ''}>${t('top')}</option>
<option value="bottom" ${currentPos === 'bottom' ? 'selected' : ''}>${t('bottom')}</option>
<option value="left" ${currentPos === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="right" ${currentPos === 'right' ? 'selected' : ''}>${t('right')}</option>
<option value="center" ${currentPos === 'center' ? 'selected' : ''}>${t('center')}</option>
`;
})()}
</select>
</div>
</div>
${(() => {
const nodeType = (data.type || '').toLowerCase();
const primitives = ['rect', 'circle', 'rounded-rect', 'text-box', 'label', 'triangle', 'diamond', 'parallelogram', 'cylinder', 'document', 'manual-input', 'rack'];
if (data.is_group || primitives.includes(nodeType)) {
return `
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_fill_color')}</label>
<input type="color" class="prop-input" id="prop-fill-color" value="${data.fill || data.background || '#ffffff'}" style="height: 34px; padding: 2px;">
</div>
<div class="prop-group">
<label>${t('prop_border_color')}</label>
<input type="color" class="prop-input" id="prop-border-color" value="${data.border || data['border-color'] || '#94a3b8'}" style="height: 34px; padding: 2px;">
</div>
</div>
`;
}
return '';
})()}
<div class="prop-row">
${data.is_group ? `
<div class="prop-group">
<label>${t('prop_padding')}</label>
<input type="number" class="prop-input" id="prop-padding" value="${data.padding || 40}" min="0" max="200">
</div>
` : ''}
${data.type === 'rack' ? `
<div class="prop-group">
<label>${t('prop_slots')}</label>
<input type="number" class="prop-input" id="prop-slots" value="${data.slots || 42}" min="1" max="100">
</div>
` : ''}
</div>
<!-- PM Standard Attributes Section -->
<div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--panel-border);">
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_vendor')}</label>
<input type="text" class="prop-input" id="prop-vendor" value="${data.vendor || ''}" placeholder="Cisco">
</div>
<div class="prop-group">
<label>${t('prop_model')}</label>
<input type="text" class="prop-input" id="prop-model" value="${data.model || ''}" placeholder="C9300">
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_ip')}</label>
<input type="text" class="prop-input" id="prop-ip" value="${data.ip || ''}" placeholder="192.168.1.1">
</div>
<div class="prop-group">
<label>${t('prop_status')}</label>
<select class="prop-input" id="prop-status">
<option value="planning" ${data.status === 'planning' ? 'selected' : ''}>Plan</option>
<option value="installed" ${data.status === 'installed' ? 'selected' : ''}>Live</option>
<option value="retired" ${data.status === 'retired' ? 'selected' : ''}>None</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group">
<label>${t('prop_project')}</label>
<input type="text" class="prop-input" id="prop-project" value="${data.project || ''}">
</div>
<div class="prop-group">
<label>${t('prop_env')}</label>
<select class="prop-input" id="prop-env">
<option value="prod" ${data.env === 'prod' ? 'selected' : ''}>PROD</option>
<option value="staging" ${data.env === 'staging' ? 'selected' : ''}>STG</option>
<option value="dev" ${data.env === 'dev' ? 'selected' : ''}>DEV</option>
</select>
</div>
</div>
<div class="prop-group">
<label>${t('prop_tags')}</label>
<input type="text" class="prop-input" id="prop-tags" value="${(data.tags || []).join(', ')}">
</div>
<div class="prop-group">
<label>${t('prop_description')}</label>
<textarea class="prop-input" id="prop-description" rows="2" style="resize: vertical; min-height: 48px; font-size: 12px;">${data.description || ''}</textarea>
</div>
</div>
`;
}
@@ -0,0 +1,74 @@
import { t } from '../../i18n.js';
export function getRichCardTemplate(cell) {
const data = cell.getData() || {};
const headerText = data.headerText || 'TITLE';
const content = data.content || '';
const cardType = data.cardType || 'standard';
const headerColor = data.headerColor || '#3b82f6';
const headerAlign = data.headerAlign || 'left';
const contentAlign = data.contentAlign || 'left';
return `
<div class="prop-section">
<h4 class="section-title"><i class="fas fa-file-alt"></i> ${t('rich_card')}</h4>
<div class="prop-group">
<label>${t('prop_header_text')}</label>
<div class="input-with-color">
<input type="text" id="prop-header-text" class="prop-input" value="${headerText}">
<input type="color" id="prop-header-color" class="prop-input" value="${headerColor}">
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_header_align')}</label>
<select id="prop-header-align" class="prop-input">
<option value="left" ${headerAlign === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="center" ${headerAlign === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="right" ${headerAlign === 'right' ? 'selected' : ''}>${t('right')}</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_card_type')}</label>
<select id="prop-card-type" class="prop-input">
<option value="standard" ${cardType === 'standard' ? 'selected' : ''}>${t('standard')}</option>
<option value="numbered" ${cardType === 'numbered' ? 'selected' : ''}>${t('numbered')}</option>
<option value="bullet" ${cardType === 'bullet' ? 'selected' : ''}>${t('bullet')}</option>
<option value="legend" ${cardType === 'legend' ? 'selected' : ''}>${t('legend')}</option>
</select>
</div>
</div>
<div class="prop-row">
<div class="prop-group horizontal">
<label>${t('prop_content_align')}</label>
<select id="prop-content-align" class="prop-input">
<option value="left" ${contentAlign === 'left' ? 'selected' : ''}>${t('left')}</option>
<option value="center" ${contentAlign === 'center' ? 'selected' : ''}>${t('center')}</option>
<option value="right" ${contentAlign === 'right' ? 'selected' : ''}>${t('right')}</option>
</select>
</div>
</div>
<div class="prop-group">
<label>${t('prop_card_content')}</label>
<textarea id="prop-card-content" class="prop-input" rows="8" style="resize: vertical; min-height: 120px; font-size: 13px;" placeholder="Enter list items...">${content}</textarea>
</div>
<div class="prop-row" style="margin-top: 10px;">
<div class="toggle-group" style="padding: 0; flex: 1;">
<label class="toggle-switch danger" style="padding: 10px 14px;">
<span style="font-size: 12px;"><i class="fas fa-lock" style="margin-right: 4px;"></i>${t('prop_locked')}</span>
<input type="checkbox" id="prop-locked" class="prop-input" ${data.locked ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
</div>
`;
}
+77
View File
@@ -0,0 +1,77 @@
import { STORAGE_KEYS } from '../constants.js';
/**
* settings/store.js - Persistence logic for app settings
*/
export function getSettings() {
return JSON.parse(localStorage.getItem(STORAGE_KEYS.SETTINGS) || '{}');
}
export function updateSetting(key, value) {
const settings = getSettings();
settings[key] = value;
localStorage.setItem(STORAGE_KEYS.SETTINGS, JSON.stringify(settings));
}
export function getTheme() {
return localStorage.getItem(STORAGE_KEYS.THEME) || 'light';
}
export function setTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEYS.THEME, theme);
window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme } }));
}
export function applySavedTheme() {
const theme = getTheme();
document.documentElement.setAttribute('data-theme', theme);
}
export function applySavedSettings() {
const settings = getSettings();
if (settings.bgColor) {
document.documentElement.style.setProperty('--bg-color', settings.bgColor);
}
if (settings.gridStyle) {
window.dispatchEvent(new CustomEvent('gridChanged', {
detail: {
style: settings.gridStyle,
color: settings.gridColor,
thickness: settings.gridThickness,
showMajor: settings.showMajorGrid,
majorColor: settings.majorGridColor,
majorInterval: settings.majorGridInterval
}
}));
}
if (settings.gridSpacing) {
document.documentElement.style.setProperty('--grid-size', `${settings.gridSpacing}px`);
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: settings.gridSpacing } }));
}
if (settings.canvasPreset) {
const val = settings.canvasPreset;
const orient = settings.canvasOrientation || 'portrait';
let w = '100%', h = '100%';
if (val === 'A4') {
w = orient === 'portrait' ? 794 : 1123;
h = orient === 'portrait' ? 1123 : 794;
}
else if (val === 'A3') {
w = orient === 'portrait' ? 1123 : 1587;
h = orient === 'portrait' ? 1587 : 1123;
}
else if (val === 'custom') {
w = settings.canvasWidth;
h = settings.canvasHeight;
}
window.dispatchEvent(new CustomEvent('canvasResize', {
detail: { width: w, height: h }
}));
}
}
+25
View File
@@ -0,0 +1,25 @@
import { t } from '../../i18n.js';
export function renderAbout(container) {
container.innerHTML = `
<h3>${t('about')} drawNET</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">Version</span>
<span class="desc">Premium Edition v2.1.0</span>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">Engine</span>
<span class="desc">AntV X6 + drawNET Proprietary Parser</span>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('identity')}</span>
<span class="desc">Developed for High-Stakes Engineering</span>
</div>
</div>
`;
}
+48
View File
@@ -0,0 +1,48 @@
import { t } from '../../i18n.js';
import { getSettings, updateSetting } from '../store.js';
export function renderEngine(container) {
const settings = getSettings();
container.innerHTML = `
<h3>${t('engine') || 'Engine'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('auto_load_last')}</span>
<span class="desc">${t('auto_load_last_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" id="auto-load-toggle" ${settings.autoLoadLast ? 'checked' : ''}>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('sync_mode')}</span>
<span class="desc">${t('sync_mode_desc')}</span>
</div>
<div class="setting-ctrl">
<select disabled>
<option selected>Manual (Pro)</option>
<option>Real-time (Legacy)</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('auto_save')}</span>
<span class="desc">${t('auto_save_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" checked disabled>
</div>
</div>
`;
}
export function bindEngineEvents(container) {
const autoLoadToggle = container.querySelector('#auto-load-toggle');
if (autoLoadToggle) {
autoLoadToggle.addEventListener('change', (e) => {
updateSetting('autoLoadLast', e.target.checked);
});
}
}
@@ -0,0 +1,73 @@
/**
* settings/tabs/general.js - General Settings Tab
*/
import { setTheme, getSettings } from '../store.js';
import { t } from '../../i18n.js';
export function renderGeneral(container) {
container.innerHTML = `
<h3>${t('identity') || 'Identity'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('dark_mode')}</span>
<span class="desc">${t('dark_mode_desc')}</span>
</div>
<div class="setting-ctrl">
<label class="toggle-switch">
<input type="checkbox" id="dark-mode-toggle" ${document.documentElement.getAttribute('data-theme') === 'dark' ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<h3 style="margin-top: 24px;">${t('editor_behavior') || 'Editor Behavior'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('confirm_ungroup') || 'Confirm before Un-grouping'}</span>
<span class="desc">${t('confirm_ungroup_desc') || 'Show a confirmation dialog when removing an object from a group.'}</span>
</div>
<div class="setting-ctrl">
<label class="toggle-switch">
<input type="checkbox" id="confirm-ungroup-toggle" ${getSettings().confirmUngroup !== false ? 'checked' : ''}>
<div class="switch-slider"></div>
</label>
</div>
</div>
<h3 style="margin-top: 24px;">${t('language') || 'Language'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('language')}</span>
<span class="desc">${t('select_lang_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="lang-select" class="prop-input" style="width: 120px;">
<option value="ko" ${localStorage.getItem('drawNET_lang') === 'ko' ? 'selected' : ''}>한국어</option>
<option value="en" ${localStorage.getItem('drawNET_lang') === 'en' ? 'selected' : ''}>English</option>
</select>
</div>
</div>
`;
}
export function bindGeneralEvents(container) {
const darkModeToggle = container.querySelector('#dark-mode-toggle');
if (darkModeToggle) {
darkModeToggle.addEventListener('change', (e) => setTheme(e.target.checked ? 'dark' : 'light'));
}
const ungroupToggle = container.querySelector('#confirm-ungroup-toggle');
if (ungroupToggle) {
ungroupToggle.addEventListener('change', (e) => {
import('../store.js').then(m => m.updateSetting('confirmUngroup', e.target.checked));
});
}
const langSelect = container.querySelector('#lang-select');
if (langSelect) {
langSelect.addEventListener('change', (e) => {
localStorage.setItem('drawNET_lang', e.target.value);
location.reload();
});
}
}
@@ -0,0 +1,220 @@
/**
* settings/tabs/workspace.js - Workspace (Canvas/Grid) Settings Tab
*/
import { updateSetting } from '../store.js';
import { t } from '../../i18n.js';
export function renderWorkspace(container, settings) {
container.innerHTML = `
<h3>${t('canvas_background') || 'Workspace'}</h3>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_style')}</span>
<span class="desc">${t('grid_style_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="grid-style-select">
<option value="none" ${settings.gridStyle === 'none' ? 'selected' : ''}>${t('grid_none')}</option>
<option value="solid" ${settings.gridStyle === 'solid' ? 'selected' : ''}>${t('grid_solid')}</option>
<option value="dashed" ${settings.gridStyle === 'dashed' ? 'selected' : ''}>${t('grid_dashed')}</option>
</select>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_spacing')}</span>
<span class="desc">${t('grid_spacing_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="grid-spacing-input" value="${settings.gridSpacing || 20}" min="10" max="100" step="5">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('bg_color')}</span>
<span class="desc">${t('bg_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="bg-color-picker" value="${settings.bgColor || '#f8fafc'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_color')}</span>
<span class="desc">${t('grid_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="grid-color-picker" value="${settings.gridColor || '#e2e8f0'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('grid_thickness')}</span>
<span class="desc">${t('grid_thickness_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="grid-thickness-input" value="${settings.gridThickness || 1}" min="0.5" max="5" step="0.5">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('show_major_grid')}</span>
<span class="desc">${t('show_major_grid_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="checkbox" id="major-grid-toggle" ${settings.showMajorGrid ? 'checked' : ''}>
</div>
</div>
<div id="major-grid-options" style="display: ${settings.showMajorGrid ? 'block' : 'none'}">
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('major_grid_color')}</span>
<span class="desc">${t('major_grid_color_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="color" id="major-grid-color-picker" value="${settings.majorGridColor || '#cbd5e1'}">
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('major_grid_interval')}</span>
<span class="desc">${t('major_grid_interval_desc')}</span>
</div>
<div class="setting-ctrl">
<input type="number" id="major-grid-interval-input" value="${settings.majorGridInterval || 5}" min="2" max="20">
</div>
</div>
</div>
<div class="setting-row">
<div class="setting-info">
<span class="label">${t('canvas_preset')}</span>
<span class="desc">${t('canvas_preset_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="canvas-preset">
<option value="full" ${settings.canvasPreset === 'full' ? 'selected' : ''}>${t('canvas_full')}</option>
<option value="A4" ${settings.canvasPreset === 'A4' ? 'selected' : ''}>A4</option>
<option value="A3" ${settings.canvasPreset === 'A3' ? 'selected' : ''}>A3</option>
<option value="custom" ${settings.canvasPreset === 'custom' ? 'selected' : ''}>Custom</option>
</select>
</div>
</div>
<div id="orientation-container" class="setting-row" style="display: ${settings.canvasPreset === 'full' ? 'none' : 'flex'}">
<div class="setting-info">
<span class="label">${t('orientation')}</span>
<span class="desc">${t('orientation_desc')}</span>
</div>
<div class="setting-ctrl">
<select id="canvas-orientation">
<option value="portrait" ${settings.canvasOrientation === 'portrait' ? 'selected' : ''}>${t('portrait')}</option>
<option value="landscape" ${settings.canvasOrientation === 'landscape' ? 'selected' : ''}>${t('landscape')}</option>
</select>
</div>
</div>
<div id="custom-size-inputs" class="setting-row" style="display: ${settings.canvasPreset === 'custom' ? 'flex' : 'none'}; gap: 10px;">
<input type="number" id="canvas-width" value="${settings.canvasWidth || 800}" placeholder="W" style="width: 70px;">
<input type="number" id="canvas-height" value="${settings.canvasHeight || 600}" placeholder="H" style="width: 70px;">
<button id="apply-custom-size" class="footer-btn mini">${t('apply')}</button>
</div>
`;
}
export function bindWorkspaceEvents(container) {
const getGridParams = () => ({
style: container.querySelector('#grid-style-select').value,
color: container.querySelector('#grid-color-picker').value,
thickness: parseFloat(container.querySelector('#grid-thickness-input').value) || 1,
showMajor: container.querySelector('#major-grid-toggle').checked,
majorColor: container.querySelector('#major-grid-color-picker').value,
majorInterval: parseInt(container.querySelector('#major-grid-interval-input').value) || 5
});
const dispatchGridUpdate = () => {
const params = getGridParams();
updateSetting('gridStyle', params.style);
updateSetting('gridColor', params.color);
updateSetting('gridThickness', params.thickness);
updateSetting('showMajorGrid', params.showMajor);
updateSetting('majorGridColor', params.majorColor);
updateSetting('majorGridInterval', params.majorInterval);
const majorOpts = container.querySelector('#major-grid-options');
if (majorOpts) majorOpts.style.display = params.showMajor ? 'block' : 'none';
window.dispatchEvent(new CustomEvent('gridChanged', { detail: params }));
};
const gridStyleSelect = container.querySelector('#grid-style-select');
gridStyleSelect.addEventListener('change', dispatchGridUpdate);
const gridColorPicker = container.querySelector('#grid-color-picker');
gridColorPicker.addEventListener('input', dispatchGridUpdate);
const gridThicknessInput = container.querySelector('#grid-thickness-input');
gridThicknessInput.addEventListener('input', dispatchGridUpdate);
const majorGridToggle = container.querySelector('#major-grid-toggle');
majorGridToggle.addEventListener('change', dispatchGridUpdate);
const majorGridColorPicker = container.querySelector('#major-grid-color-picker');
majorGridColorPicker.addEventListener('input', dispatchGridUpdate);
const majorGridIntervalInput = container.querySelector('#major-grid-interval-input');
majorGridIntervalInput.addEventListener('input', dispatchGridUpdate);
const gridSpacingInput = container.querySelector('#grid-spacing-input');
gridSpacingInput.addEventListener('input', (e) => {
const val = parseInt(e.target.value) || 20;
updateSetting('gridSpacing', val);
document.documentElement.style.setProperty('--grid-size', `${val}px`);
window.dispatchEvent(new CustomEvent('gridSpacingChanged', { detail: { spacing: val } }));
});
const bgColorPicker = container.querySelector('#bg-color-picker');
bgColorPicker.addEventListener('input', (e) => {
updateSetting('bgColor', e.target.value);
document.documentElement.style.setProperty('--bg-color', e.target.value);
});
const canvasPreset = container.querySelector('#canvas-preset');
const orientationContainer = container.querySelector('#orientation-container');
const customSizeDiv = container.querySelector('#custom-size-inputs');
const updateCanvas = () => {
const val = canvasPreset.value;
const orientationSelect = container.querySelector('#canvas-orientation');
const orient = orientationSelect ? orientationSelect.value : 'portrait';
updateSetting('canvasPreset', val);
updateSetting('canvasOrientation', orient);
if (customSizeDiv) customSizeDiv.style.display = val === 'custom' ? 'flex' : 'none';
if (orientationContainer) orientationContainer.style.display = val === 'full' ? 'none' : 'flex';
let w = '100%', h = '100%';
if (val === 'A4') {
w = orient === 'portrait' ? 794 : 1123;
h = orient === 'portrait' ? 1123 : 794;
} else if (val === 'A3') {
w = orient === 'portrait' ? 1123 : 1587;
h = orient === 'portrait' ? 1587 : 1123;
} else if (val === 'custom') {
w = parseInt(container.querySelector('#canvas-width').value) || 800;
h = parseInt(container.querySelector('#canvas-height').value) || 600;
updateSetting('canvasWidth', w);
updateSetting('canvasHeight', h);
}
window.dispatchEvent(new CustomEvent('canvasResize', {
detail: { width: w, height: h }
}));
};
canvasPreset.addEventListener('change', updateCanvas);
const orientationSelect = container.querySelector('#canvas-orientation');
if (orientationSelect) orientationSelect.addEventListener('change', updateCanvas);
const applyCustomBtn = container.querySelector('#apply-custom-size');
if (applyCustomBtn) applyCustomBtn.addEventListener('click', updateCanvas);
}
+75
View File
@@ -0,0 +1,75 @@
/**
* settings/ui.js - Professional Settings Panel UI (Modular)
*/
import { getSettings } from './store.js';
import { renderGeneral, bindGeneralEvents } from './tabs/general.js';
import { renderWorkspace, bindWorkspaceEvents } from './tabs/workspace.js';
import { renderEngine, bindEngineEvents } from './tabs/engine.js';
import { renderAbout } from './tabs/about.js';
let activeTab = 'general';
export function initSettings() {
const settingsBtn = document.getElementById('settings-btn');
const closeBtn = document.getElementById('close-settings-btn');
const panel = document.getElementById('settings-panel');
const tabs = document.querySelectorAll('.settings-tab');
if (settingsBtn && panel) {
settingsBtn.addEventListener('click', () => {
panel.classList.toggle('active');
if (panel.classList.contains('active')) renderTabContent(activeTab);
});
}
if (closeBtn && panel) {
closeBtn.addEventListener('click', () => panel.classList.remove('active'));
}
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
activeTab = tab.getAttribute('data-tab');
renderTabContent(activeTab);
});
});
document.addEventListener('mousedown', (e) => {
if (panel?.classList.contains('active')) {
if (!panel.contains(e.target) && !settingsBtn.contains(e.target)) {
panel.classList.remove('active');
}
}
});
}
function renderTabContent(tabName) {
const body = document.getElementById('settings-body');
if (!body) return;
const settings = getSettings();
body.innerHTML = '';
const section = document.createElement('div');
section.className = 'settings-section';
switch (tabName) {
case 'general':
renderGeneral(section);
bindGeneralEvents(section);
break;
case 'workspace':
renderWorkspace(section, settings);
bindWorkspaceEvents(section);
break;
case 'engine':
renderEngine(section);
bindEngineEvents(section);
break;
case 'about':
renderAbout(section);
break;
}
body.appendChild(section);
}
+41
View File
@@ -0,0 +1,41 @@
import { DEFAULTS } from './constants.js';
// Shared state across modules
export const state = {
assetMap: {},
assetsData: [],
graph: null,
// Grid & Interaction State
gridSpacing: DEFAULTS.GRID_SPACING,
gridStyle: 'none', // 'none', 'solid', or 'dashed'
isSnapEnabled: false,
canvasSize: { width: '100%', height: '100%' },
// Attribute Copy/Paste Clipboard
clipboardNodeData: null,
clipboardNodeAttrs: null,
clipboardEdgeData: null,
// Layer Management
layers: [
{ id: 'l1', name: 'Main Layer', visible: true, locked: false, color: '#3b82f6', type: 'standard' }
],
activeLayerId: 'l1',
inactiveLayerOpacity: 0.3,
// Interaction State
isRightDragging: false,
isCtrlPressed: false, // Global tracking for Ctrl+Drag copy
// Selection Order Tracking
selectionOrder: [],
// Asset Management
selectedPackIds: JSON.parse(localStorage.getItem('selectedPackIds') || '[]'),
language: localStorage.getItem('drawNET_lang') || (navigator.language.startsWith('ko') ? 'ko' : 'en'),
// Global Application Config (from config.json)
appConfig: {}
};
// Expose to window for browser debugging
if (typeof window !== 'undefined') {
window.state = state;
}
// For browser console debugging
window.state = state;
+141
View File
@@ -0,0 +1,141 @@
import { FloatingWindow } from './ui/window.js';
import { logger } from './utils/logger.js';
class ObjectStudioPlugin {
constructor() {
this.win = null;
this.uploadedPath = '';
}
open() {
if (this.win) {
this.win.bringToFront();
return;
}
this.win = new FloatingWindow('studio', t('object_studio'), {
width: 700,
height: 500,
icon: 'fas fa-magic'
});
const content = `
<div class="studio-container" style="display: flex; gap: 30px; height: 100%;">
<!-- Preview Area -->
<div class="preview-area" style="flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--item-bg); border-radius: 20px; border: 2px dashed var(--panel-border);">
<div id="studio-preview-icon" style="width: 120px; height: 120px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-image" style="font-size: 50px; color: var(--sub-text);"></i>
</div>
<p style="font-size: 11px; color: var(--sub-text); margin-top: 15px;">${t('preview')}</p>
</div>
<!-- Form Area -->
<div class="form-area" style="flex: 1.5; display: flex; flex-direction: column; justify-content: space-between;">
<div class="settings-group">
<h3 style="margin-bottom: 20px; font-size: 14px; border-bottom: 1px solid var(--panel-border); padding-bottom: 8px;">${t('basic_info')}</h3>
<div class="setting-item">
<span class="setting-label">${t('id_label')}</span>
<input type="text" id="studio-id" placeholder="${t('id_placeholder')}" style="width: 200px;">
</div>
<div class="setting-item">
<span class="setting-label">${t('display_name')}</span>
<input type="text" id="studio-label" placeholder="${t('name_placeholder')}" style="width: 200px;">
</div>
<div class="setting-item">
<span class="setting-label">${t('category')}</span>
<select id="studio-category" style="width: 200px;">
<option value="Network">${t('Network')}</option>
<option value="Security">${t('Security')}</option>
<option value="Compute">${t('Compute')}</option>
<option value="External">${t('External')}</option>
<option value="Other">${t('Other')}</option>
</select>
</div>
</div>
<div class="settings-group">
<h3 style="margin-bottom: 20px; font-size: 14px; border-bottom: 1px solid var(--panel-border); padding-bottom: 8px;">${t('asset_attachment')}</h3>
<div class="setting-item" style="flex-direction: column; align-items: flex-start; gap: 10px;">
<span class="setting-label">${t('icon_image')}</span>
<div style="display: flex; gap: 10px; width: 100%;">
<input type="file" id="studio-file" accept=".svg,.png,.jpg,.jpeg" style="font-size: 11px; flex: 1;">
<button id="studio-upload-btn" class="btn-secondary" style="padding: 5px 15px; font-size: 10px;">${t('upload') || 'Upload'}</button>
</div>
</div>
</div>
<div class="studio-footer" style="margin-top: 30px; display: flex; justify-content: flex-end; gap: 12px;">
<button id="studio-cancel" class="btn-secondary">${t('close_btn')}</button>
<button id="studio-save" class="btn-primary" style="padding: 10px 40px;">${t('save_device')}</button>
</div>
</div>
</div>
`;
this.win.render(content);
this.initEventListeners();
}
initEventListeners() {
const el = this.win.element;
const fileInput = el.querySelector('#studio-file');
const uploadBtn = el.querySelector('#studio-upload-btn');
const saveBtn = el.querySelector('#studio-save');
const cancelBtn = el.querySelector('#studio-cancel');
cancelBtn.addEventListener('click', () => this.win.destroy());
this.win.onClose = () => { this.win = null; };
uploadBtn.addEventListener('click', () => this.handleUpload(fileInput));
saveBtn.addEventListener('click', () => this.handleSave());
}
async handleUpload(fileInput) {
const file = fileInput.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/studio/upload', { method: 'POST', body: formData });
const data = await res.json();
if (data.success) {
this.uploadedPath = data.path;
const preview = this.win.element.querySelector('#studio-preview-icon');
preview.innerHTML = `<img src="/static/assets/${this.uploadedPath}" style="max-width: 100%; max-height: 100%; transition: all 0.5s cubic-bezier(0.16, 1, 0.3, 1);">`;
preview.querySelector('img').style.transform = 'scale(0.8)';
setTimeout(() => { preview.querySelector('img').style.transform = 'scale(1)'; }, 10);
}
} catch (err) { logger.critical(err); }
}
async handleSave() {
const id = document.getElementById('studio-id').value.trim();
const label = document.getElementById('studio-label').value.trim();
const category = document.getElementById('studio-category').value;
if (!id || !label || !this.uploadedPath) {
alert("Please fill all required fields.");
return;
}
const newAsset = { id, type: label, label, category, path: this.uploadedPath, format: this.uploadedPath.split('.').pop() };
try {
const res = await fetch('/api/studio/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newAsset)
});
const data = await res.json();
if (data.success) {
initAssets();
this.win.destroy();
}
} catch (err) { logger.critical(err); }
}
}
export const studioPlugin = new ObjectStudioPlugin();
export const openStudioModal = () => studioPlugin.open();
+141
View File
@@ -0,0 +1,141 @@
import { studioState } from './state.js';
import * as processor from './processor.js';
import { renderStudioUI } from './renderer.js';
import { logger } from '../utils/logger.js';
import { t } from '../i18n.js';
/**
* Handle individual asset vectorization
*/
export async function startVectorization(id) {
const src = studioState.sources.find(s => s.id === id);
if (!src || src.status === 'processing') return;
src.status = 'processing';
renderStudioUI();
try {
const img = new Image();
img.src = src.previewUrl;
await new Promise(r => img.onload = r);
const pathData = await processor.traceImage(img, {
threshold: src.threshold || 128,
turdsize: 2
});
const svgData = processor.wrapAsSVG(pathData);
studioState.updateSource(id, {
svgData,
status: 'done',
choice: 'svg'
});
} catch (err) {
logger.critical("Trace Error:", err);
src.status = 'error';
}
renderStudioUI();
}
/**
* Bulk update properties for selected assets
*/
export function bulkUpdateProperties(sources, updates) {
sources.forEach(src => {
if (updates.category) src.category = updates.category;
if (updates.vendor) src.vendor = updates.vendor;
});
}
/**
* Final Build and Publish to Server
*/
export async function buildPackage() {
const packIdElement = document.getElementById('pack-id');
const packNameElement = document.getElementById('pack-name');
if (!packIdElement) return;
const packId = packIdElement.value.trim();
const packName = packNameElement ? packNameElement.value.trim() : packId;
if (!packId || studioState.sources.length === 0) {
alert(t('please_enter_pack_id'));
return;
}
const buildBtn = document.getElementById('build-btn');
const originalText = buildBtn.innerHTML;
buildBtn.disabled = true;
buildBtn.innerHTML = `<i class="fas fa-spinner fa-spin"></i> ${t('building')}`;
try {
const formData = new FormData();
formData.append('packId', packId);
formData.append('packName', packName);
const assetsMetadata = studioState.sources.map(src => ({
id: src.id,
label: src.label,
category: src.category,
choice: src.choice,
svgData: src.choice === 'svg' ? src.svgData : null
}));
formData.append('assets', JSON.stringify(assetsMetadata));
studioState.sources.forEach(src => {
if (src.choice === 'png' || (src.type === 'svg' && src.choice === 'svg' && !src.svgData)) {
formData.append(`file_${src.id}`, src.file);
}
});
const response = await fetch('/api/studio/save-pack', {
method: 'POST',
body: formData
});
const result = await response.json();
if (result.success) {
alert(result.message || t('build_success'));
window.location.href = '/';
} else {
throw new Error(result.message || t('build_failed').replace('{message}', ''));
}
} catch (err) {
alert(t('build_failed').replace('{message}', err.message));
buildBtn.disabled = false;
buildBtn.innerHTML = originalText;
}
}
/**
* Load an existing package from the server
*/
export async function loadExistingPack(packId) {
if (!packId) return;
try {
const response = await fetch(`/api/studio/load-pack/${packId}`);
const result = await response.json();
if (result.success) {
studioState.loadFromMetadata(result.data);
// UI에 Pack ID와 Name 입력값 동기화
const packIdInput = document.getElementById('pack-id');
const packNameInput = document.getElementById('pack-name');
const vendorInput = document.getElementById('pack-vendor');
if (packIdInput) packIdInput.value = studioState.packInfo.id;
if (packNameInput) packNameInput.value = studioState.packInfo.name;
if (vendorInput) vendorInput.value = studioState.packInfo.vendor;
renderStudioUI();
logger.info(`drawNET Studio: Package "${packId}" loaded.`);
} else {
alert(t('load_failed').replace('{message}', result.message));
}
} catch (err) {
alert(t('load_error').replace('{message}', err.message));
}
}
+29
View File
@@ -0,0 +1,29 @@
import { initIngester } from './ingester.js';
import { renderStudioUI } from './renderer.js';
import { buildPackage } from './actions.js';
import { logger } from '../utils/logger.js';
/**
* Object Studio Main Entry Point
*/
document.addEventListener('DOMContentLoaded', () => {
initStudio();
});
function initStudio() {
logger.info("DrawNET Studio Initializing...");
// Initialize file ingestion module
initIngester();
// Listen for state updates to trigger re-renders
window.addEventListener('studio-sources-updated', renderStudioUI);
window.addEventListener('studio-selection-updated', renderStudioUI);
// Bind Global Actions
const buildBtn = document.getElementById('build-btn');
if (buildBtn) buildBtn.onclick = buildPackage;
// Initial render
renderStudioUI();
}
+52
View File
@@ -0,0 +1,52 @@
import { studioState } from './state.js';
export function initIngester() {
const dropZone = document.getElementById('drop-zone');
const folderInput = document.getElementById('folder-input');
const fileInput = document.getElementById('file-input');
if (!dropZone || !folderInput) return;
// Handle clicks
dropZone.addEventListener('click', () => folderInput.click());
// Handle folder selection
folderInput.addEventListener('change', (e) => handleFiles(e.target.files));
fileInput.addEventListener('change', (e) => handleFiles(e.target.files));
// Handle drag & drop
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.style.borderColor = '#38bdf8';
dropZone.style.background = 'rgba(56, 189, 248, 0.05)';
});
dropZone.addEventListener('dragleave', () => {
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.borderColor = 'rgba(255, 255, 255, 0.1)';
dropZone.style.background = 'transparent';
handleFiles(e.dataTransfer.files);
});
}
function handleFiles(files) {
const validTypes = ['image/png', 'image/jpeg', 'image/svg+xml'];
let addedCount = 0;
Array.from(files).forEach(file => {
if (validTypes.includes(file.type)) {
studioState.addSource(file);
addedCount++;
}
});
if (addedCount > 0) {
// Trigger UI update (to be implemented in index.js)
window.dispatchEvent(new CustomEvent('studio-sources-updated'));
}
}
+78
View File
@@ -0,0 +1,78 @@
import { potrace, init } from 'https://cdn.jsdelivr.net/npm/esm-potrace-wasm@0.4.1/dist/index.js';
import { logger } from '../utils/logger.js';
let isWasmInitialized = false;
/**
* Image Tracing using WASM Potrace (High performance & quality)
* @param {HTMLImageElement} imageElement
* @param {Object} options
*/
export async function traceImage(imageElement, options = {}) {
if (!isWasmInitialized) {
await init();
isWasmInitialized = true;
}
// Ensure image is loaded
if (imageElement instanceof HTMLImageElement && !imageElement.complete) {
await new Promise(r => imageElement.onload = r);
}
// --- Pre-process with Canvas for Thresholding ---
const canvas = document.createElement('canvas');
canvas.width = imageElement.naturalWidth || imageElement.width;
canvas.height = imageElement.naturalHeight || imageElement.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(imageElement, 0, 0);
const threshold = options.threshold !== undefined ? options.threshold : 128;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Luminance calculation
const brightness = 0.2126 * data[i] + 0.7152 * data[i + 1] + 0.0722 * data[i + 2];
const v = brightness < threshold ? 0 : 255;
data[i] = data[i+1] = data[i+2] = v;
// Keep alpha as is or flatten to white?
// For icons, if alpha is low, treat as white
if (data[i+3] < 128) {
data[i] = data[i+1] = data[i+2] = 255;
data[i+3] = 255;
}
}
ctx.putImageData(imageData, 0, 0);
// Potrace Options (WASM version)
const potraceOptions = {
turdsize: options.turdsize || 2,
turnpolicy: options.turnpolicy || 4,
alphamax: options.alphamax !== undefined ? options.alphamax : 1,
opticurve: options.opticurve !== undefined ? options.opticurve : 1,
opttolerance: options.opttolerance || 0.2
};
try {
// Pass the thresholded canvas instead of the original image
const svgStr = await potrace(canvas, potraceOptions);
return svgStr;
} catch (err) {
logger.critical("WASM Potrace Error:", err);
throw err;
}
}
/**
* Creates a clean SVG string from path data
* (Maintained for API compatibility, though WASM potrace returns full SVG)
*/
export function wrapAsSVG(svgData, width = 64, height = 64) {
if (typeof svgData === 'string' && svgData.trim().startsWith('<svg')) {
return svgData;
}
return `<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${svgData}
</svg>`;
}
+269
View File
@@ -0,0 +1,269 @@
import { studioState } from './state.js';
import * as actions from './actions.js';
import { t } from '../i18n.js';
/**
* Main Studio UI rendering orchestrator
*/
export function renderStudioUI() {
renderSourceList();
renderAssetGrid();
renderPropertyPanel();
}
/**
* Left Panel: Source File List
*/
function renderSourceList() {
const list = document.getElementById('source-list');
const loadArea = document.getElementById('load-package-area');
if (!list) return;
// 1. Render Load Area (only once or update if needed)
if (loadArea && loadArea.innerHTML === "") {
fetch('/assets').then(res => res.json()).then(data => {
const packs = data.packs || [];
loadArea.innerHTML = `
<div style="padding: 15px; border-bottom: 1px solid rgba(255,255,255,0.05); background: rgba(56, 189, 248, 0.03);">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 8px;">${t('studio_edit_existing')}</label>
<div style="display: flex; gap: 8px;">
<select id="exist-pack-select" style="flex: 1; background: #0f172a; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<option value="">${t('studio_select_package')}</option>
${packs.map(p => `<option value="${p.id}">${p.name}</option>`).join('')}
</select>
<button id="load-pack-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 0 10px; border-radius: 4px; cursor: pointer; font-weight: 800; font-size: 11px;">${t('studio_open')}</button>
</div>
</div>
`;
document.getElementById('load-pack-btn').onclick = () => {
const packId = document.getElementById('exist-pack-select').value;
if (packId && confirm(t('confirm_load_package').replace('{id}', packId))) {
actions.loadExistingPack(packId);
}
};
});
}
// 2. Render Source List
list.innerHTML = studioState.sources.map(src => `
<li class="source-item ${studioState.selectedIds.has(src.id) ? 'active' : ''}" data-id="${src.id}">
<i class="fas ${src.type === 'svg' ? 'fa-vector-square' : 'fa-image'}"></i>
<span style="flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${src.name}</span>
<i class="fas fa-times delete-btn" style="font-size: 10px; cursor: pointer;" title="${t('remove')}"></i>
</li>
`).join('');
// Add click listeners
list.querySelectorAll('.source-item').forEach(item => {
const id = item.dataset.id;
item.addEventListener('click', (e) => {
if (e.target.classList.contains('delete-btn')) {
studioState.removeSource(id);
renderStudioUI();
} else {
studioState.toggleSelection(id, e.ctrlKey || e.metaKey);
renderStudioUI();
}
});
});
}
/**
* Center Panel: Asset Preview Grid
*/
function renderAssetGrid() {
const grid = document.getElementById('asset-grid');
if (!grid) return;
if (studioState.sources.length === 0) {
grid.innerHTML = `
<div style="grid-column: 1/-1; text-align: center; color: #475569; margin-top: 100px;">
<i class="fas fa-layer-group" style="font-size: 40px; margin-bottom: 20px; display: block;"></i>
<p>${t('studio_no_assets')}</p>
</div>
`;
return;
}
grid.innerHTML = studioState.sources.map(src => `
<div class="asset-card ${studioState.selectedIds.has(src.id) ? 'selected' : ''}" data-id="${src.id}">
<div class="asset-preview">
<img src="${src.previewUrl}" alt="${src.name}">
</div>
<div class="asset-name">${src.label}</div>
<div style="font-size: 9px; color: #64748b; margin-top: 4px;">${src.choice.toUpperCase()}</div>
</div>
`).join('');
// Add click listeners
grid.querySelectorAll('.asset-card').forEach(card => {
card.addEventListener('click', (e) => {
studioState.toggleSelection(card.dataset.id, e.ctrlKey || e.metaKey);
renderStudioUI();
});
});
}
/**
* Right Panel: Property Settings & Comparison
*/
function renderPropertyPanel() {
const panel = document.querySelector('.property-panel .panel-content');
if (!panel) return;
const selectedSources = studioState.sources.filter(s => studioState.selectedIds.has(s.id));
if (selectedSources.length === 0) {
panel.innerHTML = `
<div style="text-align: center; color: #475569; margin-top: 50px;">
<i class="fas fa-info-circle" style="font-size: 30px; margin-bottom: 15px; display: block;"></i>
<p style="font-size: 13px;">${t('studio_select_prompt')}</p>
</div>
`;
return;
}
if (selectedSources.length === 1) {
const src = selectedSources[0];
panel.innerHTML = renderSingleAssetUI(src);
setupSingleAssetListeners(panel, src);
} else {
panel.innerHTML = renderBulkEditUI(selectedSources.length);
setupBulkEditListeners(panel, selectedSources);
}
}
function renderSingleAssetUI(src) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #38bdf8; margin-bottom: 15px;">${t('studio_asset_detail')}</h3>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('display_name_label')}</label>
<input type="text" id="prop-label" value="${src.label}" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
</div>
<div style="margin-bottom: 12px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 4px;">${t('category')}</label>
<div style="display: flex; gap: 8px;">
<select id="prop-category" style="flex: 1; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
<button id="add-cat-btn" style="background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.1); color: #38bdf8; width: 32px; border-radius: 4px; cursor: pointer;" title="${t('add_category_tooltip')}">+</button>
</div>
<!-- Hidden Add Form -->
<div id="new-cat-area" style="display: none; margin-top: 8px; gap: 8px; align-items: center;">
<input type="text" id="new-cat-name" placeholder="${t('new_category_placeholder')}" style="flex: 1; background: #0f172a; border: 1px solid #38bdf8; color: white; padding: 6px; border-radius: 4px; font-size: 11px;">
<button id="save-cat-btn" style="background: #38bdf8; color: #0f172a; border: none; padding: 6px 10px; border-radius: 4px; font-size: 11px; font-weight: 700;">${t('add')}</button>
</div>
</div>
<div style="margin-bottom: 12px;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px;">
<label style="font-size: 10px; color: #64748b;">${t('trace_threshold')}</label>
<span id="threshold-val" style="font-size: 10px; color: #38bdf8; font-weight: 800;">${src.threshold || 128}</span>
</div>
<input type="range" id="prop-threshold" min="0" max="255" value="${src.threshold || 128}" style="width: 100%; height: 4px; background: #1e293b; border-radius: 2px; cursor: pointer;">
<p style="font-size: 9px; color: #475569; margin-top: 6px;">${t('trace_hint')}</p>
</div>
</div>
<div class="comparison-view">
<div class="comp-box">
<span class="comp-label">${t('original')}</span>
<div class="comp-preview">
<img src="${src.previewUrl}">
</div>
<button class="choice-btn ${src.choice === 'png' ? 'active' : ''}" data-choice="png">${t('keep_png')}</button>
</div>
<div class="comp-box">
<span class="comp-label">${t('vector_svg')}</span>
<div class="comp-preview" id="svg-preview">
${src.svgData ? src.svgData : '<i class="fas fa-magic" style="opacity:0.2"></i>'}
</div>
<button class="choice-btn ${src.choice === 'svg' ? 'active' : ''}"
data-choice="svg"
${!src.svgData ? 'disabled style="opacity:0.3"' : ''}>
${t('use_svg')}
</button>
</div>
</div>
<div style="margin-top: 20px;">
<button id="convert-btn" class="btn-primary" style="width: 100%; font-size: 11px; background: #6366f1;" ${src.status === 'processing' ? 'disabled' : ''}>
${src.status === 'processing' ? `<i class="fas fa-spinner fa-spin"></i> ${t('tracing')}` : t('convert_to_svg')}
</button>
</div>
`;
}
let thresholdTimer = null;
function setupSingleAssetListeners(panel, src) {
panel.querySelector('#prop-label').oninput = (e) => src.label = e.target.value;
panel.querySelector('#prop-category').onchange = (e) => src.category = e.target.value;
panel.querySelector('#prop-category').value = src.category;
panel.querySelector('#add-cat-btn').onclick = () => {
const area = panel.querySelector('#new-cat-area');
area.style.display = area.style.display === 'none' ? 'flex' : 'none';
if (area.style.display === 'flex') panel.querySelector('#new-cat-name').focus();
};
panel.querySelector('#save-cat-btn').onclick = () => {
const input = panel.querySelector('#new-cat-name');
const name = input.value.trim();
if (studioState.addCategory(name)) {
src.category = name;
renderStudioUI();
} else {
panel.querySelector('#new-cat-area').style.display = 'none';
}
};
const thresholdInput = panel.querySelector('#prop-threshold');
const thresholdVal = panel.querySelector('#threshold-val');
thresholdInput.oninput = (e) => {
const val = parseInt(e.target.value);
src.threshold = val;
thresholdVal.textContent = val;
// Debounced Auto-retrace
clearTimeout(thresholdTimer);
thresholdTimer = setTimeout(() => {
actions.startVectorization(src.id);
}, 400);
};
panel.querySelectorAll('.choice-btn').forEach(btn => {
btn.onclick = () => {
if (btn.disabled) return;
studioState.setChoice(src.id, btn.dataset.choice);
renderStudioUI();
};
});
panel.querySelector('#convert-btn').onclick = () => actions.startVectorization(src.id);
}
function renderBulkEditUI(count) {
return `
<div class="settings-group">
<h3 style="font-size: 13px; color: #fbbf24; margin-bottom: 20px;">${t('bulk_edit').replace('{count}', count)}</h3>
<div style="margin-bottom: 15px;">
<label style="display: block; font-size: 10px; color: #64748b; margin-bottom: 5px;">${t('common_category')}</label>
<select id="bulk-category" style="width: 100%; background: #1e293b; border: 1px solid rgba(255,255,255,0.1); color: white; padding: 8px; border-radius: 4px; font-size: 12px;">
<option value="">${t('no_change')}</option>
${studioState.categories.map(cat => `<option value="${cat}">${cat}</option>`).join('')}
</select>
</div>
</div>
<button id="bulk-apply" class="btn-primary" style="width: 100%; font-size: 11px;">${t('apply_to_selected')}</button>
`;
}
function setupBulkEditListeners(panel, selectedSources) {
panel.querySelector('#bulk-apply').onclick = () => {
const cat = panel.querySelector('#bulk-category').value;
actions.bulkUpdateProperties(selectedSources, { category: cat });
renderStudioUI();
};
}
+118
View File
@@ -0,0 +1,118 @@
export const studioState = {
sources: [], // Original files [{file, id, previewUrl, type}]
selectedIds: new Set(),
packInfo: {
id: '',
name: '',
vendor: '',
version: '1.0.0'
},
categories: ['Network', 'Security', 'Compute', 'Cloud', 'Device', 'Other'],
addCategory(name) {
if (name && !this.categories.includes(name)) {
this.categories.push(name);
return true;
}
return false;
},
addSource(file) {
const baseId = file.name.replace(/\.[^/.]+$/, "").toLowerCase().replace(/[^a-z0-9]/g, '_');
let id = baseId;
let counter = 1;
// Ensure ID is unique
while (this.sources.some(s => s.id === id)) {
id = `${baseId}_${counter++}`;
}
const previewUrl = URL.createObjectURL(file);
this.sources.push({
id,
file,
name: file.name,
label: id.split('_').map(word => word.charAt(0).toUpperCase() + word.slice(1)).join(' '),
previewUrl,
category: 'Other',
type: file.type.includes('svg') ? 'svg' : 'png',
choice: file.type.includes('svg') ? 'svg' : 'png', // png, svg
svgData: null,
threshold: 128,
status: 'pending' // pending, processing, done, error
});
},
updateSource(id, data) {
const src = this.sources.find(s => s.id === id);
if (src) Object.assign(src, data);
},
setChoice(id, choice) {
const src = this.sources.find(s => s.id === id);
if (src) src.choice = choice;
},
removeSource(id) {
const index = this.sources.findIndex(s => s.id === id);
if (index > -1) {
URL.revokeObjectURL(this.sources[index].previewUrl);
this.sources.splice(index, 1);
this.selectedIds.delete(id);
}
},
clearSources() {
this.sources.forEach(s => URL.revokeObjectURL(s.previewUrl));
this.sources = [];
this.selectedIds.clear();
},
toggleSelection(id, multi = false) {
if (!multi) {
const wasSelected = this.selectedIds.has(id);
this.selectedIds.clear();
if (!wasSelected) this.selectedIds.add(id);
} else {
if (this.selectedIds.has(id)) this.selectedIds.delete(id);
else this.selectedIds.add(id);
}
},
loadFromMetadata(metadata) {
this.clearSources();
this.packInfo = {
id: metadata.id || '',
name: metadata.name || metadata.id || '',
vendor: metadata.vendor || '',
version: metadata.version || '1.0.0'
};
if (metadata.assets) {
metadata.assets.forEach(asset => {
const choice = asset.views && asset.views.icon && asset.views.icon.endsWith('.svg') ? 'svg' : 'png';
const filename = asset.views ? asset.views.icon : '';
const previewUrl = `/static/assets/packs/${metadata.id}/${filename}`;
this.sources.push({
id: asset.id,
file: null, // No local file object for loaded assets
name: filename,
label: asset.label || asset.id,
previewUrl,
category: asset.category || 'Other',
type: choice,
choice: choice,
svgData: null,
threshold: 128,
status: 'done'
});
// Ensure category exists in the list
if (asset.category && !this.categories.includes(asset.category)) {
this.categories.push(asset.category);
}
});
}
}
};
+11
View File
@@ -0,0 +1,11 @@
import { initSidebar } from './ui/sidebar.js';
import { logger } from './utils/logger.js';
import { initSystemMenu } from './ui/system_menu.js';
import { initGraphIO } from './graph/io/index.js';
export function initUIToggles() {
initSidebar();
initSystemMenu();
logger.info("UI modules initialized.");
}
@@ -0,0 +1,191 @@
import { state } from '../../state.js';
import { toggleSidebar } from '../../properties_sidebar/index.js';
/**
* handleMenuAction - Executes the logic for a clicked context menu item
* Direct X6 API version (Replaces legacy DSL-based handler)
*/
export function handleMenuAction(action, cellId) {
if (!state.graph) return;
// Recursive Selection Focus
if (action === 'select-cell' && cellId) {
const target = state.graph.getCellById(cellId);
if (target) {
state.graph.cleanSelection();
state.graph.select(target);
toggleSidebar(true);
}
return;
}
const selectedCells = state.graph.getSelectedCells();
const selectedNodes = selectedCells.filter(c => c.isNode());
if (action === 'properties') {
if (cellId) {
const target = state.graph.getCellById(cellId);
if (target) {
state.graph.cleanSelection();
state.graph.select(target);
}
}
toggleSidebar(true);
return;
}
// Alignment & Distribution Actions
const isAlignmentAction = action.startsWith('align') || action.startsWith('distribute');
const isZOrderAction = action === 'to-front' || action === 'to-back';
if (isAlignmentAction || isZOrderAction) {
if (action === 'to-front') { selectedCells.forEach(c => c.toFront({ deep: true })); return; }
if (action === 'to-back') { selectedCells.forEach(c => c.toBack({ deep: true })); return; }
import('/static/js/modules/graph/alignment.js').then(m => {
const alignMap = {
'alignLeft': 'left', 'align-left': 'left',
'alignRight': 'right', 'align-right': 'right',
'alignTop': 'top', 'align-top': 'top',
'alignBottom': 'bottom', 'align-bottom': 'bottom',
'alignCenter': 'center', 'align-center': 'center',
'alignMiddle': 'middle', 'align-middle': 'middle'
};
const distMap = {
'distributeHorizontal': 'horizontal', 'distribute-h': 'horizontal',
'distributeVertical': 'vertical', 'distribute-v': 'vertical'
};
if (alignMap[action]) m.alignNodes(alignMap[action]);
else if (distMap[action]) m.distributeNodes(distMap[action]);
});
return;
}
// --- Group Management (X6 API directly) ---
if (action === 'group') {
if (selectedNodes.length < 2) return;
const groupLabel = 'group_' + Math.random().toString(36).substr(2, 4);
// Calculate BBox of selected nodes
const bbox = state.graph.getCellsBBox(selectedNodes);
const padding = 40;
const groupNode = state.graph.addNode({
shape: 'drawnet-node',
x: bbox.x - padding,
y: bbox.y - padding,
width: bbox.width + padding * 2,
height: bbox.height + padding * 2,
zIndex: 1,
data: {
label: groupLabel,
is_group: true,
background: '#e0f2fe',
description: "",
asset_tag: "",
tags: []
},
attrs: {
label: { text: groupLabel },
body: { fill: '#e0f2fe', stroke: '#3b82f6' }
}
});
// Set child nodes
selectedNodes.forEach(n => groupNode.addChild(n));
state.graph.cleanSelection();
state.graph.select(groupNode);
import('/static/js/modules/persistence.js').then(m => m.markDirty());
return;
}
if (action === 'ungroup') {
if (selectedNodes.length === 0) return;
const group = selectedNodes[0];
if (!group.getData()?.is_group) return;
// Separate child nodes
const children = group.getChildren() || [];
children.forEach(child => group.removeChild(child));
state.graph.removeNode(group);
import('/static/js/modules/persistence.js').then(m => m.markDirty());
return;
}
// --- Disconnect (X6 API directly) ---
if (action === 'disconnect') {
if (selectedNodes.length < 2) return;
const nodeIds = new Set(selectedNodes.map(n => n.id));
const edgesToRemove = state.graph.getEdges().filter(e => {
return nodeIds.has(e.getSourceCellId()) && nodeIds.has(e.getTargetCellId());
});
edgesToRemove.forEach(e => state.graph.removeEdge(e));
import('/static/js/modules/persistence.js').then(m => m.markDirty());
return;
}
// --- Connect / Line Style Update actions ---
const selectedEdges = selectedCells.filter(c => c.isEdge());
const styleMap = {
'connect-solid': { routing: 'manhattan' },
'connect-straight': { routing: 'straight' },
'connect-dashed': { style: 'dashed' }
};
if (selectedEdges.length > 0 && styleMap[action]) {
const edgeData = styleMap[action];
selectedEdges.forEach(async (edge) => {
const currentData = edge.getData() || {};
const newData = { ...currentData, ...edgeData };
const { handleEdgeUpdate } = await import('../../properties_sidebar/handlers/edge.js');
await handleEdgeUpdate(edge, newData);
});
return;
}
const connectableNodes = selectedCells.filter(c => c.isNode());
if (connectableNodes.length < 2) return;
// Sort by selection order if available
const sortedNodes = [...connectableNodes].sort((a, b) => {
const idxA = state.selectionOrder.indexOf(a.id);
const idxB = state.selectionOrder.indexOf(b.id);
if (idxA === -1 || idxB === -1) return 0;
return idxA - idxB;
});
const edgeData = styleMap[action] || {};
// Chain connection logic (1 -> 2, 2 -> 3, ...)
for (let i = 0; i < sortedNodes.length - 1; i++) {
const src = sortedNodes[i];
const dst = sortedNodes[i+1];
// Prevent duplicate connections (bi-directional check)
const exists = state.graph.getEdges().some(e =>
(e.getSourceCellId() === src.id && e.getTargetCellId() === dst.id) ||
(e.getSourceCellId() === dst.id && e.getTargetCellId() === src.id)
);
if (exists) continue;
import('/static/js/modules/graph/styles.js').then(({ getX6EdgeConfig }) => {
const config = getX6EdgeConfig({
id: `e_${src.id}_${dst.id}_${Date.now()}`,
source: src.id,
target: dst.id,
description: "",
asset_tag: "",
routing_offset: 20,
...edgeData
});
state.graph.addEdge(config);
});
}
import('/static/js/modules/persistence.js').then(m => m.markDirty());
}
@@ -0,0 +1,79 @@
import { state } from '../../state.js';
import { handleMenuAction } from './handlers.js';
import { generateMenuHTML, generateSelectionMenuHTML, showContextMenu, hideContextMenu } from './renderer.js';
let contextMenuElement = null;
/**
* initContextMenu - Initializes the context menu module and event listeners
*/
export function initContextMenu() {
if (contextMenuElement) return;
// 1. Create and Setup Menu Element
contextMenuElement = document.createElement('div');
contextMenuElement.id = 'floating-context-menu';
contextMenuElement.className = 'floating-menu';
contextMenuElement.style.display = 'none';
contextMenuElement.style.position = 'fixed';
contextMenuElement.style.zIndex = '1000';
document.body.appendChild(contextMenuElement);
// 2. Event Listeners for menu items
contextMenuElement.addEventListener('click', (e) => {
const item = e.target.closest('[data-action]');
if (!item) return;
const action = item.getAttribute('data-action');
const cellId = item.getAttribute('data-cell-id');
if (action) {
handleMenuAction(action, cellId);
}
hideContextMenu(contextMenuElement);
});
// Close menu on click outside
document.addEventListener('click', (e) => {
if (contextMenuElement && !contextMenuElement.contains(e.target)) {
hideContextMenu(contextMenuElement);
}
});
if (state.graph) {
// Listen for right click on Graph Cells
state.graph.on('cell:contextmenu', ({ e, cell }) => {
e.preventDefault();
if (state.isRightDragging) return;
// Photoshop-style recursive selection if Ctrl is pressed
if (e.ctrlKey) {
const local = state.graph.clientToLocal(e.clientX, e.clientY);
const models = state.graph.getCells().filter(cell => cell.getBBox().containsPoint(local));
contextMenuElement.innerHTML = generateSelectionMenuHTML(models);
} else {
const selected = state.graph.getSelectedCells();
const nodes = selected.filter(c => c.isNode());
const isGroupSelected = nodes.length === 1 && nodes[0].getData()?.is_group;
contextMenuElement.innerHTML = generateMenuHTML(nodes, isGroupSelected);
}
showContextMenu(contextMenuElement, e.clientX, e.clientY);
});
// Blank context menu (Handle Ctrl+RightClick here too)
state.graph.on('blank:contextmenu', ({ e }) => {
e.preventDefault();
if (state.isRightDragging) return;
if (e.ctrlKey) {
const local = state.graph.clientToLocal(e.clientX, e.clientY);
const models = state.graph.getCells().filter(cell => cell.getBBox().containsPoint(local));
contextMenuElement.innerHTML = generateSelectionMenuHTML(models);
showContextMenu(contextMenuElement, e.clientX, e.clientY);
} else {
hideContextMenu(contextMenuElement);
}
});
}
}
@@ -0,0 +1,121 @@
/**
* renderer.js - UI Generation and Positioning for Context Menu
*/
export function generateMenuHTML(nodes, isGroupSelected) {
let html = '';
// 1. Connection Group
html += `
<div class="menu-label">Connection</div>
<div class="menu-item" data-action="connect-solid"><i class="fas fa-link" style="margin-right:8px; opacity:0.6;"></i> Connect (Solid)</div>
<div class="menu-item" data-action="connect-straight"><i class="fas fa-slash" style="margin-right:8px; opacity:0.6; transform: rotate(-45deg);"></i> Connect (Straight)</div>
<div class="menu-item" data-action="connect-dashed"><i class="fas fa-ellipsis-h" style="margin-right:8px; opacity:0.6;"></i> Connect (Dashed)</div>
<div class="menu-item" data-action="disconnect"><i class="fas fa-unlink" style="margin-right:8px; opacity:0.6;"></i> Disconnect</div>
`;
// 2. Node Management Group
if (nodes.length >= 2 || isGroupSelected) {
html += `<div class="menu-divider"></div><div class="menu-label">Nodes</div>`;
if (nodes.length >= 2) {
html += `<div class="menu-item" data-action="group"><i class="fas fa-object-group" style="margin-right:8px; opacity:0.6;"></i> Group Selected</div>`;
}
if (isGroupSelected) {
html += `<div class="menu-item" data-action="ungroup"><i class="fas fa-object-ungroup" style="margin-right:8px; opacity:0.6;"></i> Ungroup</div>`;
}
}
// 3. Alignment Group
if (nodes.length >= 2) {
html += `
<div class="menu-divider"></div>
<div class="menu-label">Align & Distribute</div>
<div class="menu-icon-row">
<div class="icon-btn" data-action="align-top" title="Align Top (Shift+1)"><i class="fas fa-align-left fa-rotate-90"></i></div>
<div class="icon-btn" data-action="align-bottom" title="Align Bottom (Shift+2)"><i class="fas fa-align-right fa-rotate-90"></i></div>
<div class="icon-btn" data-action="align-left" title="Align Left (Shift+3)"><i class="fas fa-align-left"></i></div>
<div class="icon-btn" data-action="align-right" title="Align Right (Shift+4)"><i class="fas fa-align-right"></i></div>
<div class="icon-btn" data-action="align-middle" title="Align Middle (Shift+5)"><i class="fas fa-align-center fa-rotate-90"></i></div>
<div class="icon-btn" data-action="align-center" title="Align Center (Shift+6)"><i class="fas fa-align-center"></i></div>
</div>
`;
if (nodes.length >= 3) {
html += `
<div class="menu-icon-row" style="border-top: 1px solid rgba(255,255,255,0.05); margin-top:4px;">
<div class="icon-btn" data-action="distribute-h" title="Distribute Horizontal"><i class="fas fa-arrows-alt-h"></i></div>
<div class="icon-btn" style="opacity:0.2; cursor:default;"><i class="fas fa-arrows-alt-v"></i></div>
</div>
`;
}
}
// 4. Other Group
html += `
<div class="menu-divider"></div>
<div class="menu-item" data-action="properties"><i class="fas fa-cog" style="margin-right:8px; opacity:0.6;"></i> Properties...</div>
`;
return html;
}
export function generateSelectionMenuHTML(models) {
let html = '<div class="menu-label">Select Object</div>';
if (models.length === 0) {
html += '<div class="menu-item disabled">No objects here</div>';
return html;
}
models.forEach(model => {
const data = model.getData() || {};
const type = (data.type || model.shape || 'Unknown').toUpperCase();
const label = data.label || model.id.substring(0, 8);
const iconClass = model.isNode() ? (data.is_group ? 'fa-object-group' : 'fa-microchip') : 'fa-link';
html += `
<div class="menu-item" data-action="select-cell" data-cell-id="${model.id}">
<i class="fas ${iconClass}" style="margin-right:8px; width:14px; opacity:0.6;"></i>
<span style="font-size:10px; opacity:0.5; margin-right:4px;">[${type}]</span>
<span>${label}</span>
</div>
`;
});
return html;
}
/**
* showContextMenu - Positions and displays the menu
*/
export function showContextMenu(el, x, y) {
if (!el) return;
el.style.opacity = '0';
el.style.display = 'block';
const menuWidth = el.offsetWidth;
const menuHeight = el.offsetHeight;
const padding = 10;
const windowWidth = window.innerWidth;
const windowHeight = window.innerHeight;
let left = x;
if (x + menuWidth + padding > windowWidth) {
left = windowWidth - menuWidth - padding;
}
let top = y;
if (y + menuHeight + padding > windowHeight) {
top = windowHeight - menuHeight - padding;
}
el.style.left = `${Math.max(padding, left)}px`;
el.style.top = `${Math.max(padding, top)}px`;
el.style.opacity = '1';
}
/**
* hideContextMenu - Hides the menu
*/
export function hideContextMenu(el) {
if (el) el.style.display = 'none';
}
+152
View File
@@ -0,0 +1,152 @@
import { t } from '../i18n.js';
/**
* help_modal.js - Advanced Tabbed Markdown Guide.
* Uses marked.js for high-fidelity rendering.
*/
export async function showHelpModal() {
const modalContainer = document.getElementById('modal-container');
if (!modalContainer) return;
const existing = document.getElementById('help-modal');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.id = 'help-modal';
overlay.className = 'modal-overlay animate-fade-in active';
overlay.innerHTML = `
<div class="modal-content glass-panel animate-scale-up" style="max-width: 900px; width: 95%; padding: 0; overflow: hidden; display: flex; flex-direction: column; height: 85vh; max-height: 1000px;">
<!-- Header -->
<div class="modal-header" style="padding: 20px 24px; border-bottom: 1px solid var(--panel-border); background: var(--item-bg); display: flex; align-items: center; justify-content: space-between;">
<div style="display: flex; align-items: center; gap: 16px;">
<div style="width: 42px; height: 42px; background: linear-gradient(135deg, #3b82f6, #6366f1); border-radius: 10px; display: flex; align-items: center; justify-content: center; color: white; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);">
<span style="font-weight: 900; font-size: 18px;">dN</span>
</div>
<div>
<h2 style="font-size: 18px; font-weight: 900; color: var(--text-color); margin: 0;">${t('help_center') || 'drawNET Help Center'}</h2>
<p style="font-size: 11px; color: var(--sub-text); margin: 2px 0 0 0;">Engineering Systems Guide & Documentation</p>
</div>
</div>
<button class="modal-close" id="close-help" style="background: none; border: none; font-size: 24px; color: var(--sub-text); cursor: pointer; padding: 4px;">&times;</button>
</div>
<!-- Tab Navigation Bar -->
<div id="help-tabs" style="display: flex; background: var(--item-bg); border-bottom: 1px solid var(--panel-border); padding: 0 12px; overflow-x: auto; gap: 4px; scrollbar-width: none;">
<div style="padding: 15px 20px; color: var(--sub-text); font-size: 12px;">Initializing tabs...</div>
</div>
<!-- Content Area -->
<div id="help-modal-body" class="modal-body" style="padding: 32px 48px; overflow-y: auto; flex: 1; scrollbar-width: thin; background: rgba(var(--bg-rgb), 0.2);">
<div class="loading-state" style="text-align: center; padding: 60px; color: var(--sub-text);">
<i class="fas fa-circle-notch fa-spin"></i> Initializing Help System...
</div>
</div>
<!-- Footer -->
<div class="modal-footer" style="padding: 16px 24px; background: var(--item-bg); text-align: right; border-top: 1px solid var(--panel-border);">
<button class="btn-primary" id="help-confirm" style="padding: 8px 32px; border-radius: 8px; background: var(--accent-color); color: white; border: none; cursor: pointer; font-weight: 700; transition: all 0.2s;">
${t('close_btn') || 'Close Guide'}
</button>
</div>
</div>
`;
modalContainer.appendChild(overlay);
const tabsBar = overlay.querySelector('#help-tabs');
const contentArea = overlay.querySelector('#help-modal-body');
async function loadTabContent(fileId, tabElement) {
// Update tab styles
tabsBar.querySelectorAll('.help-tab').forEach(t => {
t.style.borderBottom = '2px solid transparent';
t.style.color = 'var(--sub-text)';
t.style.background = 'transparent';
});
tabElement.style.borderBottom = '2px solid var(--accent-color)';
tabElement.style.color = 'var(--accent-color)';
tabElement.style.background = 'rgba(59, 130, 246, 0.05)';
contentArea.innerHTML = `
<div style="text-align: center; padding: 60px; color: var(--sub-text);">
<i class="fas fa-circle-notch fa-spin"></i> Loading ${fileId}...
</div>
`;
try {
const response = await fetch(`/manual/${fileId}`);
if (!response.ok) throw new Error('File not found');
let markdown = await response.text();
// Image Path Pre-processing: Fix relative paths for marked.js
markdown = markdown.replace(/!\[(.*?)\]\((.*?)\)/g, (match, alt, src) => {
const finalSrc = (src.startsWith('http') || src.startsWith('/')) ? src : `/manual/${src}`;
return `![${alt}](${finalSrc})`;
});
// Use marked library
const htmlContent = window.marked.parse(markdown);
contentArea.innerHTML = `<div class="markdown-body animate-fade-in">${htmlContent}</div>`;
contentArea.scrollTop = 0;
} catch (err) {
contentArea.innerHTML = `<div style="color: #ef4444; padding: 20px; text-align: center;">Error: ${err.message}</div>`;
}
}
// Load Help File List
try {
const response = await fetch('/api/help/list');
const helpFiles = await response.json();
tabsBar.innerHTML = ''; // Clear "Initializing"
helpFiles.forEach((file, index) => {
const tab = document.createElement('div');
tab.className = 'help-tab';
tab.dataset.id = file.id;
tab.innerText = file.title;
tab.style.cssText = `
padding: 14px 20px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
color: var(--sub-text);
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: all 0.2s;
`;
tab.addEventListener('mouseenter', () => {
if (tab.style.borderBottomColor === 'transparent') {
tab.style.color = 'var(--text-color)';
}
});
tab.addEventListener('mouseleave', () => {
if (tab.style.borderBottomColor === 'transparent') {
tab.style.color = 'var(--sub-text)';
}
});
tab.onclick = () => loadTabContent(file.id, tab);
tabsBar.appendChild(tab);
// Auto-load first tab
if (index === 0) loadTabContent(file.id, tab);
});
} catch (err) {
tabsBar.innerHTML = `<div style="padding: 15px; color: #ef4444;">Failed to load help list.</div>`;
}
const closeModal = () => {
overlay.classList.replace('animate-fade-in', 'animate-fade-out');
overlay.querySelector('.modal-content').classList.replace('animate-scale-up', 'animate-scale-down');
setTimeout(() => overlay.remove(), 200);
};
overlay.querySelector('#close-help').onclick = closeModal;
overlay.querySelector('#help-confirm').onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
}
+393
View File
@@ -0,0 +1,393 @@
import { state } from '../state.js';
import { applyLayerFilters } from '../graph/layers.js';
import { makeDraggable, showConfirmModal, showToast } from './utils.js';
import { markDirty } from '../persistence.js';
import { t } from '../i18n.js';
/**
* Updates the floating active layer indicator in the top-right corner
*/
export function updateActiveLayerIndicator() {
const activeLayer = state.layers.find(l => l.id === state.activeLayerId);
const indicator = document.getElementById('current-layer-name');
const badge = document.getElementById('active-layer-indicator');
if (activeLayer && indicator && badge) {
indicator.innerText = activeLayer.name.toUpperCase();
indicator.style.color = activeLayer.color || '#3b82f6';
// Add a small pop animation
badge.classList.remove('badge-pop');
void badge.offsetWidth; // trigger reflow
badge.classList.add('badge-pop');
}
}
/**
* initLayerPanel - Initializes the Layer Management Panel
*/
export function initLayerPanel() {
const container = document.getElementById('layer-panel');
if (!container) return;
renderLayerPanel(container);
updateActiveLayerIndicator(); // Sync on load
// Refresh UI on project load/recovery
if (state.graph) {
state.graph.on('project:restored', () => {
renderLayerPanel(container);
applyLayerFilters();
updateActiveLayerIndicator();
});
}
}
/**
* toggleLayerPanel - Shows/Hides the layer panel
*/
export function toggleLayerPanel() {
const container = document.getElementById('layer-panel');
if (!container) return;
if (container.style.display === 'none' || container.style.display === '') {
container.style.display = 'flex';
renderLayerPanel(container); // Refresh state
} else {
container.style.display = 'none';
}
}
/**
* renderLayerPanel - Renders the internal content of the Layer Panel
*/
export function renderLayerPanel(container) {
container.innerHTML = `
<div class="panel-header">
<i class="fas fa-layer-group"></i> <span>Layers</span>
<button id="add-layer-btn" title="Add Layer"><i class="fas fa-plus"></i></button>
</div>
<div id="layer-list" class="layer-list">
${state.layers.map(layer => `
<div class="layer-item ${state.activeLayerId === layer.id ? 'active' : ''}" data-id="${layer.id}" draggable="true">
<i class="fas fa-grip-vertical drag-handle"></i>
<div class="layer-type-toggle" data-id="${layer.id}" title="${layer.type === 'logical' ? 'Logical (Edge Only)' : 'Standard (All)'}">
<i class="fas ${layer.type === 'logical' ? 'fa-project-diagram' : 'fa-desktop'}" style="color: ${layer.type === 'logical' ? '#10b981' : '#64748b'}; font-size: 11px;"></i>
</div>
<div class="layer-color" style="background: ${layer.color}"></div>
<span class="layer-name" data-id="${layer.id}">${layer.name}</span>
<div class="layer-actions">
<button class="toggle-lock" data-id="${layer.id}" title="${layer.locked ? 'Unlock Layer' : 'Lock Layer'}">
<i class="fas ${layer.locked ? 'fa-lock' : 'fa-lock-open'}" style="color: ${layer.locked ? '#ef4444' : '#64748b'}; font-size: 11px;"></i>
</button>
<button class="rename-layer" data-id="${layer.id}" title="Rename Layer" ${layer.locked ? 'disabled style="opacity:0.3; cursor:not-allowed;"' : ''}>
<i class="fas fa-pen" style="font-size: 10px;"></i>
</button>
<button class="toggle-vis" data-id="${layer.id}">
<i class="fas ${layer.visible ? 'fa-eye' : 'fa-eye-slash'}"></i>
</button>
</div>
</div>
`).join('')}
</div>
<div class="layer-footer">
<div class="opacity-control">
<i class="fas fa-ghost" title="Inactive Layer Opacity"></i>
<input type="range" id="layer-opacity-slider" min="0" max="1" step="0.1" value="${state.inactiveLayerOpacity}">
<span id="opacity-val">${Math.round(state.inactiveLayerOpacity * 100)}%</span>
</div>
<div id="layer-trash" class="layer-trash" title="Drag layer here to delete">
<i class="fas fa-trash-alt"></i>
<span>Discard Layer</span>
</div>
</div>
`;
// Drag and Drop reordering logic
const layerList = container.querySelector('#layer-list');
if (layerList) {
layerList.addEventListener('dragstart', (e) => {
const item = e.target.closest('.layer-item');
if (item) {
e.dataTransfer.setData('text/plain', item.dataset.id);
item.classList.add('dragging');
}
});
layerList.addEventListener('dragend', (e) => {
const item = e.target.closest('.layer-item');
if (item) item.classList.remove('dragging');
});
layerList.addEventListener('dragover', (e) => {
e.preventDefault();
const draggingItem = layerList.querySelector('.dragging');
const targetItem = e.target.closest('.layer-item');
if (targetItem && targetItem !== draggingItem) {
const rect = targetItem.getBoundingClientRect();
const next = (e.clientY - rect.top) > (rect.height / 2);
layerList.insertBefore(draggingItem, next ? targetItem.nextSibling : targetItem);
}
});
layerList.addEventListener('drop', (e) => {
e.preventDefault();
const newOrderIds = Array.from(layerList.querySelectorAll('.layer-item')).map(el => el.dataset.id);
const newLayers = newOrderIds.map(id => state.layers.find(l => l.id === id));
state.layers = newLayers;
markDirty();
applyLayerFilters();
renderLayerPanel(container);
});
}
// Trash Drop Handling
const trash = container.querySelector('#layer-trash');
if (trash) {
trash.ondragover = (e) => {
e.preventDefault();
trash.classList.add('drag-over');
};
trash.ondragleave = () => trash.classList.remove('drag-over');
trash.ondrop = (e) => {
e.preventDefault();
trash.classList.remove('drag-over');
const id = e.dataTransfer.getData('text/plain');
if (id) deleteLayer(id);
};
}
// Events
const slider = document.getElementById('layer-opacity-slider');
const opacityVal = document.getElementById('opacity-val');
if (slider) {
slider.oninput = (e) => {
const val = parseFloat(e.target.value);
state.inactiveLayerOpacity = val;
if (opacityVal) opacityVal.innerText = `${Math.round(val * 100)}%`;
applyLayerFilters();
};
}
const startRename = (id, nameSpan) => {
const layer = state.layers.find(l => l.id === id);
if (!layer) return;
if (layer.locked) {
showToast(t('err_layer_locked'), 'warning');
return;
}
const input = document.createElement('input');
input.type = 'text';
input.className = 'layer-rename-input';
input.value = layer.name;
nameSpan.replaceWith(input);
input.focus();
input.select();
const finishRename = () => {
const newName = input.value.trim();
if (newName && newName !== layer.name) {
layer.name = newName;
markDirty();
updateActiveLayerIndicator();
}
renderLayerPanel(container);
};
input.onblur = finishRename;
input.onkeydown = (e) => {
if (e.key === 'Enter') finishRename();
if (e.key === 'Escape') renderLayerPanel(container);
};
};
container.querySelectorAll('.layer-name').forEach(nameSpan => {
nameSpan.ondblclick = (e) => {
e.stopPropagation();
startRename(nameSpan.dataset.id, nameSpan);
};
});
container.querySelectorAll('.rename-layer').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const nameSpan = container.querySelector(`.layer-name[data-id="${id}"]`);
if (nameSpan) startRename(id, nameSpan);
};
});
container.querySelectorAll('.layer-item').forEach(item => {
item.onclick = (e) => {
if (e.target.tagName === 'INPUT' || e.target.closest('.layer-actions')) return;
const id = item.dataset.id;
const layer = state.layers.find(l => l.id === id);
state.activeLayerId = id;
renderLayerPanel(container);
updateActiveLayerIndicator();
applyLayerFilters();
};
});
container.querySelectorAll('.toggle-vis').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
layer.visible = !layer.visible;
renderLayerPanel(container);
applyLayerFilters();
}
};
});
container.querySelectorAll('.toggle-lock').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
layer.locked = !layer.locked;
markDirty();
renderLayerPanel(container);
}
};
});
container.querySelectorAll('.layer-type-toggle').forEach(btn => {
btn.onclick = (e) => {
e.stopPropagation();
const id = btn.dataset.id;
const layer = state.layers.find(l => l.id === id);
if (layer) {
// Clear nudge if active
btn.classList.remove('pulse-nudge');
const tooltip = btn.querySelector('.nudge-tooltip');
if (tooltip) tooltip.remove();
layer.type = (layer.type === 'logical') ? 'standard' : 'logical';
markDirty();
renderLayerPanel(container);
updateActiveLayerIndicator();
}
};
});
const addBtn = document.getElementById('add-layer-btn');
if (addBtn) {
addBtn.onclick = () => {
const nextIndex = state.layers.length + 1;
const newId = `l${Date.now()}`;
state.layers.push({
id: newId,
name: `Layer ${nextIndex}`,
visible: true,
locked: false,
type: 'standard', // Explicitly standard
color: `#${Math.floor(Math.random()*16777215).toString(16).padStart(6, '0')}`
});
state.activeLayerId = newId;
renderLayerPanel(container);
updateActiveLayerIndicator();
applyLayerFilters();
// Apply Nudge & Guide Toast
setTimeout(() => {
const newToggle = container.querySelector(`.layer-type-toggle[data-id="${newId}"]`);
if (newToggle) {
newToggle.classList.add('pulse-nudge');
// Add mini tooltip
const tooltip = document.createElement('div');
tooltip.className = 'nudge-tooltip';
tooltip.innerText = t('nudge_logical_hint');
newToggle.appendChild(tooltip);
// Show breadcrumb toast
showToast(t('nudge_new_layer'), 'info', 4000);
// Auto-cleanup nudge
setTimeout(() => {
newToggle.classList.remove('pulse-nudge');
tooltip.style.animation = 'tooltipFadeOut 0.4s ease-in forwards';
setTimeout(() => tooltip.remove(), 400);
}, 5000);
}
}, 100);
};
}
// Delete Logic
function deleteLayer(id) {
if (state.layers.length <= 1) {
showToast(t('err_last_layer'), 'warning');
return;
}
const layer = state.layers.find(l => l.id === id);
if (!layer) return;
if (layer.locked) {
showToast(t('err_layer_locked'), 'warning');
return;
}
const cellsOnLayer = state.graph ? state.graph.getCells().filter(cell => {
const data = cell.getData() || {};
return data.layerId === id;
}) : [];
const finalizeDeletion = () => {
if (state.graph) {
state.graph.removeCells(cellsOnLayer);
}
state.layers = state.layers.filter(l => l.id !== id);
if (state.activeLayerId === id) {
state.activeLayerId = state.layers[0].id;
}
markDirty();
applyLayerFilters();
renderLayerPanel(container);
updateActiveLayerIndicator();
};
if (cellsOnLayer.length > 0) {
// Phase 1: Object count warning
showConfirmModal({
title: t('warning') || 'Warning',
message: t('layer_contains_objects').replace('{name}', layer.name).replace('{count}', cellsOnLayer.length),
onConfirm: () => {
// Phase 2: IRREVERSIBLE final warning
setTimeout(() => {
showConfirmModal({
title: t('confirm_delete_layer_final')?.split(':')[0] || 'Final Confirmation',
message: t('confirm_delete_layer_final').replace('{name}', layer.name),
onConfirm: finalizeDeletion
});
}, 300); // Small delay for smooth transition
}
});
} else {
// Clean layer: Single confirm
showConfirmModal({
title: t('remove'),
message: t('confirm_delete_layer').replace('{name}', layer.name),
onConfirm: finalizeDeletion
});
}
}
// Keyboard selection handling (optional: could keep for activating layers, but currently not needed as click handles it)
container.querySelectorAll('.layer-item').forEach(item => {
item.setAttribute('tabindex', '0'); // Make focusable for accessibility
});
// Re-bind draggability after the structural render
makeDraggable(container, '.panel-header');
}
+31
View File
@@ -0,0 +1,31 @@
import { state } from '../state.js';
export function initSidebar() {
const sidebar = document.getElementById('sidebar');
const sidebarToggle = document.getElementById('sidebar-toggle');
const footer = document.querySelector('.sidebar-footer');
if (sidebarToggle && sidebar) {
const toggleSidebar = () => {
sidebar.classList.toggle('collapsed');
if (footer) {
footer.style.display = sidebar.classList.contains('collapsed') ? 'none' : 'block';
}
setTimeout(() => {
if (state.graph) state.graph.zoomToFit({ padding: 50 });
}, 350);
};
sidebarToggle.addEventListener('click', toggleSidebar);
const searchIcon = document.querySelector('.search-container i');
if (searchIcon) {
searchIcon.addEventListener('click', () => {
if (sidebar.classList.contains('collapsed')) {
toggleSidebar();
document.getElementById('asset-search').focus();
}
});
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import { state } from '../state.js';
import { t } from '../i18n.js';
import { logger } from '../utils/logger.js';
/**
* handleNewProject - Clears the current graph and resets project meta
*/
export function handleNewProject() {
if (!state.graph) return;
if (confirm(t('confirm_new_project') || "Are you sure you want to start a new project?")) {
state.graph.clearCells();
// Reset Project Metadata in UI
const projectTitle = document.getElementById('project-title');
if (projectTitle) projectTitle.innerText = "Untitled_Project";
// Reset state
state.layers = [
{ id: 'l1', name: 'Main Layer', visible: true, locked: false, color: '#3b82f6' }
];
state.activeLayerId = 'l1';
state.selectionOrder = [];
// Clear Persistence (Optional: could trigger immediate save of empty state)
import('../persistence.js').then(m => {
if (m.clearLastState) m.clearLastState();
m.markDirty();
});
logger.high("New project created. Canvas cleared.");
}
}
export function initSystemMenu() {
const systemBtn = document.getElementById('system-menu-btn');
const systemFlyout = document.getElementById('system-flyout');
const studioBtn = document.getElementById('studio-btn');
const newProjectBtn = document.getElementById('new-project');
if (systemBtn && systemFlyout) {
// Toggle Flyout
systemBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isVisible = systemFlyout.style.display === 'flex';
systemFlyout.style.display = isVisible ? 'none' : 'flex';
systemBtn.classList.toggle('active', !isVisible);
});
// "New Project" Handler
if (newProjectBtn) {
newProjectBtn.addEventListener('click', () => {
handleNewProject();
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
});
}
// "Object Studio" Handler
if (studioBtn) {
studioBtn.addEventListener('click', () => {
window.open('/studio', '_blank');
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
});
}
// Close on outside click
document.addEventListener('click', (e) => {
if (!systemFlyout.contains(e.target) && e.target !== systemBtn) {
systemFlyout.style.display = 'none';
systemBtn.classList.remove('active');
}
});
}
}
// initUndoRedo is removed from UI as requested (Hotkeys still work via actionHandlers)
+105
View File
@@ -0,0 +1,105 @@
/**
* toast.js - Modularized Notification System for drawNET
*/
let toastContainer = null;
/**
* Ensures a singleton container for toasts exists in the DOM.
*/
function ensureContainer() {
if (toastContainer) return toastContainer;
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.style.cssText = `
position: fixed;
bottom: 30px;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 10px;
z-index: 10000;
pointer-events: none;
`;
document.body.appendChild(toastContainer);
return toastContainer;
}
/**
* showToast - Displays a stylish, non-blocking notification
* @param {string} message - Message to display
* @param {string} type - 'info', 'success', 'warning', 'error'
* @param {number} duration - MS to stay visible (default 3500)
*/
export function showToast(message, type = 'info', duration = 3500) {
const container = ensureContainer();
const toast = document.createElement('div');
toast.className = `toast-item ${type}`;
const colors = {
info: '#3b82f6',
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444'
};
const icons = {
info: 'fa-info-circle',
success: 'fa-check-circle',
warning: 'fa-exclamation-triangle',
error: 'fa-times-circle'
};
toast.innerHTML = `
<i class="fas ${icons[type]}"></i>
<span>${message}</span>
`;
toast.style.cssText = `
background: rgba(15, 23, 42, 0.9);
color: white;
padding: 12px 20px;
border-radius: 12px;
font-size: 13px;
font-weight: 600;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.3);
display: flex;
align-items: center;
gap: 12px;
border-left: 4px solid ${colors[type]};
backdrop-filter: blur(8px);
pointer-events: auto;
animation: toastIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275) forwards;
min-width: 280px;
`;
// Add CSS Keyframes if not present
if (!document.getElementById('toast-styles')) {
const style = document.createElement('style');
style.id = 'toast-styles';
style.innerHTML = `
@keyframes toastIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes toastOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-20px) scale(0.95); }
}
`;
document.head.appendChild(style);
}
container.appendChild(toast);
const dismiss = () => {
toast.style.animation = 'toastOut 0.3s ease-in forwards';
setTimeout(() => toast.remove(), 300);
};
setTimeout(dismiss, duration);
toast.onclick = dismiss;
}
+107
View File
@@ -0,0 +1,107 @@
/**
* ui/utils.js - Shared UI utilities
*/
/**
* makeDraggable - Helper to make an element draggable by its header or itself
*/
export function makeDraggable(el, headerSelector) {
const header = headerSelector ? el.querySelector(headerSelector) : el;
if (!header) return;
let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
header.onmousedown = dragMouseDown;
header.style.cursor = 'move';
function dragMouseDown(e) {
// Don't prevent default on inputs, buttons, or sliders inside
if (['INPUT', 'BUTTON', 'SELECT', 'TEXTAREA'].includes(e.target.tagName)) return;
pos3 = e.clientX;
pos4 = e.clientY;
document.onmouseup = closeDragElement;
document.onmousemove = elementDrag;
}
function elementDrag(e) {
e.preventDefault();
pos1 = pos3 - e.clientX;
pos2 = pos4 - e.clientY;
pos3 = e.clientX;
pos4 = e.clientY;
el.style.top = (el.offsetTop - pos2) + "px";
el.style.left = (el.offsetLeft - pos1) + "px";
el.style.bottom = 'auto'; // Break fixed constraints
el.style.right = 'auto';
}
function closeDragElement() {
document.onmouseup = null;
document.onmousemove = null;
}
}
/**
* showConfirmModal - Displays a professional confirmation modal
* @param {Object} options { title, message, onConfirm, showDontAskAgain }
*/
export function showConfirmModal({ title, message, onConfirm, onCancel, showDontAskAgain = false, checkboxKey = '' }) {
const overlay = document.createElement('div');
overlay.className = 'modal-overlay glass-panel active';
overlay.style.zIndex = '10000';
const modal = document.createElement('div');
modal.className = 'modal-content animate-up';
modal.style.width = '320px';
modal.style.padding = '0';
modal.innerHTML = `
<div class="modal-header" style="padding: 16px 20px; border-bottom: 1px solid rgba(255,255,255,0.05);">
<h2 style="font-size: 14px; margin:0; color: #f1f5f9;">${title}</h2>
</div>
<div class="modal-body" style="padding: 24px 20px;">
<p style="font-size: 13px; color: #94a3b8; margin: 0; line-height: 1.6;">${message}</p>
${showDontAskAgain ? `
<label style="display:flex; align-items:center; gap:8px; margin-top:20px; cursor:pointer;">
<input type="checkbox" id="dont-ask-again" style="width:14px; height:14px; accent-color:#3b82f6;">
<span style="font-size:11px; color:#64748b;">Don't show this again</span>
</label>
` : ''}
</div>
<div class="modal-footer" style="padding: 16px 20px; display:flex; gap:10px; background: rgba(0,0,0,0.1);">
<button id="modal-cancel" class="btn-secondary" style="flex:1; height:36px; font-size:12px;">CANCEL</button>
<button id="modal-confirm" class="btn-primary" style="flex:1; height:36px; font-size:12px; background:#ef4444; border-color:#ef4444;">DISCONNECT</button>
</div>
`;
overlay.appendChild(modal);
document.body.appendChild(overlay);
const close = () => overlay.remove();
modal.querySelector('#modal-cancel').onclick = () => {
close();
if (onCancel) onCancel();
};
modal.querySelector('#modal-confirm').onclick = () => {
const dontAsk = modal.querySelector('#dont-ask-again')?.checked;
if (dontAsk && checkboxKey) {
import('../settings/store.js').then(m => m.updateSetting(checkboxKey, false));
}
close();
if (onConfirm) onConfirm();
};
overlay.onclick = (e) => { if (e.target === overlay) close(); };
}
import { showToast as coreShowToast } from './toast.js';
/**
* showToast - Compatibility wrapper for modularized toast
*/
export function showToast(message, type = 'info', duration = 3500) {
coreShowToast(message, type, duration);
}
+105
View File
@@ -0,0 +1,105 @@
/**
* window.js - Reusable Draggable Floating Window System
*/
export class FloatingWindow {
constructor(id, title, options = {}) {
this.id = id;
this.title = title;
this.options = {
width: options.width || 400,
height: options.height || 300,
x: options.x || (window.innerWidth / 2 - (options.width || 400) / 2),
y: options.y || (window.innerHeight / 2 - (options.height || 300) / 2),
resizable: options.resizable !== false,
...options
};
this.element = null;
this.isDragging = false;
this.dragOffset = { x: 0, y: 0 };
}
render(contentHtml) {
if (this.element) return this.element;
this.element = document.createElement('div');
this.element.id = `win-${this.id}`;
this.element.className = 'floating-window';
this.element.style.width = `${this.options.width}px`;
this.element.style.height = `${this.options.height}px`;
this.element.style.left = `${this.options.x}px`;
this.element.style.top = `${this.options.y}px`;
this.element.style.zIndex = '1000';
this.element.innerHTML = `
<div class="win-header">
<div class="win-title"><i class="${this.options.icon || 'fas fa-window-maximize'}"></i> ${this.title}</div>
<div class="win-controls">
<button class="win-btn win-minimize"><i class="fas fa-minus"></i></button>
<button class="win-btn win-close"><i class="fas fa-times"></i></button>
</div>
</div>
<div class="win-body">
${contentHtml}
</div>
`;
document.body.appendChild(this.element);
this.initEvents();
return this.element;
}
initEvents() {
const header = this.element.querySelector('.win-header');
const closeBtn = this.element.querySelector('.win-close');
const minBtn = this.element.querySelector('.win-minimize');
header.addEventListener('mousedown', (e) => {
if (e.target.closest('.win-controls')) return;
this.isDragging = true;
this.dragOffset.x = e.clientX - this.element.offsetLeft;
this.dragOffset.y = e.clientY - this.element.offsetTop;
this.bringToFront();
});
window.addEventListener('mousemove', (e) => {
if (!this.isDragging) return;
this.element.style.left = `${e.clientX - this.dragOffset.x}px`;
this.element.style.top = `${e.clientY - this.dragOffset.y}px`;
});
window.addEventListener('mouseup', () => {
this.isDragging = false;
});
closeBtn.addEventListener('click', () => this.destroy());
minBtn.addEventListener('click', () => {
this.element.classList.toggle('minimized');
if (this.element.classList.contains('minimized')) {
this.element.style.height = '48px';
} else {
this.element.style.height = `${this.options.height}px`;
}
});
this.element.addEventListener('mousedown', () => this.bringToFront());
}
bringToFront() {
const allWins = document.querySelectorAll('.floating-window');
let maxZ = 1000;
allWins.forEach(win => {
const z = parseInt(win.style.zIndex);
if (z > maxZ) maxZ = z;
});
this.element.style.zIndex = maxZ + 1;
}
destroy() {
if (this.element) {
this.element.remove();
this.element = null;
}
if (this.onClose) this.onClose();
}
}

Some files were not shown because too many files have changed in this diff Show More