| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943 |
- <template>
- <div class="visual-relation-page g6-editor-page">
- <div class="page-header">
- <div class="page-title-wrap">
- <div class="page-title">对象关系分析</div>
- <div class="page-subtitle">支持通过图谱中对象和实体之间相互、共同关系线索分析</div>
- </div>
- <div class="page-meta">
- <span class="page-meta__text" v-if="activeGraphCanvas">当前 {{ activeGraphCanvas.label }}</span>
- <span class="page-meta__text" :title="nodeCountTitle">节点 {{ nodeCountDisplayText }}</span>
- <span class="page-meta__text" :title="edgeCountTitle">关系 {{ edgeCountDisplayText }}</span>
- <span class="page-meta__text">分组 {{ graphStats.combos }}</span>
- <span class="page-meta__text">缩放 {{ zoomLabel }}</span>
- <span class="page-meta__text" v-if="lastSavedAt">最近保存 {{ lastSavedAt }}</span>
- </div>
- <div class="page-actions">
- <Button type="primary" @click="handleOpenSaveLayoutModal">保存</Button>
- <Popover
- v-model:open="historyVisible"
- trigger="click"
- placement="bottomRight"
- overlayClassName="graph-history-popover"
- :arrow="true"
- @openChange="handleHistoryVisibleChange"
- >
- <template #content>
- <div class="history-panel">
- <div class="history-table">
- <div class="history-table__head">
- <div class="history-table__cell history-table__cell--name">记录名称</div>
- <div class="history-table__cell history-table__cell--time">添加时间</div>
- <div class="history-table__cell history-table__cell--action">操作</div>
- </div>
- <div v-if="historyLoading" class="history-loading">正在加载历史记录...</div>
- <div v-else-if="savedLayouts.length" class="history-table__body">
- <div
- v-for="item in savedLayouts"
- :key="item.id"
- class="history-table__row"
- :class="{ 'is-active': item.id === activeHistoryId }"
- @click="handleRestoreSnapshot(item.id)"
- >
- <div class="history-table__cell history-table__cell--name" :title="item.name">
- {{ item.name }}
- </div>
- <div class="history-table__cell history-table__cell--time" :title="item.createdAt">
- {{ item.createdAt }}
- </div>
- <div class="history-table__cell history-table__cell--action" @click.stop>
- <Button type="text" size="small" danger @click="handleRemoveSnapshot(item.id)">删除</Button>
- </div>
- </div>
- </div>
- <Empty v-else class="history-empty" description="暂无保存记录" />
- </div>
- </div>
- </template>
- <Button>历史记录</Button>
- </Popover>
- </div>
- </div>
- <div class="canvas-tabs-bar" data-export-ignore="true">
- <div class="canvas-tabs">
- <div
- v-for="(item, index) in graphCanvasTabs"
- :key="item.id"
- class="canvas-tab"
- :class="{ 'is-active': index === activeGraphCanvasIndex }"
- :title="item.label"
- @click="switchGraphCanvas(index)"
- >
- <span class="canvas-tab__label">{{ item.label }}</span>
- <span v-if="isGraphCanvasDirty(index)" class="canvas-tab__dirty"></span>
- <button type="button" class="canvas-tab__close" @click.stop="handleRemoveGraphCanvas(index)">
- <CloseOutlined />
- </button>
- </div>
- <button type="button" class="canvas-tab canvas-tab--add" @click="handleAddGraphCanvas">
- <PlusOutlined />
- </button>
- </div>
- </div>
- <div class="relation-stage">
- <div ref="graphWrapperRef" class="relation-stage__canvas" @dragover.prevent @drop="handleCanvasDrop">
- <div ref="graphContainerRef" class="relation-stage__graph"></div>
- <div
- v-if="analysisLoading || analysisLayoutLoading"
- class="relation-stage__layout-loading"
- data-export-ignore="true"
- >
- <div class="relation-stage__layout-loading-card">
- <div class="relation-stage__layout-loading-spinner"></div>
- <div class="relation-stage__layout-loading-title">
- {{ analysisLayoutLoading ? '布局中' : '正在加载分析图谱' }}
- </div>
- <div class="relation-stage__layout-loading-desc">
- {{
- analysisLayoutLoading ? '接口数据已返回,正在计算节点位置并渲染图谱。' : '正在请求对象关系分析接口。'
- }}
- </div>
- </div>
- </div>
- <div class="entity-palette" :class="{ 'is-collapsed': entityPaletteCollapsed }" data-export-ignore="true">
- <div class="entity-palette__header">
- <div class="entity-palette__title">工具箱</div>
- <Button
- type="text"
- size="small"
- class="entity-palette__toggle"
- :title="entityPaletteCollapsed ? '展开工具箱' : '收起工具箱'"
- @click="entityPaletteCollapsed = !entityPaletteCollapsed"
- >
- <template #icon>
- <PlusOutlined v-if="entityPaletteCollapsed" />
- <MinusOutlined v-else />
- </template>
- </Button>
- </div>
- <template v-if="!entityPaletteCollapsed">
- <div class="entity-grid">
- <button
- v-for="entity in entityKinds"
- :key="entity.key"
- type="button"
- class="entity-grid__item"
- :title="`拖入${entity.label}`"
- draggable="true"
- @click="handleQuickAddEntity(entity.key)"
- @dragstart="handlePaletteDragStart(entity.key, $event)"
- >
- <span class="entity-grid__icon" :style="{ '--node-accent-color': entity.color }">
- <img :src="entity.paletteIcon" :alt="entity.label" />
- </span>
- <span class="entity-grid__label">{{ entity.label }}</span>
- </button>
- </div>
- <Button
- type="primary"
- block
- class="entity-palette__action"
- :loading="analysisRunning"
- @click="handleOpenAnalysisCondition"
- >
- 设置分析条件
- </Button>
- </template>
- </div>
- <div class="control-tools-box">
- <div class="custom-atlas-tools-container">
- <ul>
- <Tooltip
- v-for="item in analysisToolbarItems"
- :key="item.key"
- :title="item.tipsContent ? `${item.label}: ${item.tipsContent}` : item.label"
- >
- <li
- class="tool-item"
- :class="{
- 'gap-item': item.gap,
- 'is-active': isToolbarItemActive(item),
- 'disabled-tool-item': getToolbarItemDisabled(item.key),
- }"
- @click="handleAnalysisToolbarClick(item)"
- >
- <span class="tool-item-icon">
- <component :is="item.icon" class="icon-style" />
- </span>
- </li>
- </Tooltip>
- </ul>
- </div>
- <div v-if="activeToolbarPanel" class="tool-panel">
- <div class="tool-panel__header">
- <div class="tool-panel__title">{{ activeToolbarPanelLabel }}</div>
- <Button type="text" size="small" class="tool-panel__close" @click="closeActiveToolbarPanel">收起</Button>
- </div>
- <template v-if="activeToolbarPanel === 'find'">
- <div class="tool-panel__row">
- <Input
- v-if="findForm.scope === 'entity'"
- v-model:value="findForm.keyword"
- placeholder="请输入查找内容"
- allow-clear
- @pressEnter="handleFindConfirm"
- />
- <div v-else class="tool-panel__range-row">
- <InputNumber
- v-model:value="findForm.rangeMin"
- :min="0"
- :precision="0"
- class="tool-panel__number"
- placeholder="最小值"
- @pressEnter="handleFindConfirm"
- />
- <span class="tool-panel__range-separator">至</span>
- <InputNumber
- v-model:value="findForm.rangeMax"
- :min="0"
- :precision="0"
- class="tool-panel__number"
- placeholder="最大值"
- @pressEnter="handleFindConfirm"
- />
- </div>
- </div>
- <div v-if="findForm.scope === 'entity'" class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': findForm.manner === 'obscure' }"
- @click="findForm.manner = 'obscure'"
- >
- 模糊查找
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': findForm.manner === 'precise' }"
- @click="findForm.manner = 'precise'"
- >
- 精确查找
- </button>
- </div>
- <div class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': findForm.scope === 'entity' }"
- @click="findForm.scope = 'entity'"
- >
- 查找实体
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': findForm.scope === 'connect' }"
- @click="findForm.scope = 'connect'"
- >
- 查找关系
- </button>
- </div>
- <div class="tool-panel__footer tool-panel__footer--find">
- <div class="tool-panel__result" v-if="findResultCount">
- 共 {{ findResultCount }} 个结果,当前第 {{ findResultIndex + 1 }} 个
- </div>
- <div class="tool-panel__actions tool-panel__actions--find">
- <Button v-if="findResultCount > 1" @click="handleFindSelectAll">全选结果</Button>
- <Button v-if="findResultCount > 1" @click="handleFindPrev">上一个</Button>
- <Button type="primary" @click="handleFindConfirm">查找</Button>
- <Button v-if="findResultCount > 1" @click="handleFindNext">下一个</Button>
- </div>
- </div>
- </template>
- <template v-else-if="activeToolbarPanel === 'filter'">
- <div class="tool-panel__row">
- <Select
- v-model:value="filterForm.findEntityList"
- class="tool-panel__entity-select"
- mode="multiple"
- allow-clear
- placeholder="请选择过滤实体"
- :options="entityKinds.map((item) => ({ label: item.label, value: item.key }))"
- />
- </div>
- <div class="tool-panel__switch-row">
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': filterForm.filterCondition === 'neighborTotal' }"
- @click="filterForm.filterCondition = 'neighborTotal'"
- >
- 实体相邻个数
- </button>
- <button
- type="button"
- class="tool-panel__switch"
- :class="{ 'is-active': filterForm.filterCondition === 'neighborEdgeNumericValue' }"
- @click="filterForm.filterCondition = 'neighborEdgeNumericValue'"
- >
- 关系数值
- </button>
- </div>
- <div class="tool-panel__switch-row">
- <button
- v-for="operator in filterOperators"
- :key="operator"
- type="button"
- class="tool-panel__switch tool-panel__switch--compact"
- :class="{ 'is-active': filterForm.targetCondition === operator }"
- @click="filterForm.targetCondition = operator"
- >
- {{ operator }}
- </button>
- <InputNumber v-model:value="filterForm.setResult" :min="0" :precision="0" class="tool-panel__number" />
- </div>
- <div class="tool-panel__footer">
- <div class="tool-panel__actions">
- <Button type="primary" @click="executeFilterCells">开始过滤</Button>
- <Button @click="handleFilterCancel">取消过滤</Button>
- <Button v-if="filterMatchedNodeIds.length" @click="handleFilterSelectAll">全选结果</Button>
- <Button v-if="filterMatchedNodeIds.length" @click="handleFilterToggleDisplay">
- {{ filterHidden ? '显示过滤' : '隐藏过滤' }}
- </Button>
- </div>
- </div>
- </template>
- <template v-else-if="activeToolbarPanel === 'mark'">
- <div class="tool-panel__swatches">
- <button
- v-for="(color, index) in markerColors"
- :key="color"
- type="button"
- class="tool-panel__swatch"
- :style="{ backgroundColor: color }"
- @click="handleSetMarker(index)"
- ></button>
- </div>
- <div class="tool-panel__footer">
- <div class="tool-panel__tip">对当前选中实体切换标记色</div>
- </div>
- </template>
- </div>
- <div class="canvas-toolbar">
- <Tooltip title="缩小">
- <Button type="text" class="canvas-toolbar__button" @click="handleZoomOut">
- <MinusOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="放大">
- <Button type="text" class="canvas-toolbar__button" @click="handleZoomIn">
- <PlusOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="适配画布">
- <Button type="text" class="canvas-toolbar__button" @click="handleFitView">
- <FullscreenOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="居中内容">
- <Button type="text" class="canvas-toolbar__button" @click="handleCenterView">
- <AimOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="设置分析条件">
- <Button type="text" class="canvas-toolbar__button" @click="handleOpenAnalysisCondition">
- <ReloadOutlined />
- </Button>
- </Tooltip>
- <Tooltip title="清空画布">
- <Button type="text" class="canvas-toolbar__button" @click="handleClearCanvas">
- <DeleteOutlined />
- </Button>
- </Tooltip>
- </div>
- </div>
- <div v-if="pendingEdgeSourceLabel" class="edge-create-tip">
- <span>连线起点:{{ pendingEdgeSourceLabel }}</span>
- <Button type="link" size="small" @click="cancelEdgeCreate">取消</Button>
- </div>
- <div v-if="!hasGraphData" class="canvas-empty">
- <div class="canvas-empty__icon">
- <span class="node node-main"></span>
- <span class="node node-branch node-branch--left"></span>
- <span class="node node-branch node-branch--right"></span>
- <span class="node node-branch node-branch--top"></span>
- </div>
- <div class="canvas-empty__title">请设置分析条件或拖动左上方实体创建关系</div>
- <div class="canvas-empty__desc">当前默认展示对象关系模式,支持拖入实体、移动节点和右键连线。</div>
- </div>
- <div class="canvas-status" data-export-ignore="true">
- <span class="canvas-status__item">提示:拖动画布可平移,滚轮可缩放</span>
- <span class="canvas-status__item">框选:按住 Shift 拖动画布</span>
- <span class="canvas-status__item" v-if="activeElement">选中:{{ activeElement.label }}</span>
- </div>
- </div>
- </div>
- <AnalysisConditionModal
- v-model:open="analysisConditionVisible"
- :loading="analysisRunning"
- :respondent-options="allRespondentOptions.length ? allRespondentOptions : undefined"
- :default-selected-ids="routeGraphRespondents.length ? routeGraphDefaultSelectedIds : undefined"
- @start="handleStartAnalysis"
- />
- <Modal
- v-model:open="saveLayoutVisible"
- title="关系分析"
- :confirmLoading="saveLayoutLoading"
- :width="560"
- :centered="true"
- wrapClassName="graph-save-layout-modal"
- destroyOnClose
- @ok="handleSaveLayout"
- @cancel="handleCloseSaveLayoutModal"
- >
- <Form layout="vertical" class="graph-save-layout-form">
- <Form.Item
- label="可视化关系名称"
- :validate-status="saveLayoutError ? 'error' : ''"
- :help="saveLayoutError"
- required
- >
- <Input v-model:value="saveLayoutForm.name" :maxlength="20" placeholder="请输入可视化关系名称" allow-clear />
- </Form.Item>
- </Form>
- </Modal>
- <QueryTableModal
- :open="analysisDetailTableModalOpen"
- :title="analysisDetailTitle"
- :width="960"
- :loading="analysisDetailLoading"
- :filters="analysisDetailFilters"
- :filter-schema="analysisDetailFilterSchema"
- :columns="analysisDetailTableColumns"
- :data-source="analysisDetailTableRows"
- :pagination="analysisDetailPagination"
- :total-pages="analysisDetailTotalPages"
- :page-size-options="analysisDetailPageSizeOptions"
- :show-index-column="true"
- :show-table-setting="false"
- :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
- :can-fullscreen="true"
- wrap-class-name="graph-analysis-detail-table-modal"
- @update:open="handleAnalysisDetailVisibleChange"
- @search="handleAnalysisDetailSearch"
- @reset="handleAnalysisDetailReset"
- @prev-page="handleAnalysisDetailPrevPage"
- @next-page="handleAnalysisDetailNextPage"
- @page-size-change="handleAnalysisDetailPageSizeChange"
- @page-change="handleAnalysisDetailPageChange"
- @sort-change="handleAnalysisDetailSortChange"
- />
- <BasicModal
- :open="analysisDetailSummaryModalOpen"
- :title="analysisDetailTitle"
- :width="960"
- :showOkBtn="false"
- :showCancelBtn="false"
- :canFullscreen="true"
- :centered="true"
- wrapClassName="graph-analysis-detail-modal"
- @update:open="handleAnalysisDetailVisibleChange"
- @cancel="handleAnalysisDetailVisibleChange(false)"
- >
- <div class="analysis-detail">
- <div v-if="analysisDetailEntries.length" class="analysis-detail__summary">
- <div v-for="item in analysisDetailEntries" :key="item.label" class="analysis-detail__entry">
- <div class="analysis-detail__entry-label">{{ item.label }}</div>
- <div class="analysis-detail__entry-value">{{ item.value }}</div>
- </div>
- </div>
- <Empty v-else description="暂无详情信息" />
- </div>
- </BasicModal>
- <TransRecordModal
- v-model:open="transDetailVisible"
- :title="transDetailTitle"
- :base-query="transDetailBaseQuery"
- :fetch-api="fetchGraphTransDetailRecords"
- :filter-fields="['startDate', 'endDate', 'transAmount']"
- :trans-amount-operator-options="graphTransAmountOperatorOptions"
- />
- <CallRecordModal
- v-model:open="callDetailVisible"
- :title="callDetailTitle"
- :base-query="callDetailBaseQuery"
- :fetch-api="fetchGraphCallDetailRecords"
- :filter-fields="['startDate', 'endDate']"
- />
- <Modal
- v-model:open="associativeObjectVisible"
- title="关联对象"
- :width="520"
- :centered="true"
- okText="创建"
- cancelText="取消"
- @ok="handleConfirmAssociativeObject"
- >
- <div class="analysis-associate-form">
- <div class="analysis-associate-form__item">
- <div class="analysis-associate-form__label">对象类型</div>
- <Select
- v-model:value="associativeObjectForm.entityKind"
- :options="entityKinds.map((item) => ({ label: item.label, value: item.key }))"
- />
- </div>
- <div class="analysis-associate-form__item">
- <div class="analysis-associate-form__label">对象名称</div>
- <Input v-model:value="associativeObjectForm.name" placeholder="请输入关联对象名称" :maxlength="32" />
- </div>
- <div class="analysis-associate-form__item">
- <div class="analysis-associate-form__label">对象标识</div>
- <Input
- v-model:value="associativeObjectForm.identityCode"
- placeholder="可填写身份证号、机构编码或其他唯一标识"
- :maxlength="64"
- />
- </div>
- <div class="analysis-associate-form__hint">
- 当前为本地关联对象创建,会在当前节点周围新增一个实体并建立一条“关联对象”关系。
- </div>
- </div>
- </Modal>
- </div>
- </template>
- <script lang="ts" setup name="ViewsGraphG6Editor">
- import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, type Component } from 'vue';
- import { useRoute } from 'vue-router';
- import { Button, Empty, Form, Input, InputNumber, Modal, Popover, Select, Tooltip, message } from 'ant-design-vue';
- import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
- import {
- AimOutlined,
- ApartmentOutlined,
- BorderOutlined,
- CloseOutlined,
- CopyOutlined,
- DeleteOutlined,
- FileAddOutlined,
- FilterOutlined,
- FullscreenOutlined,
- GroupOutlined,
- MinusOutlined,
- PlusOutlined,
- ReloadOutlined,
- SearchOutlined,
- ShareAltOutlined,
- SnippetsOutlined,
- StarOutlined,
- UngroupOutlined,
- UndoOutlined,
- RedoOutlined,
- } from '@ant-design/icons-vue';
- import {
- BaseEdge,
- BaseNode,
- CanvasEvent,
- ComboEvent,
- EdgeEvent,
- Graph,
- GraphEvent,
- NodeEvent,
- register,
- type ComboData,
- type EdgeOptions,
- type EdgeData,
- type GraphData,
- type IElementEvent,
- type LayoutOptions,
- type NodeOptions,
- type NodeData,
- } from '@antv/g6';
- import { Image as GImage, Path as GPath, Rect as GRect, Text as GText, type Group } from '@antv/g';
- import type { PathArray } from '@antv/util';
- import type {
- AnalysisConditionPayload,
- AnalysisRespondent,
- GraphCallDetailQuery,
- GraphAnalysisEdge,
- GraphAnalysisNode,
- GraphAnalysisResponse,
- GraphHistoryItem,
- GraphNodeDetailQuery,
- GraphTransDetailQuery,
- } from '../../api/analysis';
- import {
- buildGraphCallDetailQuery,
- buildGraphTransDetailQuery,
- buildGraphViewQuery,
- createGraphHistory,
- deleteGraphHistory,
- fetchGraphAnalysis,
- fetchGraphCallDetail,
- fetchGraphHistory,
- fetchGraphNodeDetail,
- fetchGraphTransDetail,
- resolveGraphRespondentsFromRouteQuery,
- } from '../../api/analysis';
- import { getPersons } from '@jeesite/person/api/personApi';
- import { BasicModal } from '@jeesite/core/components/Modal';
- import componentSetting from '@jeesite/core/settings/componentSetting';
- import QueryTableModal from '@jeesite/case/views/dm/components/QueryTableModal.vue';
- import type { CallRecordQuery } from '@jeesite/call/api/types/query';
- import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
- import type { CompareOperator } from '@jeesite/core/utils/compareOperator';
- import type { TransRecordQuery } from '@jeesite/trans/api/types/query';
- import TransRecordModal from '@jeesite/trans/views/components/TransRecordModal.vue';
- import AnalysisConditionModal from '../graph/components/AnalysisConditionModal.vue';
- import { runLegacyForceLayout } from '../graph/analysisLegacyForce';
- import { computeAnalysisLayout } from '../graph/analysisLayoutEngine';
- import type {
- AnalysisLayoutWorkerPayload,
- AnalysisLayoutWorkerPosition,
- AnalysisLayoutWorkerResponseMessage,
- } from '../graph/analysisLayoutShared';
- import personIcon from '../../assets/relation/personl.png';
- import groupIcon from '../../assets/relation/devl.png';
- import houseIcon from '../../assets/relation/homel.png';
- import carIcon from '../../assets/relation/carl.png';
- import bankIcon from '../../assets/relation/cardl.png';
- import phoneIcon from '../../assets/relation/phonel.png';
- import unknownIcon from '../../assets/relation/wz.png';
- import otherIcon from '../../assets/relation/otherl.png';
- type EntityKindKey = 'person' | 'group' | 'house' | 'car' | 'bank' | 'phone' | 'unknown' | 'other';
- type ElementKind = 'node' | 'edge' | 'combo';
- interface EntityKind {
- key: EntityKindKey;
- label: string;
- icon: string;
- paletteIcon: string;
- color: string;
- description: string;
- }
- interface ActiveElement {
- id: string;
- label: string;
- type: ElementKind;
- typeLabel: string;
- }
- interface LayoutSnapshot {
- id: string;
- remoteId?: string;
- name: string;
- createdAt: string;
- nodeCount: number;
- edgeCount: number;
- payload: string;
- filePath?: string;
- }
- interface GraphCanvasTab {
- id: string;
- label: string;
- snapshot: string;
- zoom: number;
- lastSavedAt: string;
- analysisTotalNodeCount: number;
- analysisTotalEdgeCount: number;
- dirty: boolean;
- }
- type ToolbarPanelKey = 'find' | 'filter' | 'mark';
- type AnalysisToolbarKey =
- | 'undo'
- | 'redo'
- | 'find'
- | 'filter'
- | 'mark'
- | 'selectNeighbor'
- | 'boxSelect'
- | 'copy'
- | 'paste'
- | 'delete'
- | 'group'
- | 'ungroup'
- | 'newCanvas'
- | 'forceLayout'
- | 'fullScreen';
- type ContextMenuActionKey =
- | 'copy'
- | 'paste'
- | 'delete'
- | 'group'
- | 'ungroup'
- | 'newCanvas'
- | 'analysisDetail'
- | 'startEdge'
- | 'onlyLookFlowIn'
- | 'onlyLookFlowOut'
- | 'associativeObject'
- | 'createPerson'
- | 'fitView';
- interface AnalysisToolbarItem {
- key: AnalysisToolbarKey;
- label: string;
- icon: Component;
- gap?: boolean;
- tipsContent?: string;
- panelKey?: ToolbarPanelKey;
- }
- interface FindForm {
- keyword: string;
- manner: 'obscure' | 'precise';
- scope: 'entity' | 'connect';
- rangeMin: number | undefined;
- rangeMax: number | undefined;
- }
- interface FilterForm {
- findEntityList: EntityKindKey[];
- filterCondition: 'neighborTotal' | 'neighborEdgeNumericValue';
- targetCondition: '>' | '>=' | '=' | '<' | '<=';
- setResult: number | undefined;
- }
- interface GraphClipboardPayload {
- nodes: NodeData[];
- edges: EdgeData[];
- }
- interface AnalysisDefaultCollapseResult {
- nodes: NodeData[];
- edges: EdgeData[];
- hiddenNodeIds: Set<string>;
- }
- interface AnalysisLayoutDraft {
- positions: Map<string, { x: number; y: number }>;
- fixedIds: Set<string>;
- }
- interface AnalysisLayoutResolveResult {
- draft: AnalysisLayoutDraft | null;
- positions: Map<string, { x: number; y: number }>;
- }
- interface AnalysisRelaxItem {
- id: string;
- x: number;
- y: number;
- }
- interface AnalysisRelaxBounds {
- minX: number;
- maxX: number;
- minY: number;
- maxY: number;
- }
- interface AnalysisRelaxOverlap {
- left: AnalysisRelaxItem;
- right: AnalysisRelaxItem;
- overlapX: number;
- overlapY: number;
- deltaX: number;
- deltaY: number;
- }
- interface AnalysisDetailEntry {
- label: string;
- value: string;
- }
- interface AnalysisDetailTableColumn {
- title: string;
- dataIndex: string;
- key: string;
- width: number;
- }
- interface AnalysisDetailFilterItem {
- field: string;
- label: string;
- type: 'input' | 'date' | 'date-range' | 'select' | 'compare-input';
- placeholder?: string;
- width?: number;
- maxWidth?: number;
- options?: ReadonlyArray<{ label: string; value: string | number }>;
- operatorField?: string;
- operatorWidth?: number;
- inputWidth?: number;
- inputMaxWidth?: number;
- startField?: string;
- endField?: string;
- rangePlaceholder?: [string, string];
- valueFormat?: string;
- }
- interface ContextMenuItem {
- name: string;
- value: ContextMenuActionKey;
- }
- interface ContextMenuState {
- id: string;
- type: IElementEvent['targetType'];
- canvasX: number;
- canvasY: number;
- }
- interface GraphDataBounds {
- minX: number;
- minY: number;
- maxX: number;
- maxY: number;
- }
- interface AnalysisLeafRingCenterMeta {
- id: string;
- node: NodeData;
- position: { x: number; y: number };
- leafCount: number;
- degree: number;
- }
- interface StressNodeStyle {
- x?: number;
- y?: number;
- size?: number;
- fill?: string;
- fillOpacity?: number;
- labelText?: string;
- labelFill?: string;
- labelFontSize?: number;
- labelOpacity?: number;
- secondaryLabelText?: string;
- secondaryLabelFill?: string;
- secondaryLabelFontSize?: number;
- secondaryLabelOpacity?: number;
- }
- interface RelationLayoutOptions {
- nodeSize?: number;
- seedIds?: string[];
- preset?: 'default' | 'mingjing-g6';
- }
- type GraphTransDetailBaseQuery = Partial<TransRecordQuery & GraphTransDetailQuery> & Record<string, any>;
- type GraphCallDetailBaseQuery = Partial<CallRecordQuery & GraphCallDetailQuery> & Record<string, any>;
- const graphTransAmountOperatorOptions: Array<{ label: string; value: CompareOperator }> = [
- { label: '>=', value: 'GE' },
- { label: '<=', value: 'LE' },
- ];
- class AnalysisNode extends BaseNode {
- protected drawKeyShape(attributes: Record<string, any>, container: Group) {
- const iconSize = Number(attributes.iconBoxSize || 40);
- const radius = Number(attributes.iconBoxRadius || 11);
- const hidden = Number(attributes.opacity ?? 1) <= 0;
- this.upsert(
- 'key',
- GRect,
- {
- x: -iconSize / 2,
- y: -iconSize / 2,
- width: iconSize,
- height: iconSize,
- className: 'key',
- radius,
- fill: attributes.fill,
- opacity: hidden ? 0 : Number(attributes.fillOpacity ?? 1),
- stroke: attributes.stroke || 'rgba(255, 255, 255, 0.22)',
- strokeOpacity: hidden ? 0 : Number(attributes.strokeOpacity ?? 0.22),
- lineWidth: Number(attributes.lineWidth || 1),
- shadowColor: 'rgba(15, 23, 42, 0.12)',
- shadowBlur: 10,
- shadowOffsetY: 4,
- cursor: attributes.cursor || 'pointer',
- } as any,
- container,
- );
- if (attributes.iconSrc && !hidden) {
- const imageSize = Number(attributes.iconSize || 32);
- this.upsert(
- 'icon',
- GImage,
- {
- x: -imageSize / 2,
- y: -imageSize / 2,
- width: imageSize,
- height: imageSize,
- className: 'icon',
- src: attributes.iconSrc,
- opacity: Number(attributes.iconOpacity ?? 1),
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('icon', GImage, false, container);
- }
- if (attributes.collapsedBadgeText && !hidden) {
- const badgeText = String(attributes.collapsedBadgeText);
- const badgeWidth = Math.max(18, badgeText.length * 7 + 10);
- const badgeHeight = 18;
- const badgeX = iconSize / 2 - badgeWidth + 8;
- const badgeY = -iconSize / 2 - 8;
- this.upsert(
- 'collapsed-badge',
- GRect,
- {
- x: badgeX,
- y: badgeY,
- width: badgeWidth,
- height: badgeHeight,
- className: 'collapsed-badge',
- radius: 9,
- fill: attributes.collapsedBadgeFill || '#2563eb',
- stroke: '#ffffff',
- lineWidth: 1.5,
- shadowColor: 'rgba(37, 99, 235, 0.28)',
- shadowBlur: 8,
- shadowOffsetY: 3,
- pointerEvents: 'none',
- } as any,
- container,
- );
- this.upsert(
- 'collapsed-badge-label',
- GText,
- {
- x: badgeX + badgeWidth / 2,
- y: badgeY + badgeHeight / 2,
- className: 'collapsed-badge-label',
- text: badgeText,
- fill: '#ffffff',
- fontSize: 10,
- fontWeight: 700,
- textAlign: 'center',
- textBaseline: 'middle',
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('collapsed-badge', GRect, false, container);
- this.upsert('collapsed-badge-label', GText, false, container);
- }
- return this.getShape('key');
- }
- public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this) {
- const iconSize = Number(attributes.iconBoxSize || 40);
- const hidden = Number(attributes.opacity ?? 1) <= 0;
- this.drawKeyShape(attributes, container);
- if (!attributes.labelText || hidden) {
- this.upsert('label', GText, false, container);
- this.upsert('secondary-label', GText, false, container);
- return;
- }
- const hasSecondaryLabel = Boolean(attributes.secondaryLabelText);
- const labelBaseY = iconSize / 2 + (hasSecondaryLabel ? 17 : 20);
- this.upsert(
- 'label',
- GText,
- {
- x: 0,
- y: labelBaseY,
- className: 'label',
- text: String(attributes.labelText),
- fill: attributes.labelFill || '#334155',
- fontSize: Number(attributes.labelFontSize || 14),
- fontWeight: Number(attributes.labelFontWeight || 600),
- textAlign: 'center',
- textBaseline: 'middle',
- opacity: Number(attributes.labelOpacity ?? 1),
- maxLines: 1,
- wordWrap: false,
- pointerEvents: 'none',
- } as any,
- container,
- );
- if (hasSecondaryLabel) {
- this.upsert(
- 'secondary-label',
- GText,
- {
- x: 0,
- y: labelBaseY + 18,
- className: 'secondary-label',
- text: String(attributes.secondaryLabelText),
- fill: attributes.secondaryLabelFill || '#94a3b8',
- fontSize: Number(attributes.secondaryLabelFontSize || 12),
- fontWeight: Number(attributes.secondaryLabelFontWeight || 500),
- textAlign: 'center',
- textBaseline: 'middle',
- opacity: Number(attributes.secondaryLabelOpacity ?? attributes.labelOpacity ?? 1),
- maxLines: 1,
- wordWrap: false,
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('secondary-label', GText, false, container);
- }
- }
- }
- class StressNode extends BaseNode {
- protected drawKeyShape(attributes: Record<string, any>, container: Group) {
- const size = Number(attributes.size || 8);
- const keyShape = this.upsert(
- 'key',
- GRect,
- {
- x: -size / 2,
- y: -size / 2,
- width: size,
- height: size,
- className: 'key',
- radius: Math.max(4, size * 0.26),
- fill: attributes.fill,
- fillOpacity: attributes.fillOpacity,
- stroke: attributes.stroke || 'transparent',
- strokeOpacity: Number(attributes.strokeOpacity ?? 1),
- lineWidth: Number(attributes.lineWidth ?? 0),
- cursor: attributes.cursor || 'pointer',
- } as any,
- container,
- );
- if (attributes.iconSrc && size >= 12) {
- this.upsert(
- 'icon',
- GImage,
- {
- x: -size * 0.38,
- y: -size * 0.38,
- width: size * 0.76,
- height: size * 0.76,
- className: 'icon',
- src: attributes.iconSrc,
- opacity: attributes.fillOpacity,
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('icon', GImage, false, container);
- }
- if (attributes.collapsedBadgeText) {
- const badgeText = String(attributes.collapsedBadgeText);
- const badgeWidth = Math.max(16, badgeText.length * 6 + 8);
- const badgeHeight = 16;
- const badgeX = size / 2 - badgeWidth / 2;
- const badgeY = -size / 2 - 12;
- this.upsert(
- 'collapsed-badge',
- GRect,
- {
- x: badgeX,
- y: badgeY,
- width: badgeWidth,
- height: badgeHeight,
- className: 'collapsed-badge',
- radius: 8,
- fill: attributes.collapsedBadgeFill || '#2563eb',
- stroke: '#ffffff',
- lineWidth: 1,
- pointerEvents: 'none',
- } as any,
- container,
- );
- this.upsert(
- 'collapsed-badge-label',
- GText,
- {
- x: badgeX + badgeWidth / 2,
- y: badgeY + badgeHeight / 2,
- className: 'collapsed-badge-label',
- text: badgeText,
- fill: '#ffffff',
- fontSize: 9,
- fontWeight: 700,
- textAlign: 'center',
- textBaseline: 'middle',
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('collapsed-badge', GRect, false, container);
- this.upsert('collapsed-badge-label', GText, false, container);
- }
- return keyShape;
- }
- public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this) {
- const size = Number(attributes.size || 8);
- this.drawKeyShape(attributes, container);
- if (!attributes.labelText) {
- this.upsert('label', GText, false, container);
- this.upsert('secondary-label', GText, false, container);
- return;
- }
- const hasSecondaryLabel = Boolean(attributes.secondaryLabelText);
- const labelBaseY = size / 2 + (hasSecondaryLabel ? 10 : 12);
- this.upsert(
- 'label',
- GText,
- {
- x: 0,
- y: labelBaseY,
- className: 'label',
- text: String(attributes.labelText),
- fill: attributes.labelFill || '#334155',
- fontSize: Number(attributes.labelFontSize || 10),
- textAlign: 'center',
- textBaseline: 'middle',
- opacity: Number(attributes.labelOpacity ?? 0.78),
- pointerEvents: 'none',
- } as any,
- container,
- );
- if (hasSecondaryLabel) {
- this.upsert(
- 'secondary-label',
- GText,
- {
- x: 0,
- y: labelBaseY + 14,
- className: 'secondary-label',
- text: String(attributes.secondaryLabelText),
- fill: attributes.secondaryLabelFill || '#94a3b8',
- fontSize: Number(attributes.secondaryLabelFontSize || 9),
- textAlign: 'center',
- textBaseline: 'middle',
- opacity: Number(attributes.secondaryLabelOpacity ?? attributes.labelOpacity ?? 0.72),
- pointerEvents: 'none',
- } as any,
- container,
- );
- } else {
- this.upsert('secondary-label', GText, false, container);
- }
- }
- }
- class StressEdge extends BaseEdge {
- protected getKeyPath(attributes: Record<string, any>): PathArray {
- const [sourcePoint, targetPoint] = this.getEndpoints(attributes as any, false);
- return [
- ['M', sourcePoint[0], sourcePoint[1]],
- ['L', targetPoint[0], targetPoint[1]],
- ] as unknown as PathArray;
- }
- public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this): void {
- this.upsert(
- 'key',
- GPath,
- {
- d: this.getKeyPath(attributes),
- className: 'key',
- stroke: attributes.stroke,
- lineWidth: attributes.lineWidth,
- opacity: attributes.opacity,
- } as any,
- container,
- );
- if (!attributes.labelText || Number(attributes.opacity ?? 1) <= 0) {
- this.upsert('label', GText, false, container);
- return;
- }
- const [sourcePoint, targetPoint] = this.getEndpoints(attributes as any, false);
- this.upsert(
- 'label',
- GText,
- {
- x: (sourcePoint[0] + targetPoint[0]) / 2,
- y: (sourcePoint[1] + targetPoint[1]) / 2 - 5,
- className: 'label',
- text: String(attributes.labelText),
- fill: attributes.labelFill || '#64748b',
- fontSize: Number(attributes.labelFontSize || 9),
- textAlign: 'center',
- textBaseline: 'middle',
- opacity: Number(attributes.labelOpacity ?? 0.68),
- pointerEvents: 'none',
- } as any,
- container,
- );
- }
- }
- const registerStressElements = (() => {
- let registered = false;
- return () => {
- if (registered) return;
- register('node', 'analysis-node', AnalysisNode as any);
- register('node', 'performance-node', StressNode as any);
- register('edge', 'performance-edge', StressEdge as any);
- registered = true;
- };
- })();
- const graphContainerRef = ref<HTMLDivElement | null>(null);
- const graphWrapperRef = ref<HTMLDivElement | null>(null);
- const graphRef = shallowRef<Graph | null>(null);
- const route = useRoute();
- const pendingEdgeSourceId = ref('');
- const analysisConditionVisible = ref(false);
- const analysisLoading = ref(false);
- const analysisLayoutLoading = ref(false);
- const loadedRespondentOptions = ref<AnalysisRespondent[]>([]);
- const selectedNodeIds = ref<string[]>([]);
- const selectedEdgeIds = ref<string[]>([]);
- const selectedComboIds = ref<string[]>([]);
- const activeElement = ref<ActiveElement | null>(null);
- const serializedGraphData = ref('');
- const zoomLabel = ref('100%');
- const graphPerformanceMode = ref(false);
- const graphInteractionMoving = ref(false);
- const entityPaletteCollapsed = ref(false);
- const graphCanvasTabs = ref<GraphCanvasTab[]>([]);
- const activeGraphCanvasIndex = ref(0);
- const historyVisible = ref(false);
- const historyLoading = ref(false);
- const savedLayouts = ref<LayoutSnapshot[]>([]);
- const activeHistoryId = ref('');
- const saveLayoutVisible = ref(false);
- const saveLayoutLoading = ref(false);
- const saveLayoutError = ref('');
- const saveLayoutForm = reactive({ name: '' });
- const undoStack = ref<string[]>([]);
- const redoStack = ref<string[]>([]);
- const graphClipboard = ref<GraphClipboardPayload | null>(null);
- const activeToolbarPanel = ref<ToolbarPanelKey | null>(null);
- const toolbarRunningKey = ref<AnalysisToolbarKey | ''>('');
- const isFullscreen = ref(false);
- const findForm = reactive<FindForm>({
- keyword: '',
- manner: 'obscure',
- scope: 'entity',
- rangeMin: undefined,
- rangeMax: undefined,
- });
- const findResultIds = ref<string[]>([]);
- const findResultIndex = ref(-1);
- const filterForm = reactive<FilterForm>({
- findEntityList: [],
- filterCondition: 'neighborTotal',
- targetCondition: '>=',
- setResult: 1,
- });
- const filterMatchedNodeIds = ref<string[]>([]);
- const filterMutedNodeIds = ref<string[]>([]);
- const filterMatchedEdgeIds = ref<string[]>([]);
- const filterHidden = ref(false);
- const analysisDetailVisible = ref(false);
- const analysisDetailTitle = ref('图谱详情');
- const analysisDetailEntries = ref<AnalysisDetailEntry[]>([]);
- const analysisDetailLoading = ref(false);
- const analysisDetailQueryBase = ref<GraphNodeDetailQuery | null>(null);
- const analysisDetailTableColumns = ref<AnalysisDetailTableColumn[]>([]);
- const analysisDetailTableRows = ref<Record<string, any>[]>([]);
- const analysisDetailFilters = reactive({
- startDate: '',
- endDate: '',
- transAmount: '',
- transAmountSymbol: 'GE' as CompareOperator,
- orderKey: '',
- sort: '' as '' | 'ASC' | 'DESC',
- });
- const analysisDetailPagination = ref({
- page: 1,
- limit: componentSetting.table.defaultPageSize,
- total: 0,
- });
- const analysisDetailPageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
- label: value,
- value,
- }));
- const transDetailVisible = ref(false);
- const transDetailTitle = ref('交易详情');
- const transDetailBaseQuery = ref<GraphTransDetailBaseQuery>({});
- const callDetailVisible = ref(false);
- const callDetailTitle = ref('通信详情');
- const callDetailBaseQuery = ref<GraphCallDetailBaseQuery>({});
- const contextMenuState = ref<ContextMenuState | null>(null);
- const associativeObjectVisible = ref(false);
- const associativeObjectAnchorId = ref('');
- const associativeObjectForm = reactive<{
- entityKind: EntityKindKey;
- name: string;
- identityCode: string;
- }>({
- entityKind: 'person',
- name: '',
- identityCode: '',
- });
- const graphStats = reactive({
- nodes: 0,
- edges: 0,
- combos: 0,
- });
- const analysisTotalNodeCount = ref(0);
- const analysisTotalEdgeCount = ref(0);
- const lastAnalysisPayload = ref<AnalysisConditionPayload | null>(null);
- const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
- refresh: async () => {
- if (!lastAnalysisPayload.value) return;
- await executeStartAnalysis(lastAnalysisPayload.value);
- },
- hasData: () =>
- !!lastAnalysisPayload.value && (graphStats.nodes > 0 || graphStats.edges > 0 || graphStats.combos > 0),
- });
- const LARGE_GRAPH_NODE_THRESHOLD = 800;
- const LARGE_GRAPH_EDGE_THRESHOLD = 1000;
- const ANALYSIS_LAYOUT_NODE_SIZE = 180;
- const ANALYSIS_LAYOUT_COLLISION_SIZE = 96;
- const ANALYSIS_LAYOUT_MINGJING_CENTER: [number, number] = [369, 180];
- const ANALYSIS_LAYOUT_FORCE_GRAVITY = 18;
- const ANALYSIS_LAYOUT_FORCE_FACTOR = 1.2;
- const ANALYSIS_LAYOUT_FORCE_COLLIDE_STRENGTH = 0.72;
- const ANALYSIS_LAYOUT_FORCE_LEAF_STRENGTH = 2200;
- const ANALYSIS_LAYOUT_FORCE_DEGREE_STRENGTH = 480;
- const ANALYSIS_LAYOUT_FORCE_EXPANDED_STRENGTH = 680;
- const ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_SHORT = 22;
- const ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_LONG = 180;
- const ANALYSIS_LAYOUT_FORCE_CENTER_LINK_DISTANCE = 250;
- const ANALYSIS_LAYOUT_RING_MIN_RADIUS = 230;
- const ANALYSIS_LAYOUT_RING_SECTOR_SWEEP = (Math.PI * 4) / 3;
- const ANALYSIS_LAYOUT_RING_MIN_SWEEP = (Math.PI * 3) / 4;
- const ANALYSIS_LAYOUT_RING_DENSE_MIN_SWEEP = (Math.PI * 2) / 3;
- const ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE = 540;
- const ANALYSIS_LAYOUT_RING_DENSE_RADIUS_BOOST = 140;
- const ANALYSIS_LAYOUT_CENTER_CLUSTER_MIN_GAP = 220;
- const ANALYSIS_LAYOUT_CENTER_CLUSTER_MAX_GAP = 360;
- const ANALYSIS_LAYOUT_CENTER_CLUSTER_PUSH_STEP = 34;
- const ANALYSIS_LAYOUT_CENTER_CLUSTER_ITERATIONS = 16;
- const ANALYSIS_LAYOUT_RELAX_MAX_NODES = 360;
- const ANALYSIS_LAYOUT_WORKER_TIMEOUT = 60000;
- const LEGACY_FORCE_WORKER_THRESHOLD = 12;
- const ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES = 3000;
- const ANALYSIS_LAYOUT_NODE_GAP_X = 20;
- const ANALYSIS_LAYOUT_NODE_GAP_Y = 20;
- const ANALYSIS_COLLAPSE_NEIGHBOR_THRESHOLD = 100;
- const ANALYSIS_EXPAND_FIRST_RING_RADIUS = 180;
- const ANALYSIS_EXPAND_RING_RADIUS_STEP = 96;
- const ANALYSIS_EXPAND_ARC_GAP = 112;
- const ANALYSIS_EXPANDED_EDGE_PREFIX = 'analysis-expanded-edge';
- const ANALYSIS_PERFORMANCE_EDGE_LABEL_LIMIT = 300;
- const LARGE_GRAPH_VIEW_FOCUS_LIMIT = 36;
- const LARGE_GRAPH_DEFAULT_FOCUS_ZOOM = 0.18;
- // Keep the default analysis layout close to Mingjing's single-pass force layout.
- const DEFAULT_GRAPH_LAYOUT_PASS_COUNT = 1;
- const LARGE_GRAPH_LAYOUT_PASS_COUNT = 1;
- const EDGE_TARGET_ARROW_GAP = 14;
- const PARALLEL_EDGE_CURVE_GAP = 28;
- const PERSON_NEIGHBOR_CLUSTER_MIN_HUB_DEGREE = 4;
- // Keep the G6 analysis layout aligned with Mingjing's force2 preset.
- const PERSON_NEIGHBOR_CLUSTER_MIN_COUNT = 999;
- const PERSON_NEIGHBOR_CLUSTER_RADIUS = 36;
- const PERSON_NEIGHBOR_CLUSTER_RING_STEP = 44;
- const PERSON_NEIGHBOR_CLUSTER_SPACING = 40;
- const SNAPSHOT_PREVIEW_NODE_LIMIT = 80;
- const SNAPSHOT_PREVIEW_EDGE_LIMIT = 120;
- const MAX_HISTORY_SNAPSHOTS = 10;
- const MAX_UNDO_STACK = 20;
- const EMPTY_GRAPH_SNAPSHOT = JSON.stringify({ nodes: [], edges: [], combos: [] });
- const markerColors = ['#ef5350', '#ffb300', '#4c91ff', '#00d156', '#be4ed1', '#27ab9e', '#707c9b'] as const;
- const filterOperators = ['>', '>=', '=', '<', '<='] as const;
- let analysisLayoutWorker: Worker | null = null;
- const toSvgDataUri = (svgMarkup: string) =>
- `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup.trim())}`;
- const getPaletteIconMarkup = (typeKey: EntityKindKey) => {
- const common =
- 'fill="none" stroke="rgba(255,255,255,0.96)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"';
- const glyphs: Record<EntityKindKey, string> = {
- person: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <circle cx="14" cy="9.2" r="3.8" ${common}></circle>
- <path d="M7.8 20.8c1.8-4.2 10.6-4.2 12.4 0" ${common}></path>
- </svg>
- `,
- group: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <path d="M6.5 11.2 14 7l7.5 4.2" ${common}></path>
- <path d="M8.2 11.6h11.6v9.2H8.2z" ${common}></path>
- <path d="M11 20.8v-5.4M14 20.8v-5.4M17 20.8v-5.4" ${common}></path>
- </svg>
- `,
- house: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <path d="M6.4 13.2 14 7.2l7.6 6" ${common}></path>
- <path d="M8.8 12.8v8h10.4v-8" ${common}></path>
- <path d="M12.2 20.8v-4.8h3.6v4.8" ${common}></path>
- </svg>
- `,
- car: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <path d="M8.2 18.8h11.6l1.4-3.6a2.1 2.1 0 0 0-2-2.8H8.8a2.1 2.1 0 0 0-2 1.5l-0.8 2.5a1.9 1.9 0 0 0 1.8 2.4Z" ${common}></path>
- <path d="M8.8 12.4 10.6 9.6h6.8l1.8 2.8" ${common}></path>
- <circle cx="10" cy="19.4" r="1.5" ${common}></circle>
- <circle cx="18" cy="19.4" r="1.5" ${common}></circle>
- </svg>
- `,
- bank: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <rect x="5.6" y="8.4" width="16.8" height="11.2" rx="2.4" ${common}></rect>
- <path d="M5.6 12.4h16.8" ${common}></path>
- <path d="M9 16.4h4.4M16.6 16.4h2.6" ${common}></path>
- </svg>
- `,
- phone: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <rect x="9.2" y="6.2" width="9.6" height="15.6" rx="2.2" ${common}></rect>
- <path d="M12.2 8.8h3.6M13.1 18.9h1.8" ${common}></path>
- </svg>
- `,
- unknown: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <path d="M10.2 10.6a3.8 3.8 0 1 1 6.3 2.8c-1 .8-2 1.6-2 3" ${common}></path>
- <path d="M14 20.4h.1" ${common}></path>
- <path d="M7.2 7.2 9 9M21 7.2 19.2 9M7.2 20.8 9 19M21 20.8 19.2 19" ${common}></path>
- </svg>
- `,
- other: `
- <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
- <path d="M14 6.8 19.8 10v8L14 21.2 8.2 18v-8z" ${common}></path>
- <path d="M14 6.8v6.2M19.8 10 14 13 8.2 10M14 13v8.2" ${common}></path>
- </svg>
- `,
- };
- return glyphs[typeKey] || glyphs.unknown;
- };
- const getPaletteIconDataUri = (typeKey: EntityKindKey) => toSvgDataUri(getPaletteIconMarkup(typeKey));
- const entityKinds: EntityKind[] = [
- {
- key: 'person',
- label: '人物',
- icon: personIcon,
- paletteIcon: getPaletteIconDataUri('person'),
- color: '#b92a31',
- description: '重点对象、联系人',
- },
- {
- key: 'group',
- label: '机构',
- icon: groupIcon,
- paletteIcon: getPaletteIconDataUri('group'),
- color: '#0f9f68',
- description: '公司、门店、组织',
- },
- {
- key: 'house',
- label: '房产',
- icon: houseIcon,
- paletteIcon: getPaletteIconDataUri('house'),
- color: '#0f4c8a',
- description: '住址、房产、活动地点',
- },
- {
- key: 'car',
- label: '车辆',
- icon: carIcon,
- paletteIcon: getPaletteIconDataUri('car'),
- color: '#f29a1f',
- description: '车牌、车辆、轨迹对象',
- },
- {
- key: 'bank',
- label: '银行卡',
- icon: bankIcon,
- paletteIcon: getPaletteIconDataUri('bank'),
- color: '#8441ae',
- description: '银行卡、账户、资金实体',
- },
- {
- key: 'phone',
- label: '手机',
- icon: phoneIcon,
- paletteIcon: getPaletteIconDataUri('phone'),
- color: '#0aa0b4',
- description: '手机号、固话、虚拟号',
- },
- {
- key: 'unknown',
- label: '未知',
- icon: unknownIcon,
- paletteIcon: getPaletteIconDataUri('unknown'),
- color: '#434c55',
- description: '未识别实体',
- },
- {
- key: 'other',
- label: '其他',
- icon: otherIcon,
- paletteIcon: getPaletteIconDataUri('other'),
- color: '#d4ac2b',
- description: '扩展实体',
- },
- ];
- const analysisToolbarItems: AnalysisToolbarItem[] = [
- { key: 'undo', label: '撤销', icon: UndoOutlined },
- { key: 'redo', label: '恢复', icon: RedoOutlined, gap: true },
- { key: 'find', label: '查找', icon: SearchOutlined, panelKey: 'find' },
- { key: 'filter', label: '过滤', icon: FilterOutlined, panelKey: 'filter' },
- { key: 'mark', label: '标记', icon: StarOutlined },
- { key: 'selectNeighbor', label: '扩散关系', icon: ShareAltOutlined, tipsContent: '选中当前实体的相邻实体和关系' },
- { key: 'boxSelect', label: '框选', icon: BorderOutlined, tipsContent: 'G6 当前通过 Shift 拖拽框选' },
- { key: 'copy', label: '复制', icon: CopyOutlined, gap: true },
- { key: 'paste', label: '粘贴', icon: SnippetsOutlined },
- { key: 'delete', label: '删除', icon: DeleteOutlined },
- { key: 'group', label: '组合实体', icon: GroupOutlined },
- { key: 'ungroup', label: '拆分实体', icon: UngroupOutlined },
- { key: 'newCanvas', label: '复制到新画布', icon: FileAddOutlined, gap: true },
- { key: 'forceLayout', label: '智能布局', icon: ApartmentOutlined, tipsContent: '按对象关系分析的力导向重新布局' },
- { key: 'fullScreen', label: '全屏预览', icon: FullscreenOutlined },
- ];
- const routeGraphQuery = computed(() => buildGraphViewQuery(route.query));
- const routeGraphRespondents = computed(() => resolveGraphRespondentsFromRouteQuery(route.query));
- const routeGraphDefaultSelectedIds = computed(() => routeGraphRespondents.value.map((item) => item.id));
- const allRespondentOptions = computed(() => {
- const loaded = loadedRespondentOptions.value;
- const fromRoute = routeGraphRespondents.value;
- if (!loaded.length) return fromRoute;
- if (!fromRoute.length) return loaded;
- const seenKeys = new Set(loaded.map((item) => item.id));
- const merged = [...loaded];
- fromRoute.forEach((item) => {
- if (!seenKeys.has(item.id)) {
- seenKeys.add(item.id);
- merged.push(item);
- }
- });
- return merged;
- });
- const activeGraphCanvas = computed(() => graphCanvasTabs.value[activeGraphCanvasIndex.value] || null);
- const lastSavedAt = computed(() => activeGraphCanvas.value?.lastSavedAt || '');
- const nodeCountDisplayText = computed(() =>
- analysisTotalNodeCount.value > 0 ? String(analysisTotalNodeCount.value) : String(graphStats.nodes),
- );
- const edgeCountDisplayText = computed(() =>
- analysisTotalEdgeCount.value > 0 ? String(analysisTotalEdgeCount.value) : String(graphStats.edges),
- );
- const nodeCountTitle = computed(() =>
- analysisTotalNodeCount.value > 0 && analysisTotalNodeCount.value !== graphStats.nodes
- ? `当前显示 ${graphStats.nodes} 个节点,接口返回 ${analysisTotalNodeCount.value} 个节点`
- : '',
- );
- const edgeCountTitle = computed(() =>
- analysisTotalEdgeCount.value > 0 && analysisTotalEdgeCount.value !== graphStats.edges
- ? `当前显示 ${graphStats.edges} 条关系,接口返回 ${analysisTotalEdgeCount.value} 条关系`
- : '',
- );
- const analysisRunning = computed(() => analysisLoading.value || analysisLayoutLoading.value);
- const hasGraphData = computed(() => graphStats.nodes > 0 || graphStats.edges > 0 || graphStats.combos > 0);
- const findResultCount = computed(() => findResultIds.value.length);
- const analysisDetailTotalPages = computed(() =>
- Math.max(1, Math.ceil(analysisDetailPagination.value.total / analysisDetailPagination.value.limit)),
- );
- const analysisDetailTableModalOpen = computed(
- () => analysisDetailVisible.value && (analysisDetailLoading.value || analysisDetailTableColumns.value.length > 0),
- );
- const analysisDetailSummaryModalOpen = computed(
- () => analysisDetailVisible.value && !analysisDetailLoading.value && !analysisDetailTableColumns.value.length,
- );
- const isAnalysisDetailDateColumn = (column: AnalysisDetailTableColumn | undefined) => {
- const title = String(column?.title ?? '').trim();
- const dataIndex = String(column?.dataIndex ?? '').trim();
- const key = String(column?.key ?? '').trim();
- const lookupText = [title, dataIndex, key].filter(Boolean).join('|').toLowerCase();
- return (
- title.includes('联系日期') ||
- title.includes('呼叫开始时间') ||
- title.includes('通话日期时间') ||
- title.includes('通话日期') ||
- title.includes('交易日期') ||
- title.includes('交易时间') ||
- title.includes('日期') ||
- title.includes('时间') ||
- lookupText.includes('startdate') ||
- lookupText.includes('enddate') ||
- lookupText.includes('transdate') ||
- lookupText.includes('transtime') ||
- lookupText.includes('transdatetime') ||
- lookupText.includes('date') ||
- lookupText.includes('time')
- );
- };
- const analysisDetailDateField = computed(() => {
- const dateColumn = analysisDetailTableColumns.value.find((column) => isAnalysisDetailDateColumn(column));
- return dateColumn ? String(dateColumn.dataIndex) : '';
- });
- const analysisDetailDateFilterLabel = computed(() => {
- const dateColumn = analysisDetailTableColumns.value.find(
- (column) => String(column?.dataIndex ?? '') === analysisDetailDateField.value,
- );
- const title = String(dateColumn?.title ?? '').trim();
- if (
- title.includes('联系日期') ||
- title.includes('呼叫开始时间') ||
- title.includes('通话日期时间') ||
- title.includes('通话日期')
- ) {
- return '联系日期';
- }
- if (title.includes('交易时间') || title.includes('时间')) {
- return '交易日期';
- }
- return '交易日期';
- });
- const analysisDetailDateFilterVisible = computed(() => Boolean(analysisDetailDateField.value));
- const analysisDetailAmountField = computed(() => {
- const amountColumn = analysisDetailTableColumns.value.find((column) => {
- const title = String(column?.title ?? '').trim();
- return title.includes('交易金额') || title.includes('金额');
- });
- return amountColumn ? String(amountColumn.dataIndex) : '';
- });
- const analysisDetailAmountFilterVisible = computed(() => Boolean(analysisDetailAmountField.value));
- const analysisDetailFilterSchema = computed<AnalysisDetailFilterItem[]>(() => {
- const schema: AnalysisDetailFilterItem[] = [];
- if (analysisDetailDateFilterVisible.value) {
- schema.push({
- field: 'analysisDetailDateRange',
- label: analysisDetailDateFilterLabel.value,
- type: 'date-range',
- startField: 'startDate',
- endField: 'endDate',
- width: 320,
- maxWidth: 360,
- rangePlaceholder: ['开始日期', '结束日期'],
- valueFormat: 'YYYY-MM-DD',
- });
- }
- if (analysisDetailAmountFilterVisible.value) {
- schema.push({
- field: 'transAmount',
- label: '交易金额',
- type: 'compare-input',
- operatorField: 'transAmountSymbol',
- options: graphTransAmountOperatorOptions,
- operatorWidth: 72,
- inputWidth: 180,
- inputMaxWidth: 180,
- placeholder: '请输入交易金额',
- });
- }
- return schema;
- });
- const activeToolbarPanelLabel = computed(() => {
- if (activeToolbarPanel.value === 'find') return '查找';
- if (activeToolbarPanel.value === 'filter') return '过滤';
- if (activeToolbarPanel.value === 'mark') return '标记';
- return '';
- });
- const selectedSummary = computed(() => {
- const nodes = selectedNodeIds.value.length;
- const edges = selectedEdgeIds.value.length;
- const combos = selectedComboIds.value.length;
- return {
- nodes,
- edges,
- combos,
- total: nodes + edges + combos,
- };
- });
- const pendingEdgeSourceLabel = computed(() => {
- if (!pendingEdgeSourceId.value || !graphRef.value) return '';
- const node = findNodeData(pendingEdgeSourceId.value);
- return getNodeLabel(node);
- });
- const getEntityKind = (kind?: unknown) => entityKinds.find((item) => item.key === kind) || entityKinds[0];
- const sanitizeEdgeLabelText = (value?: string | number | null) =>
- String(value ?? '')
- .trim()
- .replace(/(?<=\d)[,\uFF0C](?=\d)/g, '');
- const formatEdgeAmountLabel = (value: number) => (Number.isFinite(value) ? String(Math.round(value)) : String(value));
- const getNodeLabel = (node?: NodeData | null) => String(node?.data?.label || node?.id || '');
- const getNodeSecondaryLabel = (node?: NodeData | null) => String(node?.data?.secondaryLabel || '');
- const getComboLabel = (combo?: ComboData | null) => String(combo?.data?.label || combo?.id || '');
- const getEdgeLabel = (edge?: EdgeData | null) => sanitizeEdgeLabelText(edge?.data?.label || edge?.id || '关系');
- const getPerformanceNodeLabel = (node?: NodeData | null) => String(node?.data?.performanceLabel || '');
- const getPerformanceNodeSecondaryLabel = (node?: NodeData | null) =>
- String(node?.data?.performanceSecondaryLabel || '');
- const getPerformanceEdgeLabel = (edge?: EdgeData | null) => sanitizeEdgeLabelText(edge?.data?.performanceLabel || '');
- const isLargeGraphData = (data: GraphData) =>
- (data.nodes?.length || 0) >= LARGE_GRAPH_NODE_THRESHOLD || (data.edges?.length || 0) >= LARGE_GRAPH_EDGE_THRESHOLD;
- const getBoundedLimit = (value: number, total: number) =>
- Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
- const normalizeLookupKey = (value?: string | number | null) =>
- String(value || '')
- .trim()
- .toUpperCase();
- const uniqueValues = <T,>(items: T[]) => Array.from(new Set(items));
- const normalizeAnalysisEdgeClazz = (clazz?: string | null) =>
- String(clazz || '')
- .trim()
- .toLowerCase();
- const isTradeAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'card_card';
- const isCommunicationAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'tel_tel';
- const shouldShowAnalysisEdgeCount = (clazz?: string | null) => {
- const normalizedClazz = normalizeAnalysisEdgeClazz(clazz);
- return normalizedClazz === 'card_card' || normalizedClazz === 'tel_tel';
- };
- const getAnalysisEdgeCountText = (edge: GraphAnalysisEdge) => {
- if (!shouldShowAnalysisEdgeCount(edge.clazz)) return '';
- const countValue =
- edge.nums !== null && edge.nums !== undefined && edge.nums !== ''
- ? edge.nums
- : isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
- ? edge.money
- : undefined;
- return countValue !== undefined ? `${String(countValue).replace(/\.0$/, '')}次` : '';
- };
- const stringifyDetailValue = (value: unknown) => {
- if (value === undefined || value === null || value === '') return '--';
- if (Array.isArray(value)) return value.length ? value.map((item) => stringifyDetailValue(item)).join(' / ') : '--';
- if (typeof value === 'object') {
- try {
- return JSON.stringify(value);
- } catch {
- return String(value);
- }
- }
- return String(value);
- };
- const appendDetailEntry = (entries: AnalysisDetailEntry[], label: string, value: unknown) => {
- const text = stringifyDetailValue(value);
- if (text === '--') return;
- entries.push({ label, value: text });
- };
- const normalizeGraphDetailText = (value: unknown) => {
- const text = String(value ?? '').trim();
- if (!text || text === 'null' || text === 'undefined') return undefined;
- return text;
- };
- const getAnalysisClassByKind = (kind: EntityKindKey) => {
- switch (kind) {
- case 'person':
- return 'sfz';
- case 'phone':
- return 'tel';
- case 'bank':
- return 'card';
- case 'house':
- return 'dz_fc';
- case 'car':
- return 'car';
- case 'group':
- return 'obj';
- case 'other':
- return 'other';
- default:
- return 'x';
- }
- };
- const inferEntityKindFromToken = (value?: string | null): EntityKindKey => {
- const token = String(value || '').trim();
- if (!token) return 'unknown';
- if (/公司|超市|商店|经营部|企业|集团|合作社|银行|机构|组织/.test(token)) return 'group';
- if (/^[\u4e00-\u9fa5][A-Z0-9]{5,}$/i.test(token)) return 'car';
- if (/小区|楼|室|路|街|村|镇|区|县|市|地址|房产|住址/.test(token)) return 'house';
- const digitOnly = token.replace(/\D/g, '');
- if (/^\d{17}[0-9X*]$/i.test(token) || token.includes('****')) return 'person';
- if (digitOnly.length >= 7 && digitOnly.length <= 11) return 'phone';
- if (digitOnly.length >= 12) return 'bank';
- return 'other';
- };
- const getEntityKindByClass = (value?: string | null): EntityKindKey | null => {
- const normalized = String(value || '')
- .trim()
- .toLowerCase();
- if (!normalized) return null;
- if (normalized.includes('sfz') || normalized === 'obj') return 'person';
- if (normalized.includes('tel')) return 'phone';
- if (normalized.includes('card')) return 'bank';
- if (normalized.includes('fc') || normalized.includes('house') || normalized.includes('addr')) return 'house';
- if (normalized.includes('car')) return 'car';
- if (normalized.includes('ins') || normalized.includes('org') || normalized.includes('group')) return 'group';
- return null;
- };
- const getEntityKindByClazzPositionToken = (value?: string | null): EntityKindKey | null => {
- const normalized = String(value || '')
- .trim()
- .toLowerCase();
- if (!normalized) return null;
- if (normalized === 'obj') return 'person';
- if (normalized === 'org' || normalized === 'ins') return 'group';
- return getEntityKindByClass(normalized);
- };
- const getEdgeEndpointKindsByClazz = (clazz?: string | null) => {
- const tokens = String(clazz || '')
- .trim()
- .toLowerCase()
- .split(/[^a-z0-9]+/)
- .filter(Boolean);
- return {
- sourceKind: getEntityKindByClazzPositionToken(tokens[0]),
- targetKind: getEntityKindByClazzPositionToken(tokens[tokens.length - 1]),
- };
- };
- const getAnalysisNodeFullLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
- const name = String(node.mc || '').trim();
- const nodeCn = String(node.nodecn || '').trim();
- const code = String(node.bm || node.sfz || '').trim();
- if (kind === 'person' || kind === 'group') return name || nodeCn || code || '未命名实体';
- if (kind === 'bank' || kind === 'phone' || kind === 'car' || kind === 'house') {
- return code || name || nodeCn || '未命名实体';
- }
- return name || nodeCn || code || '未命名实体';
- };
- const getAnalysisNodeSecondaryLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
- const nodeCn = String(node.nodecn || '').trim();
- const code = String(node.bm || node.sfz || '').trim();
- const fullLabel = getAnalysisNodeFullLabel(node, kind);
- if (!nodeCn || nodeCn === code || nodeCn === fullLabel) return '';
- return nodeCn;
- };
- const buildAnalysisEdgeLabel = (edge: GraphAnalysisEdge) => {
- const baseLabel = String(edge.mc || edge.sm || '').trim();
- const relationText = [baseLabel, edge.clazz, edge.sm, edge.mc]
- .map((item) => String(item || '').trim())
- .filter(Boolean)
- .join(' ');
- const shouldHideAmount =
- relationText.includes('持卡人') ||
- relationText.includes('机主') ||
- relationText.includes('开户人') ||
- relationText.includes('户主');
- const countLabel = getAnalysisEdgeCountText(edge);
- const amountLabel =
- !shouldHideAmount && !isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
- ? `¥${formatEdgeAmountLabel(edge.money)}`
- : '';
- return [baseLabel, amountLabel || countLabel].filter(Boolean).join(' ') || '关联';
- };
- const buildNodeDetailEntriesFromPayload = (payload: unknown) => {
- const entries: AnalysisDetailEntry[] = [];
- if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return entries;
- Object.entries(payload as Record<string, unknown>).forEach(([key, value]) => {
- appendDetailEntry(entries, key, value);
- });
- return entries;
- };
- const resolveNodeDetailPayload = (response: unknown) => {
- const root = response && typeof response === 'object' ? (response as Record<string, unknown>) : {};
- const data = root?.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : root;
- const head = Array.isArray(data?.head) ? (data.head as Record<string, unknown>[]) : [];
- const pages = data?.pages && typeof data.pages === 'object' ? (data.pages as Record<string, unknown>) : undefined;
- const records = Array.isArray(pages?.records) ? (pages?.records as Record<string, unknown>[]) : [];
- return {
- data,
- head,
- records,
- pages,
- };
- };
- const isNodeDetailSequenceColumn = (item: Record<string, unknown>) => {
- const title = (normalizeGraphDetailText(item.title) || '').toLowerCase();
- const dataIndex = (normalizeGraphDetailText(item.dataIndex) || '').toLowerCase();
- const key = (normalizeGraphDetailText(item.key) || '').toLowerCase();
- return (
- ['序号', 'index', 'xh'].includes(title) || ['index', 'xh'].includes(dataIndex) || ['index', 'xh'].includes(key)
- );
- };
- const normalizeNodeDetailColumnWidth = (value: unknown, title: string) => {
- const width = Number.parseFloat(String(value ?? ''));
- if (Number.isFinite(width) && width > 0) return width;
- if (title === '序号') return 72;
- if (title.includes('时间') || title.includes('日期')) return 180;
- if (title.includes('地址') || title.includes('内容') || title.includes('摘要') || title.includes('名称')) {
- return 220;
- }
- return 160;
- };
- const buildNodeDetailTableColumns = (head: Record<string, unknown>[]): AnalysisDetailTableColumn[] =>
- head
- .filter((item) => !isNodeDetailSequenceColumn(item))
- .map((item, index) => {
- const title = normalizeGraphDetailText(item.title) || normalizeGraphDetailText(item.key) || `字段${index + 1}`;
- const dataIndex =
- normalizeGraphDetailText(item.dataIndex) || normalizeGraphDetailText(item.key) || `field_${index}`;
- const key = normalizeGraphDetailText(item.key) || dataIndex;
- const width = normalizeNodeDetailColumnWidth(item.width, title);
- return {
- title,
- dataIndex,
- key,
- width,
- };
- })
- .filter((item) => item.dataIndex);
- const buildNodeDetailTableRows = (records: Record<string, unknown>[]) =>
- records.map((item, index) => ({
- rowKey: String(item.id ?? item.rowId ?? item.fileId ?? index),
- ...item,
- }));
- const resetAnalysisDetailTableFilters = () => {
- analysisDetailFilters.startDate = '';
- analysisDetailFilters.endDate = '';
- analysisDetailFilters.transAmount = '';
- analysisDetailFilters.transAmountSymbol = 'GE';
- analysisDetailFilters.orderKey = '';
- analysisDetailFilters.sort = '';
- };
- const buildAnalysisDetailNodeQuery = () => {
- const baseQuery = analysisDetailQueryBase.value;
- if (!baseQuery) return null;
- return {
- ...baseQuery,
- page: analysisDetailPagination.value.page,
- limit: analysisDetailPagination.value.limit,
- startDate: analysisDetailFilters.startDate || undefined,
- endDate: analysisDetailFilters.endDate || undefined,
- transAmountSymbol: analysisDetailFilters.transAmount ? analysisDetailFilters.transAmountSymbol : undefined,
- transAmount: analysisDetailFilters.transAmount || undefined,
- orderKey: analysisDetailFilters.orderKey || undefined,
- sort: analysisDetailFilters.sort || undefined,
- } as GraphNodeDetailQuery;
- };
- const fetchAnalysisDetailTable = async () => {
- const query = buildAnalysisDetailNodeQuery();
- if (!query) return;
- analysisDetailLoading.value = true;
- try {
- const response = await fetchGraphNodeDetail(query);
- const { data, head, records, pages } = resolveNodeDetailPayload(response);
- if (head.length) {
- analysisDetailTableColumns.value = buildNodeDetailTableColumns(head);
- analysisDetailTableRows.value = buildNodeDetailTableRows(records);
- const current = Number(pages?.current ?? analysisDetailPagination.value.page);
- const size = Number((pages?.size ?? records.length) || analysisDetailPagination.value.limit);
- const total = Number(pages?.total ?? records.length);
- analysisDetailPagination.value.page =
- Number.isFinite(current) && current > 0 ? current : analysisDetailPagination.value.page;
- analysisDetailPagination.value.limit =
- Number.isFinite(size) && size > 0 ? size : analysisDetailPagination.value.limit;
- analysisDetailPagination.value.total = Number.isFinite(total) && total >= 0 ? total : records.length;
- analysisDetailEntries.value = [];
- return;
- }
- analysisDetailEntries.value = buildNodeDetailEntriesFromPayload(data);
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- analysisDetailPagination.value.total = 0;
- } catch (error) {
- console.error('load g6 node detail failed', error);
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- analysisDetailPagination.value.total = 0;
- message.error('节点详情加载失败');
- } finally {
- analysisDetailLoading.value = false;
- }
- };
- const handleAnalysisDetailSearch = async () => {
- analysisDetailPagination.value.page = 1;
- await fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailReset = async () => {
- resetAnalysisDetailTableFilters();
- analysisDetailPagination.value.page = 1;
- await fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailPrevPage = () => {
- if (analysisDetailPagination.value.page <= 1) return;
- analysisDetailPagination.value.page -= 1;
- void fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailNextPage = () => {
- if (analysisDetailPagination.value.page >= analysisDetailTotalPages.value) return;
- analysisDetailPagination.value.page += 1;
- void fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailPageChange = (page: number, pageSize: number) => {
- if (analysisDetailLoading.value) return;
- if (pageSize !== analysisDetailPagination.value.limit) {
- handleAnalysisDetailPageSizeChange(pageSize);
- return;
- }
- if (page === analysisDetailPagination.value.page - 1) {
- handleAnalysisDetailPrevPage();
- return;
- }
- if (page === analysisDetailPagination.value.page + 1) {
- handleAnalysisDetailNextPage();
- return;
- }
- if (page === analysisDetailPagination.value.page) return;
- analysisDetailPagination.value.page = page;
- void fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailPageSizeChange = (size: number) => {
- analysisDetailPagination.value.limit = Number(size);
- analysisDetailPagination.value.page = 1;
- void fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailSortChange = (payload: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
- analysisDetailFilters.orderKey = payload.orderKey;
- analysisDetailFilters.sort = payload.sort;
- analysisDetailPagination.value.page = 1;
- void fetchAnalysisDetailTable();
- };
- const handleAnalysisDetailVisibleChange = (open: boolean) => {
- analysisDetailVisible.value = open;
- };
- const findNodeData = (id: string) => graphRef.value?.getNodeData().find((node) => String(node.id) === id) || null;
- const findEdgeData = (id: string) =>
- graphRef.value?.getEdgeData().find((edge) => edge.id && String(edge.id) === id) || null;
- const findComboData = (id: string) => graphRef.value?.getComboData().find((combo) => String(combo.id) === id) || null;
- const createInitialData = (): GraphData => {
- return {
- combos: [],
- nodes: [],
- edges: [],
- };
- };
- const createAnalysisNodeFromToken = (token: string, kind?: EntityKindKey | null): NodeData | null => {
- const key = normalizeLookupKey(token);
- if (!key) return null;
- const entityKind = kind || inferEntityKindFromToken(token);
- return {
- id: key,
- data: {
- kind: entityKind,
- label: token.trim(),
- fullLabel: token.trim(),
- isAnalysis: true,
- },
- };
- };
- const getRelationLayoutSize = () => {
- const container = graphContainerRef.value || graphWrapperRef.value;
- return {
- width: container?.clientWidth || 1200,
- height: container?.clientHeight || 720,
- };
- };
- const getFiniteNumber = (value: unknown, fallback: number) => {
- const numericValue = Number(value);
- return Number.isFinite(numericValue) ? numericValue : fallback;
- };
- const getGraphElementPositionPoint = (graph: Graph, elementId: string, fallback?: { x?: unknown; y?: unknown }) => {
- let position: unknown;
- try {
- position = graph.getElementPosition(elementId);
- } catch {
- position = undefined;
- }
- const x = Array.isArray(position) || position instanceof Float32Array ? position[0] : (position as any)?.x;
- const y = Array.isArray(position) || position instanceof Float32Array ? position[1] : (position as any)?.y;
- const fallbackX = getFiniteNumber(fallback?.x, 0);
- const fallbackY = getFiniteNumber(fallback?.y, 0);
- return {
- x: getFiniteNumber(x, fallbackX),
- y: getFiniteNumber(y, fallbackY),
- };
- };
- const getNodeAnalysisTokens = (node: NodeData) => {
- const rawData = (node.data?.rawData || {}) as Record<string, any>;
- return uniqueValues(
- [
- node.id,
- node.data?.label,
- node.data?.fullLabel,
- rawData.bm,
- rawData.sfz,
- rawData.id,
- rawData.dxId,
- rawData.dxId0,
- rawData.fzId,
- rawData.mc,
- rawData.name,
- ]
- .map((item) => normalizeLookupKey(item as string | number | null | undefined))
- .filter(Boolean),
- );
- };
- const sortRelationLayoutCandidates = (nodes: NodeData[], degreeRecord: Record<string, number>) =>
- [...nodes].sort((left, right) => {
- const leftKind = getEntityKind(left.data?.kind).key;
- const rightKind = getEntityKind(right.data?.kind).key;
- const personDiff = Number(rightKind === 'person') - Number(leftKind === 'person');
- if (personDiff !== 0) return personDiff;
- const degreeDiff = (degreeRecord[String(right.id)] || 0) - (degreeRecord[String(left.id)] || 0);
- if (degreeDiff !== 0) return degreeDiff;
- return getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
- });
- const resolveRelationLayoutSeedIds = (
- nodes: NodeData[],
- degreeRecord: Record<string, number>,
- preferredSeedIds: string[] = [],
- ) => {
- const nodeIdSet = new Set(nodes.map((node) => String(node.id)));
- const validPreferredSeedIds = preferredSeedIds.map(String).filter((nodeId) => nodeIdSet.has(nodeId));
- if (validPreferredSeedIds.length) return uniqueValues(validPreferredSeedIds);
- const routeSeedTokens = new Set(
- routeGraphRespondents.value
- .flatMap((item) => [item.id, item.objId, item.identityCode, item.name])
- .map((item) => normalizeLookupKey(item))
- .filter(Boolean),
- );
- if (routeSeedTokens.size) {
- const routeSeedIds = nodes
- .filter((node) => getNodeAnalysisTokens(node).some((token) => routeSeedTokens.has(token)))
- .map((node) => String(node.id));
- if (routeSeedIds.length) return routeSeedIds;
- }
- const sortedCandidates = sortRelationLayoutCandidates(nodes, degreeRecord);
- return sortedCandidates[0]?.id ? [String(sortedCandidates[0].id)] : [];
- };
- const buildRelationLevelMap = (nodes: NodeData[], edges: AnalysisLayoutWorkerPayload['edges'], seedIds: string[]) => {
- const adjacency = new Map<string, string[]>();
- nodes.forEach((node) => adjacency.set(String(node.id), []));
- edges.forEach((edge) => {
- adjacency.get(edge.source)?.push(edge.target);
- adjacency.get(edge.target)?.push(edge.source);
- });
- const levelMap = new Map<string, number>();
- const queue = [...seedIds];
- seedIds.forEach((seedId) => levelMap.set(seedId, 0));
- while (queue.length) {
- const currentId = queue.shift() || '';
- const currentLevel = levelMap.get(currentId) ?? 0;
- (adjacency.get(currentId) || []).forEach((nextId) => {
- if (levelMap.has(nextId)) return;
- levelMap.set(nextId, currentLevel + 1);
- queue.push(nextId);
- });
- }
- nodes.forEach((node) => {
- const nodeId = String(node.id);
- if (!levelMap.has(nodeId)) {
- levelMap.set(nodeId, 99);
- }
- });
- return levelMap;
- };
- const cloneAnalysisNodeData = (node: NodeData): NodeData => JSON.parse(JSON.stringify(node)) as NodeData;
- const cloneAnalysisEdgeData = (edge: EdgeData): EdgeData => JSON.parse(JSON.stringify(edge)) as EdgeData;
- const getGraphEdgeEndpointId = (edge: EdgeData, endpoint: 'source' | 'target') =>
- String(endpoint === 'source' ? edge.source || '' : edge.target || '').trim();
- const getUndirectedGraphEdgePairKey = (sourceId?: unknown, targetId?: unknown) => {
- const source = String(sourceId || '').trim();
- const target = String(targetId || '').trim();
- if (!source || !target) return '';
- return [source, target].sort().join('::');
- };
- const getGraphEdgeStyle = (edge: EdgeData) => (edge.style || {}) as Record<string, any>;
- const compactGraphEdgeStyle = (style: Record<string, any>) => (Object.keys(style).length ? style : undefined);
- const isBidirectionalArrowEdge = (edge: EdgeData) => {
- const style = getGraphEdgeStyle(edge);
- if (edge.data?.analysisBidirectionalMirrorEdge) return false;
- return style.startArrow === true && style.endArrow !== false;
- };
- const createUniqueGraphEdgeId = (baseId: string, usedEdgeIdSet: Set<string>) => {
- let edgeId = baseId;
- let index = 1;
- while (usedEdgeIdSet.has(edgeId)) {
- edgeId = `${baseId}-${index}`;
- index += 1;
- }
- usedEdgeIdSet.add(edgeId);
- return edgeId;
- };
- const cloneGraphEdgeForCurve = (edge: EdgeData) =>
- ({
- ...edge,
- data: edge.data ? { ...edge.data } : edge.data,
- style: edge.style ? { ...(edge.style as Record<string, any>) } : edge.style,
- }) as EdgeData;
- const splitBidirectionalArrowEdges = (edges: EdgeData[]) => {
- const usedEdgeIdSet = new Set(edges.map((edge) => String(edge.id || '')).filter(Boolean));
- return edges.flatMap((edge, index) => {
- const source = getGraphEdgeEndpointId(edge, 'source');
- const target = getGraphEdgeEndpointId(edge, 'target');
- if (!source || !target || source === target || !isBidirectionalArrowEdge(edge)) {
- return [cloneGraphEdgeForCurve(edge)];
- }
- const sourceEdge = cloneGraphEdgeForCurve(edge);
- const sourceStyle: Record<string, any> = { ...getGraphEdgeStyle(sourceEdge), startArrow: false, endArrow: true };
- delete sourceStyle.childrenData;
- sourceEdge.source = source;
- sourceEdge.target = target;
- sourceEdge.type = 'quadratic';
- sourceEdge.style = sourceStyle;
- sourceEdge.data = {
- ...(sourceEdge.data || {}),
- analysisBidirectionalSourceEdge: true,
- };
- const baseId = String(edge.id || `${source}-${target}-${index}`).trim() || `${source}-${target}-${index}`;
- const mirrorEdge = cloneGraphEdgeForCurve(edge);
- const mirrorStyle: Record<string, any> = { ...getGraphEdgeStyle(mirrorEdge), startArrow: false, endArrow: true };
- delete mirrorStyle.childrenData;
- mirrorEdge.id = createUniqueGraphEdgeId(`${baseId}-reverse`, usedEdgeIdSet);
- mirrorEdge.source = target;
- mirrorEdge.target = source;
- mirrorEdge.type = 'quadratic';
- mirrorEdge.style = mirrorStyle;
- mirrorEdge.data = {
- ...(mirrorEdge.data || {}),
- analysisBidirectionalMirrorEdge: true,
- analysisSourceEdgeId: edge.id || baseId,
- };
- return [sourceEdge, mirrorEdge];
- });
- };
- const getParallelCurveOffset = (index: number, total: number, isReverseEdge: boolean) => {
- const sign = (index % 2 === 0 ? 1 : -1) * (isReverseEdge ? -1 : 1);
- if (total % 2 === 1) {
- return sign * Math.ceil(index / 2) * PARALLEL_EDGE_CURVE_GAP * 2;
- }
- return sign * (Math.floor(index / 2) * PARALLEL_EDGE_CURVE_GAP * 2 + PARALLEL_EDGE_CURVE_GAP);
- };
- const normalizeGraphEdgeCurves = (edges: EdgeData[]) => {
- const normalizedEdges = splitBidirectionalArrowEdges(edges);
- const groupMap = new Map<string, Array<{ edge: EdgeData; source: string; target: string }>>();
- normalizedEdges.forEach((edge) => {
- const source = getGraphEdgeEndpointId(edge, 'source');
- const target = getGraphEdgeEndpointId(edge, 'target');
- const pairKey = getUndirectedGraphEdgePairKey(source, target);
- if (!pairKey || source === target) return;
- const group = groupMap.get(pairKey) || [];
- group.push({ edge, source, target });
- groupMap.set(pairKey, group);
- });
- groupMap.forEach((group) => {
- if (group.length <= 1) {
- const edge = group[0]?.edge;
- if (!edge) return;
- const style = { ...getGraphEdgeStyle(edge) };
- if (edge.data?.analysisBidirectionalSourceEdge || edge.data?.analysisBidirectionalMirrorEdge) {
- style.curveOffset = edge.data?.analysisBidirectionalMirrorEdge
- ? -PARALLEL_EDGE_CURVE_GAP
- : PARALLEL_EDGE_CURVE_GAP;
- edge.type = 'quadratic';
- } else {
- delete style.curveOffset;
- edge.type = 'line';
- }
- edge.style = compactGraphEdgeStyle(style);
- return;
- }
- const baseSource = group[0].source;
- const baseTarget = group[0].target;
- group.forEach((item, index) => {
- const isReverseEdge = item.source === baseTarget && item.target === baseSource;
- const style = {
- ...getGraphEdgeStyle(item.edge),
- curveOffset: getParallelCurveOffset(index, group.length, isReverseEdge),
- };
- item.edge.type = 'quadratic';
- item.edge.style = style;
- });
- });
- return normalizedEdges;
- };
- const normalizeGraphDataEdgeCurves = <T extends GraphData>(data: T): T => ({
- ...data,
- edges: normalizeGraphEdgeCurves(data.edges || []),
- });
- const refreshCurrentGraphEdgeCurves = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const nextData = normalizeGraphDataEdgeCurves(graph.getData());
- graph.setData(nextData);
- await graph.draw();
- };
- const getAnalysisNodeData = (node?: NodeData | null) => (node?.data || {}) as Record<string, any>;
- const getAnalysisEdgeData = (edge?: EdgeData | null) => (edge?.data || {}) as Record<string, any>;
- const getAnalysisCollapsedNodes = (node?: NodeData | null) =>
- (getAnalysisNodeData(node).analysisCollapsedNodes || []) as NodeData[];
- const getAnalysisCollapsedEdges = (node?: NodeData | null) =>
- (getAnalysisNodeData(node).analysisCollapsedEdges || []) as EdgeData[];
- const getAnalysisExpandedNodeIds = (node?: NodeData | null) =>
- ((getAnalysisNodeData(node).analysisExpandedNodeIds || []) as unknown[]).map(String).filter(Boolean);
- const getAnalysisExpandedEdgeIds = (node?: NodeData | null) =>
- ((getAnalysisNodeData(node).analysisExpandedEdgeIds || []) as unknown[]).map(String).filter(Boolean);
- const getAnalysisCollapsedNeighborCount = (node?: NodeData | null) =>
- Number(getAnalysisNodeData(node).analysisCollapsedNeighborCount || 0);
- const getAnalysisExpandedNeighborCount = (node?: NodeData | null) =>
- Number(getAnalysisNodeData(node).analysisExpandedNeighborCount || 0);
- const getAnalysisRelationBadgeCount = (node?: NodeData | null) =>
- getAnalysisExpandedNeighborCount(node) || getAnalysisCollapsedNeighborCount(node);
- const getAnalysisRelationBadgeText = (node?: NodeData | null) => {
- const count = getAnalysisRelationBadgeCount(node);
- if (!count) return '';
- return count > 99 ? '99+' : String(count);
- };
- const buildAnalysisGraphNeighborStats = (nodes: NodeData[], edges: EdgeData[]) => {
- const neighborMap = new Map<string, Set<string>>();
- const degreeMap = new Map<string, number>();
- const edgeMapByNodeId = new Map<string, EdgeData[]>();
- nodes.forEach((node) => {
- const nodeId = String(node.id || '');
- if (!nodeId) return;
- neighborMap.set(nodeId, new Set<string>());
- degreeMap.set(nodeId, 0);
- edgeMapByNodeId.set(nodeId, []);
- });
- edges.forEach((edge) => {
- const source = String(edge.source || '');
- const target = String(edge.target || '');
- if (!source || !target || source === target || !neighborMap.has(source) || !neighborMap.has(target)) {
- return;
- }
- neighborMap.get(source)?.add(target);
- neighborMap.get(target)?.add(source);
- degreeMap.set(source, (degreeMap.get(source) || 0) + 1);
- degreeMap.set(target, (degreeMap.get(target) || 0) + 1);
- edgeMapByNodeId.get(source)?.push(edge);
- edgeMapByNodeId.get(target)?.push(edge);
- });
- return { neighborMap, degreeMap, edgeMapByNodeId };
- };
- const getAnalysisGraphDegreeRecord = (nodes: NodeData[], degreeMap: Map<string, number>) =>
- nodes.reduce<Record<string, number>>((accumulator, node) => {
- const nodeId = String(node.id || '');
- if (nodeId) accumulator[nodeId] = degreeMap.get(nodeId) || 0;
- return accumulator;
- }, {});
- const getAnalysisCollapseCenterNodes = (nodes: NodeData[], seedIdSet: Set<string>, degreeMap: Map<string, number>) =>
- [...nodes].sort((left, right) => {
- const leftId = String(left.id || '');
- const rightId = String(right.id || '');
- const seedDiff = Number(seedIdSet.has(rightId)) - Number(seedIdSet.has(leftId));
- if (seedDiff !== 0) return seedDiff;
- const personDiff =
- Number(getEntityKind(right.data?.kind).key === 'person') -
- Number(getEntityKind(left.data?.kind).key === 'person');
- if (personDiff !== 0) return personDiff;
- const degreeDiff = (degreeMap.get(rightId) || 0) - (degreeMap.get(leftId) || 0);
- if (degreeDiff !== 0) return degreeDiff;
- return getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
- });
- const getAnalysisCollapsibleNeighborIds = (options: {
- centerId: string;
- neighborMap: Map<string, Set<string>>;
- hiddenNodeIds: Set<string>;
- seedIdSet: Set<string>;
- }) =>
- Array.from(options.neighborMap.get(options.centerId) || []).filter((neighborId) => {
- if (!neighborId || options.hiddenNodeIds.has(neighborId) || options.seedIdSet.has(neighborId)) return false;
- const neighborDegree = options.neighborMap.get(neighborId)?.size || 0;
- // Only collapse leaf neighbors so bridge nodes connected to other entities remain visible.
- return neighborDegree === 1;
- });
- const applyAnalysisCollapsedNeighbors = (options: {
- centerNode: NodeData;
- collapsedNodeIds: string[];
- nodeMap: Map<string, NodeData>;
- edgeMapByNodeId: Map<string, EdgeData[]>;
- }) => {
- const collapsedNodeIdSet = new Set(options.collapsedNodeIds);
- const collapsedNodes = options.collapsedNodeIds
- .map((nodeId) => options.nodeMap.get(nodeId))
- .filter((node): node is NodeData => Boolean(node))
- .map((node) => cloneAnalysisNodeData(node));
- if (!collapsedNodes.length) return false;
- const edgeKeySet = new Set<string>();
- const collapsedEdges: EdgeData[] = [];
- options.collapsedNodeIds.forEach((nodeId) => {
- (options.edgeMapByNodeId.get(nodeId) || []).forEach((edge, index) => {
- const source = String(edge.source || '');
- const target = String(edge.target || '');
- if (!collapsedNodeIdSet.has(source) && !collapsedNodeIdSet.has(target)) return;
- const edgeKey = String(edge.id || `${source}->${target}-${getEdgeLabel(edge)}-${index}`);
- if (edgeKeySet.has(edgeKey)) return;
- edgeKeySet.add(edgeKey);
- collapsedEdges.push(cloneAnalysisEdgeData(edge));
- });
- });
- options.centerNode.data = {
- ...(options.centerNode.data || {}),
- analysisCollapsedNeighborCount: collapsedNodes.length,
- analysisCollapsedNodes: collapsedNodes,
- analysisCollapsedEdges: collapsedEdges,
- };
- return true;
- };
- const collectHiddenAnalysisNodeIds = (nodes: NodeData[], edges: EdgeData[], seedIds: string[]) => {
- const nodeMap = nodes.reduce<Map<string, NodeData>>((map, node) => {
- const nodeId = String(node.id || '');
- if (nodeId) map.set(nodeId, node);
- return map;
- }, new Map<string, NodeData>());
- const seedIdSet = new Set(seedIds.map(String).filter(Boolean));
- const hiddenNodeIds = new Set<string>();
- const { neighborMap, degreeMap, edgeMapByNodeId } = buildAnalysisGraphNeighborStats(nodes, edges);
- getAnalysisCollapseCenterNodes(nodes, seedIdSet, degreeMap).forEach((centerNode) => {
- const centerId = String(centerNode.id || '');
- if (!centerId || hiddenNodeIds.has(centerId)) return;
- const candidateNeighborIds = getAnalysisCollapsibleNeighborIds({
- centerId,
- neighborMap,
- hiddenNodeIds,
- seedIdSet,
- });
- if (candidateNeighborIds.length <= ANALYSIS_COLLAPSE_NEIGHBOR_THRESHOLD) return;
- if (
- applyAnalysisCollapsedNeighbors({
- centerNode,
- collapsedNodeIds: candidateNeighborIds,
- nodeMap,
- edgeMapByNodeId,
- })
- ) {
- candidateNeighborIds.forEach((nodeId) => hiddenNodeIds.add(nodeId));
- }
- });
- return hiddenNodeIds;
- };
- const getVisibleAnalysisGraph = (
- nodes: NodeData[],
- edges: EdgeData[],
- hiddenNodeIds: Set<string>,
- ): AnalysisDefaultCollapseResult => {
- const visibleNodes = nodes.filter((node) => !hiddenNodeIds.has(String(node.id || '')));
- const visibleNodeIdSet = new Set(visibleNodes.map((node) => String(node.id || '')).filter(Boolean));
- const visibleEdges = edges.filter(
- (edge) => visibleNodeIdSet.has(String(edge.source || '')) && visibleNodeIdSet.has(String(edge.target || '')),
- );
- return {
- nodes: visibleNodes,
- edges: visibleEdges,
- hiddenNodeIds,
- };
- };
- const applyDefaultAnalysisCollapse = (nodes: NodeData[], edges: EdgeData[]): AnalysisDefaultCollapseResult => {
- const { degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
- const degreeRecord = getAnalysisGraphDegreeRecord(nodes, degreeMap);
- const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord);
- const hiddenNodeIds = collectHiddenAnalysisNodeIds(nodes, edges, seedIds);
- return getVisibleAnalysisGraph(nodes, edges, hiddenNodeIds);
- };
- const buildRelationLayoutPayload = (
- data: GraphData,
- options: RelationLayoutOptions = {},
- ): AnalysisLayoutWorkerPayload => {
- const { width, height } = getRelationLayoutSize();
- const centerX = width / 2;
- const centerY = height / 2;
- const nodes = (data.nodes || []).filter((node) => node.id !== undefined && node.id !== null);
- const nodeIdSet = new Set(nodes.map((node) => String(node.id)));
- const edges = (data.edges || [])
- .map((edge) => ({
- source: String(edge.source || ''),
- target: String(edge.target || ''),
- }))
- .filter((edge) => edge.source && edge.target && edge.source !== edge.target)
- .filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target));
- const degreeRecord = nodes.reduce<Record<string, number>>((accumulator, node) => {
- accumulator[String(node.id)] = 0;
- return accumulator;
- }, {});
- edges.forEach((edge) => {
- degreeRecord[edge.source] = (degreeRecord[edge.source] || 0) + 1;
- degreeRecord[edge.target] = (degreeRecord[edge.target] || 0) + 1;
- });
- const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord, options.seedIds);
- const seedIdSet = new Set(seedIds);
- const levelMap = buildRelationLevelMap(nodes, edges, seedIds);
- const nodeSize = Math.max(24, Math.round(options.nodeSize || ANALYSIS_LAYOUT_NODE_SIZE));
- return {
- width,
- height,
- center: [centerX, centerY],
- nodeWidth: nodeSize,
- nodeHeight: nodeSize,
- layoutPreset: options.preset || 'default',
- degreeRecord,
- nodes: nodes.map((node) => {
- const nodeId = String(node.id);
- return {
- id: nodeId,
- kind: getEntityKind(node.data?.kind).key,
- degree: degreeRecord[nodeId] || 0,
- isSeed: seedIdSet.has(nodeId),
- level: levelMap.get(nodeId) ?? 99,
- x: getFiniteNumber(node.style?.x, centerX),
- y: getFiniteNumber(node.style?.y, centerY),
- anchorX: centerX,
- anchorY: centerY,
- };
- }),
- edges,
- };
- };
- const getGraphDataBounds = (data: GraphData): GraphDataBounds => {
- const nodes = data.nodes || [];
- if (!nodes.length) {
- return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
- }
- return nodes.reduce<GraphDataBounds>(
- (bounds, node) => {
- const x = getFiniteNumber(node.style?.x, 0);
- const y = getFiniteNumber(node.style?.y, 0);
- bounds.minX = Math.min(bounds.minX, x);
- bounds.minY = Math.min(bounds.minY, y);
- bounds.maxX = Math.max(bounds.maxX, x);
- bounds.maxY = Math.max(bounds.maxY, y);
- return bounds;
- },
- {
- minX: Number.POSITIVE_INFINITY,
- minY: Number.POSITIVE_INFINITY,
- maxX: Number.NEGATIVE_INFINITY,
- maxY: Number.NEGATIVE_INFINITY,
- },
- );
- };
- const terminateAnalysisLayoutWorker = () => {
- analysisLayoutWorker?.terminate();
- analysisLayoutWorker = null;
- };
- const createAnalysisLayoutWorker = () => {
- terminateAnalysisLayoutWorker();
- analysisLayoutWorker = new Worker(new URL('../graph/workers/analysisLayout.worker.ts', import.meta.url), {
- type: 'module',
- });
- return analysisLayoutWorker;
- };
- const runAnalysisLayoutWorker = (
- payload: AnalysisLayoutWorkerPayload,
- engine: 'deterministic' | 'legacy-force' = 'legacy-force',
- ) =>
- new Promise<Map<string, { x: number; y: number }>>((resolve, reject) => {
- let timeoutId = 0;
- try {
- const worker = createAnalysisLayoutWorker();
- const cleanup = () => {
- window.clearTimeout(timeoutId);
- worker.onmessage = null;
- worker.onerror = null;
- };
- timeoutId = window.setTimeout(() => {
- cleanup();
- terminateAnalysisLayoutWorker();
- reject(new Error('analysis layout worker timeout'));
- }, ANALYSIS_LAYOUT_WORKER_TIMEOUT);
- worker.onmessage = (event: MessageEvent<AnalysisLayoutWorkerResponseMessage>) => {
- cleanup();
- if (event.data?.type === 'layout-complete') {
- const nextPositions = new Map<string, { x: number; y: number }>();
- (event.data.positions as AnalysisLayoutWorkerPosition[]).forEach((item) => {
- nextPositions.set(item.id, { x: item.x, y: item.y });
- });
- terminateAnalysisLayoutWorker();
- resolve(nextPositions);
- return;
- }
- const messageText = event.data?.message || 'analysis layout worker failed';
- terminateAnalysisLayoutWorker();
- reject(new Error(messageText));
- };
- worker.onerror = (event: ErrorEvent) => {
- cleanup();
- terminateAnalysisLayoutWorker();
- reject(event.error || new Error(event.message || 'analysis layout worker failed'));
- };
- worker.postMessage({
- type: 'layout',
- payload,
- engine,
- });
- } catch (error) {
- terminateAnalysisLayoutWorker();
- reject(error instanceof Error ? error : new Error('analysis layout worker failed'));
- }
- });
- const buildFallbackAnalysisNodePositions = (data: GraphData, options: RelationLayoutOptions = {}) => {
- const layoutResult = computeAnalysisLayout(buildRelationLayoutPayload(data, options));
- return {
- positions: new Map(layoutResult.positions.map((item) => [item.id, { x: item.x, y: item.y }])),
- fixedIds: new Set(layoutResult.fixedIds),
- } satisfies AnalysisLayoutDraft;
- };
- const getAnalysisRelaxBounds = (items: AnalysisRelaxItem[], payload: AnalysisLayoutWorkerPayload) => {
- const spreadRatio = Math.max(2.6, Math.sqrt(Math.max(items.length, 1) / 18) * 2.2);
- return {
- minX: -payload.width * spreadRatio,
- maxX: payload.width * (spreadRatio + 1),
- minY: -payload.height * spreadRatio,
- maxY: payload.height * (spreadRatio + 1),
- } satisfies AnalysisRelaxBounds;
- };
- const clampAnalysisRelaxItem = (item: AnalysisRelaxItem, bounds: AnalysisRelaxBounds) => {
- item.x = Math.min(bounds.maxX, Math.max(bounds.minX, item.x));
- item.y = Math.min(bounds.maxY, Math.max(bounds.minY, item.y));
- };
- const getAnalysisRelaxOverlap = (
- left: AnalysisRelaxItem,
- right: AnalysisRelaxItem,
- boxWidth: number,
- boxHeight: number,
- ): AnalysisRelaxOverlap | null => {
- const deltaX = right.x - left.x;
- const deltaY = right.y - left.y;
- const overlapX = boxWidth - Math.abs(deltaX);
- const overlapY = boxHeight - Math.abs(deltaY);
- if (overlapX <= 0 || overlapY <= 0) return null;
- return {
- left,
- right,
- overlapX,
- overlapY,
- deltaX,
- deltaY,
- };
- };
- const getAnalysisRelaxDirection = (overlap: AnalysisRelaxOverlap, leftIndex: number, rightIndex: number) => {
- const separateOnX = overlap.overlapX < overlap.overlapY;
- if (separateOnX) {
- return overlap.deltaX === 0 ? (leftIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaX);
- }
- return overlap.deltaY === 0 ? (rightIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaY);
- };
- const moveAnalysisRelaxItem = (
- item: AnalysisRelaxItem,
- options: { separateOnX: boolean; direction: number; offset: number; multiplier: number },
- ) => {
- if (options.separateOnX) {
- item.x += options.direction * options.offset * options.multiplier;
- return;
- }
- item.y += options.direction * options.offset * options.multiplier;
- };
- const separateAnalysisRelaxItems = (
- overlap: AnalysisRelaxOverlap,
- leftIndex: number,
- rightIndex: number,
- fixedIds: Set<string>,
- bounds: AnalysisRelaxBounds,
- ) => {
- const { left, right } = overlap;
- const leftFixed = fixedIds.has(left.id);
- const rightFixed = fixedIds.has(right.id);
- if (leftFixed && rightFixed) return false;
- const separateOnX = overlap.overlapX < overlap.overlapY;
- const direction = getAnalysisRelaxDirection(overlap, leftIndex, rightIndex);
- const offset = (separateOnX ? overlap.overlapX : overlap.overlapY) / 2 + 20;
- const moveOptions = { separateOnX, direction, offset };
- if (leftFixed) {
- moveAnalysisRelaxItem(right, { ...moveOptions, multiplier: 2 });
- } else if (rightFixed) {
- moveAnalysisRelaxItem(left, { ...moveOptions, multiplier: -2 });
- } else {
- moveAnalysisRelaxItem(left, { ...moveOptions, multiplier: -1 });
- moveAnalysisRelaxItem(right, { ...moveOptions, multiplier: 1 });
- }
- clampAnalysisRelaxItem(left, bounds);
- clampAnalysisRelaxItem(right, bounds);
- return true;
- };
- const relaxAnalysisPositionIteration = (
- items: AnalysisRelaxItem[],
- fixedIds: Set<string>,
- bounds: AnalysisRelaxBounds,
- boxWidth: number,
- boxHeight: number,
- ) => {
- let hasOverlap = false;
- for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
- for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
- const overlap = getAnalysisRelaxOverlap(items[leftIndex], items[rightIndex], boxWidth, boxHeight);
- if (!overlap) continue;
- hasOverlap = separateAnalysisRelaxItems(overlap, leftIndex, rightIndex, fixedIds, bounds) || hasOverlap;
- }
- }
- return hasOverlap;
- };
- const getAnalysisRelaxMaxIterations = (itemCount: number) => (itemCount > 220 ? 36 : itemCount > 120 ? 72 : 120);
- const relaxAnalysisNodePositions = (
- positions: Map<string, { x: number; y: number }>,
- payload: AnalysisLayoutWorkerPayload,
- fixedIds: Set<string> = new Set<string>(),
- ) => {
- const items = Array.from(positions.entries()).map(([id, position]) => ({ id, ...position }));
- if (items.length > ANALYSIS_LAYOUT_RELAX_MAX_NODES) {
- return positions;
- }
- const nodeSize = Math.max(payload.nodeWidth, payload.nodeHeight);
- const boxWidth = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_X;
- const boxHeight = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_Y;
- const bounds = getAnalysisRelaxBounds(items, payload);
- const maxIterations = getAnalysisRelaxMaxIterations(items.length);
- for (let iteration = 0; iteration < maxIterations; iteration += 1) {
- const hasOverlap = relaxAnalysisPositionIteration(items, fixedIds, bounds, boxWidth, boxHeight);
- if (!hasOverlap) break;
- }
- return new Map(items.map((item) => [item.id, { x: item.x, y: item.y }]));
- };
- const mapLegacyLayoutPositions = (positions: AnalysisLayoutWorkerPosition[]) =>
- new Map(positions.map((item) => [item.id, { x: item.x, y: item.y }]));
- const resolveWorkerFallbackAnalysisLayoutPositions = async (
- data: GraphData,
- payload: AnalysisLayoutWorkerPayload,
- options: RelationLayoutOptions,
- ): Promise<AnalysisLayoutResolveResult> => {
- const draft = buildFallbackAnalysisNodePositions(data, options);
- let workerPositions = new Map<string, { x: number; y: number }>();
- try {
- workerPositions = await runAnalysisLayoutWorker(payload, 'legacy-force');
- } catch (error) {
- console.warn('relation force layout fallback to deterministic layout', error);
- }
- return {
- draft,
- positions: workerPositions.size >= (data.nodes?.length || 0) ? workerPositions : draft.positions,
- };
- };
- const resolveLegacyAnalysisLayoutPositions = async (
- data: GraphData,
- payload: AnalysisLayoutWorkerPayload,
- options: RelationLayoutOptions,
- ): Promise<AnalysisLayoutResolveResult> => {
- const draft = buildFallbackAnalysisNodePositions(data, options);
- const legacyPositions = await runLegacyForceLayout(payload);
- return {
- draft,
- positions:
- legacyPositions.length >= (data.nodes?.length || 0)
- ? mapLegacyLayoutPositions(legacyPositions)
- : draft.positions,
- };
- };
- const resolveAnalysisLayoutPositions = async (
- data: GraphData,
- payload: AnalysisLayoutWorkerPayload,
- options: RelationLayoutOptions,
- ): Promise<AnalysisLayoutResolveResult> => {
- const nodeCount = data.nodes?.length || 0;
- if (nodeCount > ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES) {
- const draft = buildFallbackAnalysisNodePositions(data, options);
- return {
- draft,
- positions: draft.positions,
- };
- }
- if (nodeCount >= LEGACY_FORCE_WORKER_THRESHOLD) {
- return resolveWorkerFallbackAnalysisLayoutPositions(data, payload, options);
- }
- return resolveLegacyAnalysisLayoutPositions(data, payload, options);
- };
- const createMingjingG6LayoutOptions = (data: GraphData): LayoutOptions => {
- const nodeCount = data.nodes?.length || 0;
- const degreeMap = new Map<string, number>();
- const hasExpandedNode = (data.nodes || []).some((node) => {
- const nodeData = getAnalysisNodeData(node);
- return Boolean(nodeData.analysisExpandedNodeIds?.length || nodeData.analysisExpandedNeighborCount);
- });
- const hasExpandedFlag = (data.nodes || []).some((node) => {
- const rawData = (node.data?.rawData || {}) as Record<string, any>;
- return Boolean(rawData.expend || node.data?.expend);
- });
- (data.nodes || []).forEach((node) => {
- const nodeId = String(node.id || '');
- if (nodeId) degreeMap.set(nodeId, 0);
- });
- (data.edges || []).forEach((edge) => {
- const source = String(edge.source || '');
- const target = String(edge.target || '');
- if (!source || !target || source === target) return;
- degreeMap.set(source, (degreeMap.get(source) || 0) + 1);
- degreeMap.set(target, (degreeMap.get(target) || 0) + 1);
- });
- return {
- type: 'force',
- center: ANALYSIS_LAYOUT_MINGJING_CENTER,
- nodeSize: ANALYSIS_LAYOUT_COLLISION_SIZE,
- nodeSpacing: 0,
- preventOverlap: true,
- collideStrength: ANALYSIS_LAYOUT_FORCE_COLLIDE_STRENGTH,
- animate: false,
- gravity: ANALYSIS_LAYOUT_FORCE_GRAVITY,
- factor: ANALYSIS_LAYOUT_FORCE_FACTOR,
- maxIteration: hasExpandedNode ? 150 : 1000,
- linkDistance: (edge: { source?: string; target?: string }, source: { id?: string }, target: { id?: string }) => {
- const sourceDegree = degreeMap.get(String(source.id || '')) || 0;
- const targetDegree = degreeMap.get(String(target.id || '')) || 0;
- const minDegree = Math.min(sourceDegree, targetDegree);
- if (minDegree <= 1) {
- return ANALYSIS_LAYOUT_FORCE_CENTER_LINK_DISTANCE;
- }
- return nodeCount > 5 ? ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_SHORT : ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_LONG;
- },
- nodeStrength: (node: { id?: string }) => {
- if (hasExpandedNode && !hasExpandedFlag) {
- return ANALYSIS_LAYOUT_FORCE_EXPANDED_STRENGTH;
- }
- const degree = degreeMap.get(String(node.id || '')) || 0;
- if (degree === 1) return ANALYSIS_LAYOUT_FORCE_LEAF_STRENGTH;
- if (degree > 0) return degree * ANALYSIS_LAYOUT_FORCE_DEGREE_STRENGTH;
- return 120;
- },
- } as LayoutOptions;
- };
- const syncGraphNodePositionsToData = (data: GraphData, graph: Graph) => {
- const nodePositionMap = new Map<string, { x: number; y: number }>();
- (graph.getNodeData() || []).forEach((node) => {
- const nodeId = String(node.id || '');
- const { x, y } = getGraphElementPositionPoint(graph, nodeId, node.style as any);
- if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) return;
- nodePositionMap.set(nodeId, { x, y });
- });
- (data.nodes || []).forEach((node) => {
- const position = nodePositionMap.get(String(node.id || ''));
- if (!position) return;
- node.style = {
- ...(node.style || {}),
- x: position.x,
- y: position.y,
- };
- });
- };
- const syncRenderedNodePositionsToGraphData = (graph = graphRef.value) => {
- if (!graph) return false;
- if (isLargeGraphData(graph.getData())) return false;
- const updates = (graph.getNodeData() || [])
- .map((node) => {
- const nodeId = String(node.id || '');
- if (!nodeId) return null;
- const { x, y } = getGraphElementPositionPoint(graph, nodeId, node.style as any);
- if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
- const currentX = Number((node.style as any)?.x);
- const currentY = Number((node.style as any)?.y);
- if (Math.abs(currentX - x) < 0.5 && Math.abs(currentY - y) < 0.5) return null;
- return {
- id: node.id,
- style: {
- ...(node.style || {}),
- x,
- y,
- },
- };
- })
- .filter((item): item is { id: NodeData['id']; style: NonNullable<NodeData['style']> } => Boolean(item));
- if (!updates.length) return false;
- graph.updateNodeData(updates);
- return true;
- };
- const getAnalysisLeafRingCenterIds = (
- nodes: NodeData[],
- neighborMap: Map<string, Set<string>>,
- degreeMap: Map<string, number>,
- ) =>
- [...nodes]
- .map((node) => {
- const nodeId = String(node.id || '');
- const leafNeighborIds = Array.from(neighborMap.get(nodeId) || []).filter(
- (neighborId) => (degreeMap.get(neighborId) || 0) <= 1,
- );
- return {
- nodeId,
- node,
- degree: degreeMap.get(nodeId) || 0,
- leafNeighborIds,
- kind: getEntityKind(node.data?.kind).key,
- label: getNodeLabel(node),
- };
- })
- .filter((item) => item.nodeId && item.leafNeighborIds.length >= 3)
- .sort((left, right) => {
- const personDiff = Number(right.kind === 'person') - Number(left.kind === 'person');
- if (personDiff !== 0) return personDiff;
- const degreeDiff = right.degree - left.degree;
- if (degreeDiff !== 0) return degreeDiff;
- const leafDiff = right.leafNeighborIds.length - left.leafNeighborIds.length;
- if (leafDiff !== 0) return leafDiff;
- return left.label.localeCompare(right.label, 'zh-CN');
- })
- .map((item) => item.nodeId);
- const normalizeAnalysisAngle = (angle: number) => {
- if (!Number.isFinite(angle)) return 0;
- let normalizedAngle = angle;
- while (normalizedAngle <= -Math.PI) normalizedAngle += Math.PI * 2;
- while (normalizedAngle > Math.PI) normalizedAngle -= Math.PI * 2;
- return normalizedAngle;
- };
- const getAnalysisGraphDataCenterPoint = (data: GraphData) => {
- const bounds = getGraphDataBounds(data);
- if (![bounds.minX, bounds.minY, bounds.maxX, bounds.maxY].every((value) => Number.isFinite(value))) {
- return getGraphCenterCanvasPoint();
- }
- return {
- x: (bounds.minX + bounds.maxX) / 2,
- y: (bounds.minY + bounds.maxY) / 2,
- };
- };
- const getAnalysisLeafRingNearbyCenters = (
- centerId: string,
- centerPosition: { x: number; y: number },
- centerMetaMap: Map<string, AnalysisLeafRingCenterMeta>,
- ) =>
- Array.from(centerMetaMap.values())
- .filter((item) => item.id !== centerId)
- .map((item) => {
- const deltaX = centerPosition.x - item.position.x;
- const deltaY = centerPosition.y - item.position.y;
- return {
- ...item,
- distance: Math.hypot(deltaX, deltaY),
- deltaX,
- deltaY,
- };
- })
- .filter((item) => item.distance < ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE)
- .sort((left, right) => left.distance - right.distance || right.leafCount - left.leafCount);
- const getAnalysisLeafRingCenterDesiredGap = (left: AnalysisLeafRingCenterMeta, right: AnalysisLeafRingCenterMeta) =>
- Math.min(
- ANALYSIS_LAYOUT_CENTER_CLUSTER_MAX_GAP,
- ANALYSIS_LAYOUT_CENTER_CLUSTER_MIN_GAP +
- Math.max(left.leafCount, right.leafCount) * 5 +
- Math.min(70, ((left.leafCount + right.leafCount) / 2) * 3),
- );
- const separateAnalysisLeafRingCenters = (
- centerMetaMap: Map<string, AnalysisLeafRingCenterMeta>,
- graphCenterPoint: { x: number; y: number },
- ) => {
- const centers = Array.from(centerMetaMap.values());
- if (centers.length < 2) return;
- for (let iteration = 0; iteration < ANALYSIS_LAYOUT_CENTER_CLUSTER_ITERATIONS; iteration += 1) {
- let moved = false;
- for (let leftIndex = 0; leftIndex < centers.length; leftIndex += 1) {
- for (let rightIndex = leftIndex + 1; rightIndex < centers.length; rightIndex += 1) {
- const left = centers[leftIndex];
- const right = centers[rightIndex];
- const deltaX = right.position.x - left.position.x;
- const deltaY = right.position.y - left.position.y;
- const distance = Math.hypot(deltaX, deltaY);
- const desiredGap = getAnalysisLeafRingCenterDesiredGap(left, right);
- if (distance >= desiredGap) continue;
- let directionX = 0;
- let directionY = -1;
- if (distance > 1) {
- directionX = deltaX / distance;
- directionY = deltaY / distance;
- } else {
- const fallbackX = left.position.x - graphCenterPoint.x || (leftIndex % 2 === 0 ? -1 : 1);
- const fallbackY = left.position.y - graphCenterPoint.y || (rightIndex % 2 === 0 ? -1 : 1);
- const fallbackDistance = Math.hypot(fallbackX, fallbackY) || 1;
- directionX = fallbackX / fallbackDistance;
- directionY = fallbackY / fallbackDistance;
- }
- const overlap = desiredGap - distance;
- const shift = Math.min(ANALYSIS_LAYOUT_CENTER_CLUSTER_PUSH_STEP, overlap * 0.38 + 6);
- const leftWeight = 1 + left.leafCount * 0.6 + left.degree * 0.12;
- const rightWeight = 1 + right.leafCount * 0.6 + right.degree * 0.12;
- const totalWeight = leftWeight + rightWeight;
- const leftShift = shift * (rightWeight / totalWeight);
- const rightShift = shift * (leftWeight / totalWeight);
- left.position.x -= directionX * leftShift;
- left.position.y -= directionY * leftShift;
- right.position.x += directionX * rightShift;
- right.position.y += directionY * rightShift;
- moved = true;
- }
- }
- if (!moved) break;
- }
- centers.forEach((center) => {
- center.node.style = {
- ...(center.node.style || {}),
- x: center.position.x,
- y: center.position.y,
- };
- });
- };
- const getAnalysisLeafRingBaseAngle = (
- centerPosition: { x: number; y: number },
- centerId: string,
- neighborMap: Map<string, Set<string>>,
- degreeMap: Map<string, number>,
- nodeMap: Map<string, NodeData>,
- graphCenterPoint: { x: number; y: number },
- nearbyCenters: Array<
- AnalysisLeafRingCenterMeta & {
- distance: number;
- deltaX: number;
- deltaY: number;
- }
- >,
- ) => {
- let baseVectorX = 0;
- let baseVectorY = -1;
- const backboneNeighbors = Array.from(neighborMap.get(centerId) || [])
- .filter((neighborId) => (degreeMap.get(neighborId) || 0) > 1)
- .map((neighborId) => nodeMap.get(neighborId))
- .filter((node): node is NodeData => Boolean(node));
- if (backboneNeighbors.length) {
- const averagePosition = backboneNeighbors.reduce(
- (accumulator, node) => {
- const position = getAnalysisNodePosition(node);
- accumulator.x += position.x;
- accumulator.y += position.y;
- return accumulator;
- },
- { x: 0, y: 0 },
- );
- averagePosition.x /= backboneNeighbors.length;
- averagePosition.y /= backboneNeighbors.length;
- const deltaX = centerPosition.x - averagePosition.x;
- const deltaY = centerPosition.y - averagePosition.y;
- if (Math.hypot(deltaX, deltaY) > 1) {
- baseVectorX = deltaX;
- baseVectorY = deltaY;
- }
- } else {
- const graphDeltaX = centerPosition.x - graphCenterPoint.x;
- const graphDeltaY = centerPosition.y - graphCenterPoint.y;
- if (Math.hypot(graphDeltaX, graphDeltaY) > 1) {
- baseVectorX = graphDeltaX;
- baseVectorY = graphDeltaY;
- }
- }
- let vectorX = baseVectorX;
- let vectorY = baseVectorY;
- nearbyCenters.forEach((item) => {
- const distanceRatio = Math.max(0, ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - item.distance);
- if (!distanceRatio) return;
- const normalizedDeltaX = item.deltaX / Math.max(item.distance, 1);
- const normalizedDeltaY = item.deltaY / Math.max(item.distance, 1);
- const weight =
- (distanceRatio / ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE) * (1.8 + Math.min(1.4, item.leafCount / 8));
- vectorX += normalizedDeltaX * weight;
- vectorY += normalizedDeltaY * weight;
- });
- if (Math.hypot(vectorX, vectorY) > 1) {
- return Math.atan2(vectorY, vectorX);
- }
- if (Math.hypot(baseVectorX, baseVectorY) > 1) {
- return Math.atan2(baseVectorY, baseVectorX);
- }
- return -Math.PI / 2;
- };
- const expandAnalysisLeafRingNodes = (data: GraphData) => {
- const nodes = data.nodes || [];
- const edges = data.edges || [];
- if (!nodes.length || !edges.length) return;
- const nodeMap = new Map(nodes.map((node) => [String(node.id || ''), node]));
- const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
- const occupiedLeafIds = new Set<string>();
- const graphCenterPoint = getAnalysisGraphDataCenterPoint(data);
- const centerMetaMap = new Map<string, AnalysisLeafRingCenterMeta>();
- getAnalysisLeafRingCenterIds(nodes, neighborMap, degreeMap).forEach((centerId) => {
- const centerNode = nodeMap.get(centerId);
- if (!centerNode) return;
- centerMetaMap.set(centerId, {
- id: centerId,
- node: centerNode,
- position: getAnalysisNodePosition(centerNode),
- leafCount: Array.from(neighborMap.get(centerId) || []).filter(
- (neighborId) => (degreeMap.get(neighborId) || 0) <= 1,
- ).length,
- degree: degreeMap.get(centerId) || 0,
- });
- });
- separateAnalysisLeafRingCenters(centerMetaMap, graphCenterPoint);
- Array.from(centerMetaMap.values()).forEach((centerMeta) => {
- const centerId = centerMeta.id;
- const centerPosition = centerMeta.position;
- const nearbyCenters = getAnalysisLeafRingNearbyCenters(centerId, centerPosition, centerMetaMap);
- const crowdingFactor = nearbyCenters.reduce(
- (total, item) =>
- total +
- (ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - item.distance) / ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE,
- 0,
- );
- const closestCenterDistance = nearbyCenters[0]?.distance ?? Number.POSITIVE_INFINITY;
- const dynamicMinSweep = Math.max(
- ANALYSIS_LAYOUT_RING_DENSE_MIN_SWEEP,
- ANALYSIS_LAYOUT_RING_MIN_SWEEP - Math.min(Math.PI / 8, crowdingFactor * 0.14),
- );
- const maxAvailableSweep = Math.max(
- dynamicMinSweep,
- ANALYSIS_LAYOUT_RING_SECTOR_SWEEP - Math.min(Math.PI / 2, crowdingFactor * 0.4),
- );
- const dynamicRadiusBoost = Math.min(
- ANALYSIS_LAYOUT_RING_DENSE_RADIUS_BOOST,
- Math.max(0, ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - closestCenterDistance) * 0.34 + crowdingFactor * 24,
- );
- const baseAngle = getAnalysisLeafRingBaseAngle(
- centerPosition,
- centerId,
- neighborMap,
- degreeMap,
- nodeMap,
- graphCenterPoint,
- nearbyCenters,
- );
- const leafNodes = Array.from(neighborMap.get(centerId) || [])
- .filter((neighborId) => (degreeMap.get(neighborId) || 0) <= 1 && !occupiedLeafIds.has(neighborId))
- .map((neighborId) => nodeMap.get(neighborId))
- .filter((node): node is NodeData => Boolean(node))
- .sort((left, right) => {
- const leftPosition = getAnalysisNodePosition(left);
- const rightPosition = getAnalysisNodePosition(right);
- const leftAngle = normalizeAnalysisAngle(
- Math.atan2(leftPosition.y - centerPosition.y, leftPosition.x - centerPosition.x) - baseAngle,
- );
- const rightAngle = normalizeAnalysisAngle(
- Math.atan2(rightPosition.y - centerPosition.y, rightPosition.x - centerPosition.x) - baseAngle,
- );
- return leftAngle - rightAngle || getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
- });
- if (leafNodes.length < 3) return;
- let cursor = 0;
- let ringIndex = 0;
- while (cursor < leafNodes.length) {
- const radius =
- ANALYSIS_LAYOUT_RING_MIN_RADIUS + dynamicRadiusBoost + ringIndex * ANALYSIS_EXPAND_RING_RADIUS_STEP;
- const maxRingCount = Math.max(5, Math.floor((maxAvailableSweep * radius) / ANALYSIS_EXPAND_ARC_GAP));
- const ringNodes = leafNodes.slice(cursor, cursor + maxRingCount);
- const desiredSweep = ringNodes.length > 1 ? ((ringNodes.length - 1) * ANALYSIS_EXPAND_ARC_GAP) / radius : 0;
- const ringSweep = Math.min(maxAvailableSweep, Math.max(dynamicMinSweep, desiredSweep));
- ringNodes.forEach((node, index) => {
- const nodeId = String(node.id || '');
- occupiedLeafIds.add(nodeId);
- const angle =
- ringNodes.length === 1
- ? baseAngle
- : baseAngle - ringSweep / 2 + (ringSweep * (index + 1)) / (ringNodes.length + 1);
- node.style = {
- ...(node.style || {}),
- x: centerPosition.x + Math.cos(angle) * radius,
- y: centerPosition.y + Math.sin(angle) * radius,
- };
- });
- cursor += ringNodes.length;
- ringIndex += 1;
- }
- });
- };
- const applyAnalysisLayoutPositions = (data: GraphData, positionMap: Map<string, { x: number; y: number }>) => {
- (data.nodes || []).forEach((node) => {
- const position = positionMap.get(String(node.id));
- if (!position) return;
- node.style = {
- ...(node.style || {}),
- x: position.x,
- y: position.y,
- };
- });
- };
- const applyPersonNeighborClusterLayout = (data: GraphData, positionMap: Map<string, { x: number; y: number }>) => {
- const nodes = data.nodes || [];
- const edges = data.edges || [];
- if (!nodes.length || !edges.length) return positionMap;
- const nodeMap = new Map(nodes.map((node) => [String(node.id), node]));
- const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
- const centers = [...nodes]
- .map((node) => {
- const id = String(node.id || '');
- return {
- id,
- degree: degreeMap.get(id) || 0,
- };
- })
- .filter((item) => item.id && item.degree >= PERSON_NEIGHBOR_CLUSTER_MIN_HUB_DEGREE && positionMap.has(item.id))
- .sort((left, right) => right.degree - left.degree);
- const centerIdSet = new Set(centers.map((item) => item.id));
- const clusterMap = new Map<string, NodeData[]>();
- nodes
- .filter((node) => getEntityKind(node.data?.kind).key === 'person' && positionMap.has(String(node.id)))
- .forEach((node) => {
- const nodeId = String(node.id);
- const nodePosition = positionMap.get(nodeId);
- if (!nodePosition) return;
- const closestCenter = Array.from(neighborMap.get(nodeId) || [])
- .filter((centerId) => centerIdSet.has(centerId) && positionMap.has(centerId))
- .map((centerId) => {
- const centerPosition = positionMap.get(centerId)!;
- const distance = Math.hypot(centerPosition.x - nodePosition.x, centerPosition.y - nodePosition.y);
- return {
- id: centerId,
- distance,
- degree: degreeMap.get(centerId) || 0,
- };
- })
- .sort((left, right) => left.distance - right.distance || right.degree - left.degree)[0];
- if (!closestCenter) return;
- const cluster = clusterMap.get(closestCenter.id) || [];
- cluster.push(node);
- clusterMap.set(closestCenter.id, cluster);
- });
- centers.forEach((center) => {
- const centerPosition = positionMap.get(center.id);
- const personNeighbors = clusterMap.get(center.id) || [];
- if (!centerPosition) return;
- if (personNeighbors.length < PERSON_NEIGHBOR_CLUSTER_MIN_COUNT) return;
- personNeighbors.sort((left, right) => {
- const leftDegree = degreeMap.get(String(left.id)) || 0;
- const rightDegree = degreeMap.get(String(right.id)) || 0;
- return rightDegree - leftDegree || String(left.id).localeCompare(String(right.id));
- });
- personNeighbors.forEach((node, index) => {
- const nodeId = String(node.id);
- const ringIndex = Math.floor(index / 12);
- const indexInRing = index % 12;
- const ringSize = Math.min(12, personNeighbors.length - ringIndex * 12);
- const radius =
- PERSON_NEIGHBOR_CLUSTER_RADIUS +
- ringIndex * Math.max(PERSON_NEIGHBOR_CLUSTER_RING_STEP, PERSON_NEIGHBOR_CLUSTER_SPACING);
- const angle = -Math.PI / 2 + (Math.PI * 2 * indexInRing) / Math.max(ringSize, 1);
- positionMap.set(nodeId, {
- x: centerPosition.x + Math.cos(angle) * radius,
- y: centerPosition.y + Math.sin(angle) * radius,
- });
- });
- });
- return positionMap;
- };
- const applyAnalysisRelationLayout = async (data: GraphData, options: RelationLayoutOptions = {}) => {
- const nodes = data.nodes || [];
- if (!nodes.length) return getGraphDataBounds(data);
- const payload = buildRelationLayoutPayload(data, { ...options, preset: 'mingjing-g6' });
- let draft: AnalysisLayoutDraft | null = null;
- let positionMap = new Map<string, { x: number; y: number }>();
- try {
- const layoutResult = await resolveAnalysisLayoutPositions(data, payload, { ...options, preset: 'mingjing-g6' });
- draft = layoutResult.draft;
- positionMap = layoutResult.positions;
- } catch (error) {
- console.warn('relation force layout fallback to deterministic layout', error);
- }
- if (!positionMap.size) {
- draft = draft || buildFallbackAnalysisNodePositions(data, { ...options, preset: 'mingjing-g6' });
- positionMap = draft.positions;
- }
- positionMap = relaxAnalysisNodePositions(positionMap, payload, draft?.fixedIds || new Set<string>());
- applyAnalysisLayoutPositions(data, positionMap);
- return getGraphDataBounds(data);
- };
- const buildAnalysisGraphData = (response: GraphAnalysisResponse): GraphData => {
- const graphData = response?.data || { dx: [], gx: [] };
- const rawNodes = Array.isArray(graphData.dx) ? graphData.dx : [];
- const rawEdges = Array.isArray(graphData.gx) ? graphData.gx : [];
- const nodeMap = new Map<string, NodeData>();
- const exactTokenToKey = new Map<string, string>();
- const registerNode = (node: NodeData, exactTokens: Array<string | number | null | undefined>) => {
- const nodeId = String(node.id);
- if (!nodeId) return;
- if (!nodeMap.has(nodeId)) {
- nodeMap.set(nodeId, node);
- }
- exactTokens
- .map((item) => normalizeLookupKey(item))
- .filter(Boolean)
- .forEach((token) => {
- if (!exactTokenToKey.has(token)) {
- exactTokenToKey.set(token, nodeId);
- }
- });
- };
- rawNodes.forEach((rawNode) => {
- const primaryToken = rawNode.bm || rawNode.sfz || rawNode.mc || rawNode.id;
- const key = normalizeLookupKey(primaryToken);
- if (!key) return;
- const kind =
- getEntityKindByClazzPositionToken(rawNode.class || rawNode.clazz) ||
- inferEntityKindFromToken(rawNode.mc || rawNode.bm || rawNode.sfz || rawNode.id);
- const fullLabel = getAnalysisNodeFullLabel(rawNode, kind);
- registerNode(
- {
- id: key,
- data: {
- kind,
- label: fullLabel,
- secondaryLabel: getAnalysisNodeSecondaryLabel(rawNode, kind),
- fullLabel,
- rawData: rawNode,
- isAnalysis: true,
- },
- },
- [rawNode.bm, rawNode.sfz, rawNode.id, rawNode.dxId, rawNode.dxId0, rawNode.fzId, rawNode.mc],
- );
- });
- const resolveNodeKey = (token?: string | null, preferredKind?: EntityKindKey | null) => {
- const lookupKey = normalizeLookupKey(token);
- if (!lookupKey) return '';
- const existingKey = exactTokenToKey.get(lookupKey);
- if (existingKey) return existingKey;
- const virtualNode = createAnalysisNodeFromToken(String(token || ''), preferredKind);
- if (!virtualNode) return '';
- registerNode(virtualNode, [token, lookupKey]);
- return String(virtualNode.id);
- };
- const edges = normalizeGraphEdgeCurves(
- rawEdges
- .map((rawEdge, index) => {
- const { sourceKind, targetKind } = getEdgeEndpointKindsByClazz(rawEdge.clazz);
- const source = resolveNodeKey(rawEdge.dx1, sourceKind);
- const target = resolveNodeKey(rawEdge.dx2, targetKind);
- if (!source || !target || source === target) return null;
- return {
- id: rawEdge.id || `analysis-edge-${index}`,
- source,
- target,
- type: 'line',
- data: {
- label: buildAnalysisEdgeLabel(rawEdge),
- rawData: rawEdge,
- isAnalysis: true,
- },
- } as EdgeData;
- })
- .filter((edge): edge is EdgeData => Boolean(edge)),
- );
- const nodes = Array.from(nodeMap.values());
- const shouldUsePerformanceLabels = isLargeGraphData({ nodes, edges, combos: [] });
- const edgeLabelLimit = shouldUsePerformanceLabels
- ? getBoundedLimit(ANALYSIS_PERFORMANCE_EDGE_LABEL_LIMIT, edges.length)
- : 0;
- nodes.forEach((node, index) => {
- node.data = {
- ...(node.data || {}),
- performanceLabel: getNodeLabel(node),
- performanceSecondaryLabel: getNodeSecondaryLabel(node),
- };
- });
- edges.forEach((edge, index) => {
- edge.data = {
- ...(edge.data || {}),
- performanceLabel: index < edgeLabelLimit ? getEdgeLabel(edge) : '',
- };
- });
- return { nodes, edges, combos: [] };
- };
- const normalizeGraphData = (data: GraphData): Required<GraphData> => ({
- nodes: data.nodes || [],
- edges: data.edges || [],
- combos: data.combos || [],
- });
- const createSnapshotFromData = (data: GraphData) => JSON.stringify(normalizeGraphData(data));
- const serializeGraph = () => {
- syncRenderedNodePositionsToGraphData();
- return createSnapshotFromData(graphRef.value?.getData() || { nodes: [], edges: [], combos: [] });
- };
- const cloneGraphData = (data: GraphData): Required<GraphData> =>
- JSON.parse(JSON.stringify(normalizeGraphData(data))) as Required<GraphData>;
- const getAnalysisResponseCounts = (response?: GraphAnalysisResponse | null) => {
- const graphData = (response as any)?.data ?? response;
- return {
- nodeCount: Array.isArray(graphData?.dx) ? graphData.dx.length : 0,
- edgeCount: Array.isArray(graphData?.gx) ? graphData.gx.length : 0,
- };
- };
- const syncActiveCanvasAnalysisTotals = () => {
- const current = activeGraphCanvas.value;
- if (!current) return;
- current.analysisTotalNodeCount = analysisTotalNodeCount.value;
- current.analysisTotalEdgeCount = analysisTotalEdgeCount.value;
- };
- const setAnalysisTotalCounts = (nodeCount = 0, edgeCount = 0) => {
- analysisTotalNodeCount.value = Math.max(0, Number(nodeCount) || 0);
- analysisTotalEdgeCount.value = Math.max(0, Number(edgeCount) || 0);
- syncActiveCanvasAnalysisTotals();
- };
- const clearAnalysisTotalCounts = () => {
- setAnalysisTotalCounts(0, 0);
- };
- const restoreAnalysisTotalCountsFromActiveCanvas = () => {
- setAnalysisTotalCounts(
- Number(activeGraphCanvas.value?.analysisTotalNodeCount || 0),
- Number(activeGraphCanvas.value?.analysisTotalEdgeCount || 0),
- );
- };
- const markActiveCanvasDirty = (dirty = true) => {
- const current = activeGraphCanvas.value;
- if (current) current.dirty = dirty;
- };
- const getFallbackZoomValue = () => {
- const zoomValue = Number.parseFloat(zoomLabel.value);
- return Number.isFinite(zoomValue) && zoomValue > 0 ? zoomValue / 100 : 1;
- };
- const getGraphZoomValue = (graph = graphRef.value) => {
- if (!graph || typeof (graph as any).getZoom !== 'function') return getFallbackZoomValue();
- try {
- const zoom = (graph as any).getZoom();
- return Number.isFinite(zoom) && zoom > 0 ? zoom : getFallbackZoomValue();
- } catch {
- return getFallbackZoomValue();
- }
- };
- const refreshZoomLabel = () => {
- zoomLabel.value = `${Math.round(getGraphZoomValue() * 100)}%`;
- };
- const getGraphLayoutPassCount = (data: GraphData) =>
- isLargeGraphData(data) ? LARGE_GRAPH_LAYOUT_PASS_COUNT : DEFAULT_GRAPH_LAYOUT_PASS_COUNT;
- const getLargeGraphFocusNodeIds = (data: GraphData) => {
- const nodes = data.nodes || [];
- const edges = data.edges || [];
- const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
- const degreeRecord = getAnalysisGraphDegreeRecord(nodes, degreeMap);
- const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord);
- const sortedNodeIds = sortRelationLayoutCandidates(nodes, degreeRecord).map((node) => String(node.id));
- const seedNeighborIds = seedIds.flatMap((seedId) =>
- Array.from(neighborMap.get(seedId) || []).sort((left, right) => {
- const degreeDiff = (degreeMap.get(right) || 0) - (degreeMap.get(left) || 0);
- return degreeDiff || left.localeCompare(right, 'zh-CN');
- }),
- );
- return uniqueValues([...seedIds, ...seedNeighborIds, ...sortedNodeIds]).slice(0, LARGE_GRAPH_VIEW_FOCUS_LIMIT);
- };
- const focusLargeGraphInitialView = async (data: GraphData) => {
- const graph = graphRef.value;
- if (!graph) return false;
- const anchorNodeId = getLargeGraphFocusNodeIds(data).find((nodeId) => {
- try {
- return Boolean(graph.getNodeData(nodeId));
- } catch {
- return false;
- }
- });
- if (!anchorNodeId) return false;
- await graph.focusElement(anchorNodeId, false);
- if (getGraphZoomValue(graph) < LARGE_GRAPH_DEFAULT_FOCUS_ZOOM) {
- await graph.zoomTo(LARGE_GRAPH_DEFAULT_FOCUS_ZOOM, false);
- await graph.focusElement(anchorNodeId, false);
- }
- refreshZoomLabel();
- return true;
- };
- const fitGraphViewForData = async (
- data?: GraphData,
- animation: boolean | { duration: number } = { duration: 260 },
- options: { focusLargeGraph?: boolean } = {},
- ) => {
- const graph = graphRef.value;
- if (!graph) return;
- const normalizedData = normalizeGraphData(data || graph.getData());
- if (!normalizedData.nodes.length && !normalizedData.edges.length) {
- refreshZoomLabel();
- return;
- }
- if (isLargeGraphData(normalizedData)) {
- await graph.fitView({ when: 'always' }, false);
- if (options.focusLargeGraph && (await focusLargeGraphInitialView(normalizedData))) {
- return;
- }
- refreshZoomLabel();
- return;
- }
- await graph.fitView({ when: 'always' }, animation);
- refreshZoomLabel();
- };
- const refreshGraphStats = (data: GraphData) => {
- const normalizedData = normalizeGraphData(data);
- graphStats.nodes = normalizedData.nodes.length;
- graphStats.edges = normalizedData.edges.length;
- graphStats.combos = normalizedData.combos.length;
- };
- const refreshGraphSnapshot = (markDirty = false) => {
- const graph = graphRef.value;
- if (!graph) return;
- if (graphInteractionMoving.value) {
- if (markDirty) markActiveCanvasDirty(true);
- refreshZoomLabel();
- return;
- }
- const data = normalizeGraphData(graph.getData());
- refreshGraphStats(data);
- if (isLargeGraphData(data)) {
- serializedGraphData.value = JSON.stringify(
- {
- performanceMode: true,
- total: {
- nodes: data.nodes.length,
- edges: data.edges.length,
- combos: data.combos.length,
- },
- preview: {
- nodes: data.nodes.slice(0, SNAPSHOT_PREVIEW_NODE_LIMIT),
- edges: data.edges.slice(0, SNAPSHOT_PREVIEW_EDGE_LIMIT),
- },
- },
- null,
- 2,
- );
- } else {
- serializedGraphData.value = JSON.stringify(data, null, 2);
- }
- refreshZoomLabel();
- if (markDirty) markActiveCanvasDirty(true);
- };
- const saveActiveCanvasState = () => {
- const current = activeGraphCanvas.value;
- const graph = graphRef.value;
- if (!current || !graph) return;
- current.snapshot = serializeGraph();
- current.zoom = getGraphZoomValue(graph);
- current.analysisTotalNodeCount = analysisTotalNodeCount.value;
- current.analysisTotalEdgeCount = analysisTotalEdgeCount.value;
- current.dirty = false;
- };
- const pushUndoSnapshot = () => {
- const snapshot = serializeGraph();
- undoStack.value = [...undoStack.value, snapshot].slice(-MAX_UNDO_STACK);
- redoStack.value = [];
- };
- const restoreGraphSnapshot = async (snapshot: string, fitView = true) => {
- const graph = graphRef.value;
- if (!graph) return;
- let data: Required<GraphData>;
- try {
- data = normalizeGraphDataEdgeCurves(normalizeGraphData(JSON.parse(snapshot || EMPTY_GRAPH_SNAPSHOT)));
- } catch {
- data = normalizeGraphData({ nodes: [], edges: [], combos: [] });
- }
- graphPerformanceMode.value = isLargeGraphData(data);
- applyGraphInteractionOptions();
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- graph.setCombo(createComboOptions() as any);
- clearGraphLayout(graph);
- graph.setData(data);
- await graph.render();
- if (fitView && (data.nodes.length || data.edges.length)) {
- await fitGraphViewForData(data, false, { focusLargeGraph: isLargeGraphData(data) });
- }
- clearLocalSelection();
- refreshGraphSnapshot(false);
- restoreAnalysisTotalCountsFromActiveCanvas();
- };
- const withGraphMutation = async (mutation: () => void | Promise<void>) => {
- pushUndoSnapshot();
- await mutation();
- refreshGraphSnapshot();
- saveActiveCanvasState();
- };
- const syncSelection = () => {
- const graph = graphRef.value;
- if (!graph) return;
- selectedNodeIds.value = graph.getElementDataByState('node', 'selected').map((node) => String(node.id));
- selectedEdgeIds.value = graph
- .getElementDataByState('edge', 'selected')
- .map((edge) => (edge.id ? String(edge.id) : ''))
- .filter(Boolean);
- selectedComboIds.value = graph.getElementDataByState('combo', 'selected').map((combo) => String(combo.id));
- const activeNode = selectedNodeIds.value[0] ? findNodeData(selectedNodeIds.value[0]) : null;
- const activeEdge = !activeNode && selectedEdgeIds.value[0] ? findEdgeData(selectedEdgeIds.value[0]) : null;
- const activeCombo =
- !activeNode && !activeEdge && selectedComboIds.value[0] ? findComboData(selectedComboIds.value[0]) : null;
- if (activeNode) {
- activeElement.value = {
- id: String(activeNode.id),
- label: getNodeLabel(activeNode),
- type: 'node',
- typeLabel: getEntityKind(activeNode.data?.kind).label,
- };
- } else if (activeEdge) {
- activeElement.value = {
- id: String(activeEdge.id),
- label: getEdgeLabel(activeEdge),
- type: 'edge',
- typeLabel: '关系',
- };
- } else if (activeCombo) {
- activeElement.value = {
- id: String(activeCombo.id),
- label: getComboLabel(activeCombo),
- type: 'combo',
- typeLabel: '分组',
- };
- } else {
- activeElement.value = null;
- }
- refreshZoomLabel();
- };
- const clearSelection = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const states: Record<string, string[]> = {};
- graph.getData().nodes?.forEach((node) => {
- states[String(node.id)] = [];
- });
- graph.getData().edges?.forEach((edge) => {
- if (edge.id) states[String(edge.id)] = [];
- });
- graph.getData().combos?.forEach((combo) => {
- states[String(combo.id)] = [];
- });
- await graph.setElementState(states, false);
- syncSelection();
- };
- const clearLocalSelection = () => {
- selectedNodeIds.value = [];
- selectedEdgeIds.value = [];
- selectedComboIds.value = [];
- activeElement.value = null;
- };
- const setManyElementSelected = async (ids: string[]) => {
- const graph = graphRef.value;
- if (!graph) return;
- const states = ids.reduce<Record<string, string[]>>((accumulator, id) => {
- accumulator[id] = ['selected'];
- return accumulator;
- }, {});
- await graph.setElementState(states, false);
- };
- const getEventElementId = (event: IElementEvent) => String(event.target?.id || '');
- const getGraphCenterCanvasPoint = () => {
- const graph = graphRef.value;
- const container = graphContainerRef.value;
- if (!graph || !container) return { x: 0, y: 0 };
- const rect = container.getBoundingClientRect();
- const [x, y] = graph.getCanvasByClient([rect.left + rect.width / 2, rect.top + rect.height / 2]);
- return { x, y };
- };
- const getContextMenuCanvasPoint = (event: IElementEvent) => {
- const graph = graphRef.value;
- const eventData = event as any;
- if (typeof eventData.canvas?.x === 'number' && typeof eventData.canvas?.y === 'number') {
- return { x: eventData.canvas.x, y: eventData.canvas.y };
- }
- if (typeof eventData.canvasX === 'number' && typeof eventData.canvasY === 'number') {
- return { x: eventData.canvasX, y: eventData.canvasY };
- }
- const clientX =
- typeof eventData.client?.x === 'number'
- ? eventData.client.x
- : typeof eventData.clientX === 'number'
- ? eventData.clientX
- : undefined;
- const clientY =
- typeof eventData.client?.y === 'number'
- ? eventData.client.y
- : typeof eventData.clientY === 'number'
- ? eventData.clientY
- : undefined;
- if (graph && clientX !== undefined && clientY !== undefined) {
- const [x, y] = graph.getCanvasByClient([clientX, clientY]);
- return { x, y };
- }
- return getGraphCenterCanvasPoint();
- };
- const captureContextMenuState = (event: IElementEvent) => {
- const position = getContextMenuCanvasPoint(event);
- contextMenuState.value = {
- id: getEventElementId(event),
- type: event.targetType,
- canvasX: position.x,
- canvasY: position.y,
- };
- return contextMenuState.value;
- };
- const resolveElementKind = (elementId: string, fallback?: IElementEvent['targetType']): ElementKind | null => {
- if (fallback === 'node' || fallback === 'edge' || fallback === 'combo') return fallback;
- if (findNodeData(elementId)) return 'node';
- if (findEdgeData(elementId)) return 'edge';
- if (findComboData(elementId)) return 'combo';
- return null;
- };
- const getNodeFlowEdges = (nodeId: string, direction: 'incoming' | 'outgoing') => {
- const graph = graphRef.value;
- if (!graph || !nodeId) return [];
- return graph
- .getEdgeData()
- .filter((edge) => (direction === 'incoming' ? String(edge.target) === nodeId : String(edge.source) === nodeId));
- };
- const canOpenAnalysisDetail = (elementId: string, kind: ElementKind | null) => {
- if (!elementId || !kind) return false;
- if (kind === 'node') return Boolean(findNodeData(elementId));
- if (kind === 'combo') return Boolean(findComboData(elementId));
- const edge = findEdgeData(elementId);
- if (!edge) return false;
- const rawData = (edge.data?.rawData || {}) as Record<string, any>;
- const relationTexts = [edge.data?.label, rawData.mc, rawData.sm, rawData.clazz]
- .map((item) => String(item || '').trim())
- .filter(Boolean);
- return !relationTexts.some((text) => text.includes('持卡人') || text.includes('机主'));
- };
- const canShowAssociativeObject = (nodeId: string) => {
- const graph = graphRef.value;
- const node = findNodeData(nodeId);
- if (!graph || !node || String(node.combo || '')) return false;
- const rawData = (node.data?.rawData || {}) as Record<string, any>;
- const rawClass = String(rawData.class || rawData.clazz || '').trim();
- const kind = getEntityKind(node.data?.kind).key;
- if (!node.data?.isAnalysis || !['phone', 'bank'].includes(kind)) return false;
- const relatedEdges = graph
- .getEdgeData()
- .filter((edge) => String(edge.source) === nodeId || String(edge.target) === nodeId);
- if (!relatedEdges.length) return false;
- const expectedClazz = kind === 'phone' || ['tel_gt', 'tel'].includes(rawClass) ? 'tel_tel' : 'card_card';
- return relatedEdges.every((edge) => {
- const rawEdge = (edge.data?.rawData || {}) as Record<string, any>;
- const edgeClazz = String(rawEdge.clazz || '').trim();
- return !edgeClazz || edgeClazz === expectedClazz;
- });
- };
- const isAnalysisNode = (node?: NodeData | null) => Boolean(node?.data?.isAnalysis);
- const isAnalysisEdge = (edge?: EdgeData | null) => Boolean(edge?.data?.isAnalysis);
- const isObjAnalysisNode = (node?: NodeData | null) => {
- if (!isAnalysisNode(node)) return false;
- const rawNode = (node?.data?.rawData || {}) as GraphAnalysisNode;
- const rawClass = String(rawNode.class || rawNode.clazz || '')
- .trim()
- .toLowerCase();
- return rawClass === 'obj';
- };
- const isPhoneAnalysisNode = (node?: NodeData | null) => {
- if (!isAnalysisNode(node)) return false;
- const rawNode = (node?.data?.rawData || {}) as GraphAnalysisNode;
- const rawClass = String(rawNode.class || rawNode.clazz || '')
- .trim()
- .toLowerCase();
- return rawClass === 'tel' || getEntityKind(node?.data?.kind).key === 'phone';
- };
- const shouldFetchNodeDetail = (node?: NodeData | null) => isAnalysisNode(node) && !isObjAnalysisNode(node);
- const buildGraphNodeDetailQuery = (node: NodeData): GraphNodeDetailQuery | null => {
- const rawNode = (node.data?.rawData || {}) as GraphAnalysisNode;
- const kind = getEntityKind(node.data?.kind).key;
- const clazz = normalizeGraphDetailText(rawNode?.clazz ?? rawNode?.class) || getAnalysisClassByKind(kind);
- const nodeToken =
- normalizeGraphDetailText(rawNode?.node) ||
- normalizeGraphDetailText(rawNode?.bm) ||
- normalizeGraphDetailText(rawNode?.id) ||
- normalizeGraphDetailText(rawNode?.dxId);
- if (!nodeToken || !clazz) return null;
- const lxValue = rawNode?.lx;
- const fxValue = rawNode?.fx;
- const lx =
- typeof lxValue === 'number' && Number.isFinite(lxValue)
- ? lxValue
- : Number.isFinite(Number(lxValue))
- ? Number(lxValue)
- : 0;
- const fx =
- typeof fxValue === 'number' && Number.isFinite(fxValue)
- ? fxValue
- : Number.isFinite(Number(fxValue))
- ? Number(fxValue)
- : 0;
- return {
- node: nodeToken,
- clazz,
- lx,
- fx,
- };
- };
- const getEdgeEndpointNodeData = (edge: EdgeData, endpoint: 'source' | 'target') =>
- findNodeData(String(endpoint === 'source' ? edge.source : edge.target));
- const getEdgeEndpointName = (edge: EdgeData, endpoint: 'source' | 'target') => {
- const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
- const endpointNode = getEdgeEndpointNodeData(edge, endpoint);
- if (endpoint === 'source') {
- return String(
- edge.data?.sourceName || getNodeLabel(endpointNode) || rawEdge.obj1_name || rawEdge.dx1 || '未知源节点',
- );
- }
- return String(
- edge.data?.targetName || getNodeLabel(endpointNode) || rawEdge.obj2_name || rawEdge.dx2 || '未知目标节点',
- );
- };
- const isCommunicationAnalysisEdge = (edge?: EdgeData | null) => {
- if (!isAnalysisEdge(edge)) return false;
- const rawEdge = (edge?.data?.rawData || {}) as GraphAnalysisEdge;
- return normalizeAnalysisEdgeClazz(rawEdge.clazz).includes('tel');
- };
- const buildGraphTransDetailQueryByEdge = (edge: EdgeData) => {
- const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
- const personCardNo = normalizeGraphDetailText(rawEdge?.dx1);
- const targetToken = normalizeGraphDetailText(rawEdge?.dx2);
- return buildGraphTransDetailQuery(routeGraphQuery.value, {
- personCardNo,
- otherCardNo: targetToken,
- lx:
- typeof rawEdge?.lx === 'number'
- ? rawEdge.lx
- : normalizeGraphDetailText(rawEdge?.lx)
- ? Number(rawEdge.lx)
- : undefined,
- }) as GraphTransDetailBaseQuery;
- };
- const fetchGraphTransDetailRecords = (query: GraphTransDetailBaseQuery) =>
- fetchGraphTransDetail(query as GraphTransDetailQuery);
- const normalizeGraphCallDetailQuery = (query: GraphCallDetailBaseQuery): GraphCallDetailQuery => {
- const normalizedQuery = {
- ...(query as GraphCallDetailQuery),
- };
- const startDate = normalizeGraphDetailText(query.startDate ?? query.date1);
- const endDate = normalizeGraphDetailText(query.endDate ?? query.date2);
- normalizedQuery.startDate = startDate || undefined;
- normalizedQuery.endDate = endDate || undefined;
- normalizedQuery.date1 = startDate || normalizedQuery.date1;
- normalizedQuery.date2 = endDate || normalizedQuery.date2;
- return normalizedQuery;
- };
- const fetchGraphCallDetailRecords = (query: GraphCallDetailBaseQuery) =>
- fetchGraphCallDetail(normalizeGraphCallDetailQuery(query));
- const openCommunicationCallDetail = (target: NodeData | EdgeData, kind: ElementKind) => {
- if (kind === 'node') {
- const node = target as NodeData;
- if (!isPhoneAnalysisNode(node)) return false;
- const rawNode = (node.data?.rawData || {}) as GraphAnalysisNode;
- const phoneNo = normalizeGraphDetailText(rawNode?.bm);
- if (!rawNode || !phoneNo) {
- message.warning('当前通信节点缺少可查询的号码');
- return false;
- }
- transDetailVisible.value = false;
- analysisDetailVisible.value = false;
- callDetailTitle.value = `${String(node.data?.fullLabel || node.data?.label || phoneNo)} 通信详情`;
- callDetailBaseQuery.value = buildGraphCallDetailQuery(
- routeGraphQuery.value,
- {},
- rawNode,
- ) as GraphCallDetailBaseQuery;
- callDetailVisible.value = true;
- return true;
- }
- const edge = target as EdgeData;
- if (!isCommunicationAnalysisEdge(edge)) return false;
- const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
- const sourceName = getEdgeEndpointName(edge, 'source');
- const targetName = getEdgeEndpointName(edge, 'target');
- transDetailVisible.value = false;
- analysisDetailVisible.value = false;
- callDetailTitle.value = `${sourceName} -> ${targetName} 通信详情`;
- callDetailBaseQuery.value = buildGraphCallDetailQuery(
- routeGraphQuery.value,
- {},
- null,
- rawEdge,
- ) as GraphCallDetailBaseQuery;
- callDetailVisible.value = true;
- return true;
- };
- const openTradeEdgeTransDetail = (edge: EdgeData) => {
- if (!isAnalysisEdge(edge)) return false;
- const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
- if (!isTradeAnalysisEdgeClazz(rawEdge.clazz)) return false;
- const personCardNo = normalizeGraphDetailText(rawEdge.dx1);
- const otherCardNo = normalizeGraphDetailText(rawEdge.dx2);
- if (!personCardNo || !otherCardNo) return false;
- const sourceName = getEdgeEndpointName(edge, 'source');
- const targetName = getEdgeEndpointName(edge, 'target');
- callDetailVisible.value = false;
- analysisDetailVisible.value = false;
- transDetailTitle.value = `${sourceName} -> ${targetName} 交易详情`;
- transDetailBaseQuery.value = buildGraphTransDetailQueryByEdge(edge);
- transDetailVisible.value = true;
- return true;
- };
- const getSelectedNodeIdsForContext = (nodeId?: string) => {
- if (!nodeId) return [...selectedNodeIds.value];
- if (selectedNodeIds.value.includes(nodeId)) return [...selectedNodeIds.value];
- return [nodeId];
- };
- const hasUngroupTarget = (elementId: string, kind: ElementKind | null) => {
- if (kind === 'combo' && elementId) return true;
- if (kind === 'node') {
- const node = findNodeData(elementId);
- return Boolean(node?.combo);
- }
- return Boolean(selectedComboIds.value.length);
- };
- const canCollapseExpandedAnalysisNode = (nodeId: string) => {
- const node = findNodeData(nodeId);
- return Boolean(node && (getAnalysisExpandedNeighborCount(node) > 0 || getAnalysisExpandedNodeIds(node).length));
- };
- const getContextMenuItems = (event: IElementEvent): ContextMenuItem[] => {
- const state = captureContextMenuState(event);
- const elementId = state.id;
- const kind = resolveElementKind(elementId, state.type);
- const hasClipboard = Boolean(graphClipboard.value?.nodes.length);
- if (kind === 'edge') {
- return [
- ...(canOpenAnalysisDetail(elementId, kind) ? [{ name: '查看详情', value: 'analysisDetail' as const }] : []),
- ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
- { name: '删除关系', value: 'delete' },
- ];
- }
- if (kind === 'combo') {
- return [
- ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
- { name: '拆分实体', value: 'ungroup' },
- { name: '删除组合', value: 'delete' },
- ];
- }
- if (kind === 'node') {
- const contextNodeIds = getSelectedNodeIdsForContext(elementId);
- const incomingEdges = getNodeFlowEdges(elementId, 'incoming');
- const outgoingEdges = getNodeFlowEdges(elementId, 'outgoing');
- return [
- ...(canOpenAnalysisDetail(elementId, kind) ? [{ name: '查看详情', value: 'analysisDetail' as const }] : []),
- { name: '开始连线', value: 'startEdge' },
- ...(incomingEdges.length ? [{ name: '仅看流入', value: 'onlyLookFlowIn' as const }] : []),
- ...(outgoingEdges.length ? [{ name: '仅看流出', value: 'onlyLookFlowOut' as const }] : []),
- ...(canShowAssociativeObject(elementId) ? [{ name: '关联对象', value: 'associativeObject' as const }] : []),
- {
- name: contextNodeIds.length > 1 ? `复制实体(${contextNodeIds.length})` : '复制实体',
- value: 'copy',
- },
- ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
- {
- name: contextNodeIds.length > 1 ? `删除实体(${contextNodeIds.length})` : '删除实体',
- value: 'delete',
- },
- ...(contextNodeIds.length > 1 ? [{ name: '组合实体', value: 'group' as const }] : []),
- ...(hasUngroupTarget(elementId, kind) ? [{ name: '拆分实体', value: 'ungroup' as const }] : []),
- { name: '复制到新画布', value: 'newCanvas' },
- ];
- }
- return [
- ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
- { name: '新增人员', value: 'createPerson' },
- { name: '适配视图', value: 'fitView' },
- ];
- };
- const createContextMenuPlugin = () => ({
- type: 'contextmenu',
- key: 'g6-editor-contextmenu',
- className: 'g6-editor-contextmenu',
- getItems: (event: IElementEvent) => getContextMenuItems(event),
- onClick: (value: string, _target: HTMLElement, current: any) => {
- void handleContextMenuClick(value as ContextMenuActionKey, current);
- },
- });
- const createGraphPlugins = () => [
- {
- type: 'grid-line',
- size: 24,
- stroke: '#e2e8f0',
- lineWidth: 1,
- },
- createContextMenuPlugin(),
- ];
- const createPerformanceGraphPlugins = () => [createContextMenuPlugin()];
- const scheduleSyncSelection = () => {
- window.setTimeout(syncSelection, 0);
- };
- const isBrushSelectInteraction = (event: any) => Boolean(event?.shiftKey || event?.nativeEvent?.shiftKey);
- const isElementDragInteraction = (event: any) =>
- ['node', 'combo'].includes(String(event?.targetType || '')) && !isBrushSelectInteraction(event);
- const createGraphBehaviors = (performanceMode = false) =>
- [
- {
- type: 'drag-canvas',
- enable: (event: any) => event?.targetType === 'canvas' && !isBrushSelectInteraction(event),
- },
- 'zoom-canvas',
- performanceMode
- ? {
- type: 'drag-element',
- dropEffect: 'move',
- hideEdge: 'both',
- animation: false,
- shadow: true,
- enable: isElementDragInteraction,
- }
- : { type: 'drag-element', dropEffect: 'move', state: 'selected', enable: isElementDragInteraction },
- {
- type: 'click-select',
- multiple: true,
- trigger: ['shift'],
- state: 'selected',
- animation: false,
- onClick: scheduleSyncSelection,
- },
- {
- type: 'brush-select',
- trigger: ['shift'],
- state: 'selected',
- animation: false,
- enableElements: performanceMode ? ['node', 'edge'] : ['node', 'edge', 'combo'],
- onSelect: scheduleSyncSelection,
- },
- {
- type: 'optimize-viewport-transform',
- key: 'g6-editor-transform-performance',
- debounce: performanceMode ? 80 : 120,
- shapes: performanceMode ? { node: ['key'] } : (type: string) => type === 'node',
- },
- ] as any;
- const applyGraphInteractionOptions = (performanceMode = graphPerformanceMode.value) => {
- const graph = graphRef.value;
- if (!graph) return;
- graph.setPlugins(performanceMode ? createPerformanceGraphPlugins() : createGraphPlugins());
- graph.setBehaviors(createGraphBehaviors(performanceMode));
- };
- const createNodeOptions = (): NodeOptions => ({
- type: graphPerformanceMode.value ? 'performance-node' : 'analysis-node',
- style: (node: NodeData) => {
- const entity = getEntityKind(node.data?.kind);
- const performanceMode = graphPerformanceMode.value;
- const hidden = Boolean(node.data?.filterHidden);
- const dimmed = Boolean(node.data?.dimmed);
- const markerColor = String(node.data?.markerColor || '');
- if (performanceMode) {
- const labelText = getPerformanceNodeLabel(node);
- const secondaryLabelText = labelText ? getPerformanceNodeSecondaryLabel(node) : '';
- return {
- size: 22,
- fill: markerColor || entity.color,
- fillOpacity: hidden ? 0 : dimmed ? 0.18 : 0.95,
- stroke: 'transparent',
- strokeOpacity: hidden ? 0 : 1,
- lineWidth: 0,
- cursor: 'pointer',
- iconSrc: entity.paletteIcon,
- iconOpacity: hidden ? 0 : dimmed ? 0.18 : 0.96,
- labelText,
- labelFill: '#334155',
- labelFontSize: 12,
- labelOpacity: hidden ? 0 : dimmed ? 0.18 : 0.82,
- secondaryLabelText,
- secondaryLabelFill: '#94a3b8',
- secondaryLabelFontSize: 9,
- secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.14 : 0.7,
- };
- }
- return {
- size: 96,
- iconBoxSize: 40,
- iconBoxRadius: 11,
- iconSize: 32,
- fill: markerColor || entity.color,
- fillOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
- stroke: 'rgba(255, 255, 255, 0.22)',
- strokeOpacity: hidden ? 0 : 1,
- lineWidth: 1,
- opacity: hidden ? 0 : 1,
- cursor: 'pointer',
- iconSrc: entity.paletteIcon,
- iconOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
- labelText: getNodeLabel(node),
- labelFill: '#334155',
- labelFontSize: 14,
- labelFontWeight: 600,
- labelOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
- secondaryLabelText: getNodeSecondaryLabel(node),
- secondaryLabelFill: '#94a3b8',
- secondaryLabelFontSize: 12,
- secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.2 : 1,
- };
- },
- state: {
- selected: {
- halo: true,
- haloStroke: '#1677ff',
- haloLineWidth: 2,
- stroke: '#1677ff',
- strokeOpacity: 1,
- lineWidth: 3,
- fillOpacity: 1,
- },
- },
- });
- const createEdgeOptions = (): EdgeOptions => ({
- type: (edge: EdgeData) => (graphPerformanceMode.value ? 'performance-edge' : String(edge.type || 'line')),
- style: (edge: EdgeData) => {
- const performanceMode = graphPerformanceMode.value;
- const hidden = Boolean(edge.data?.filterHidden);
- const dimmed = Boolean(edge.data?.dimmed);
- const edgeStyle = getGraphEdgeStyle(edge);
- if (performanceMode) {
- const labelText = getPerformanceEdgeLabel(edge);
- return {
- stroke: '#d9d9df',
- lineWidth: 0.7,
- opacity: hidden ? 0 : dimmed ? 0.12 : 0.78,
- cursor: 'pointer',
- labelText,
- labelFill: '#516076',
- labelFontSize: 9,
- labelOpacity: hidden ? 0 : dimmed ? 0.12 : 0.62,
- };
- }
- return {
- stroke: '#d9d9df',
- lineWidth: 2,
- opacity: hidden ? 0 : dimmed ? 0.18 : 1,
- cursor: 'pointer',
- startArrow: edgeStyle.startArrow === true,
- startArrowOffset: EDGE_TARGET_ARROW_GAP,
- startArrowFill: '#d9d9df',
- endArrow: edgeStyle.endArrow !== false,
- endArrowOffset: EDGE_TARGET_ARROW_GAP,
- endArrowFill: '#d9d9df',
- label: true,
- labelText: getEdgeLabel(edge),
- labelPlacement: 'center',
- labelFill: '#516076',
- labelFontSize: 10,
- labelOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
- labelBackground: true,
- labelBackgroundFill: 'rgba(255, 255, 255, 0.92)',
- labelBackgroundRadius: 4,
- };
- },
- state: {
- selected: {
- stroke: '#2563eb',
- lineWidth: 3,
- startArrowFill: '#2563eb',
- endArrowFill: '#2563eb',
- },
- },
- });
- const createComboOptions = () => ({
- type: 'rect',
- style: (combo: ComboData) => ({
- padding: [30, 34, 40, 34],
- radius: 8,
- fill: 'rgba(37, 99, 235, 0.06)',
- stroke: '#93c5fd',
- lineWidth: 1.5,
- lineDash: [6, 4],
- labelText: getComboLabel(combo),
- labelPlacement: 'top',
- labelFill: '#2563eb',
- labelFontSize: 13,
- }),
- state: {
- selected: {
- stroke: '#1d4ed8',
- lineWidth: 2.2,
- },
- },
- });
- const createGraph = async () => {
- if (!graphContainerRef.value) return;
- registerStressElements();
- const { clientWidth, clientHeight } = graphContainerRef.value;
- const graph = new Graph({
- container: graphContainerRef.value,
- width: clientWidth,
- height: clientHeight,
- autoResize: true,
- animation: false,
- data: createInitialData(),
- node: createNodeOptions(),
- edge: createEdgeOptions(),
- combo: createComboOptions(),
- behaviors: createGraphBehaviors(false),
- plugins: createGraphPlugins(),
- } as any);
- graphRef.value = graph;
- bindGraphEvents(graph);
- await graph.render();
- await graph.fitView({ when: 'always' }, false);
- refreshGraphSnapshot();
- };
- const bindGraphEvents = (graph: Graph) => {
- const handleDragStart = () => {
- graphInteractionMoving.value = true;
- };
- const handleDragEnd = () => {
- if (!graphInteractionMoving.value) return;
- graphInteractionMoving.value = false;
- syncRenderedNodePositionsToGraphData(graph);
- markActiveCanvasDirty(true);
- refreshZoomLabel();
- };
- graph.on<IElementEvent>(NodeEvent.CLICK, (event) => {
- const nodeId = getEventElementId(event);
- if (!nodeId) return;
- if (pendingEdgeSourceId.value) {
- void completeEdgeCreate(nodeId);
- return;
- }
- window.setTimeout(syncSelection, 0);
- });
- graph.on<IElementEvent>(EdgeEvent.CLICK, () => {
- window.setTimeout(syncSelection, 0);
- });
- graph.on<IElementEvent>(ComboEvent.CLICK, () => {
- window.setTimeout(syncSelection, 0);
- });
- graph.on<IElementEvent>(NodeEvent.CONTEXT_MENU, (event) => {
- const nodeId = getEventElementId(event);
- if (!nodeId) return;
- if (!selectedNodeIds.value.includes(nodeId)) {
- void (async () => {
- await clearSelection();
- await graph.setElementState(nodeId, ['selected'], false);
- syncSelection();
- })();
- }
- });
- graph.on(CanvasEvent.CLICK, () => {
- pendingEdgeSourceId.value = '';
- window.setTimeout(syncSelection, 0);
- });
- graph.on(NodeEvent.DRAG_START, handleDragStart);
- graph.on(NodeEvent.DRAG_END, handleDragEnd);
- graph.on(ComboEvent.DRAG_START, handleDragStart);
- graph.on(ComboEvent.DRAG_END, handleDragEnd);
- graph.on(GraphEvent.AFTER_TRANSFORM, refreshZoomLabel);
- };
- const addNodeAt = async (kind: EntityKindKey, x: number, y: number) => {
- const graph = graphRef.value;
- if (!graph) return;
- const entity = getEntityKind(kind);
- const timestamp = Date.now();
- const id = `${kind}-${timestamp}`;
- await withGraphMutation(async () => {
- clearAnalysisTotalCounts();
- graph.addNodeData([
- {
- id,
- data: {
- kind,
- label: `${entity.label}${graphStats.nodes + 1}`,
- },
- style: { x, y },
- },
- ]);
- await graph.draw();
- });
- await clearSelection();
- await graph.setElementState(id, ['selected'], false);
- syncSelection();
- };
- const handleQuickAddEntity = (kind: EntityKindKey) => {
- const graph = graphRef.value;
- if (!graph || !graphContainerRef.value) return;
- const rect = graphContainerRef.value.getBoundingClientRect();
- const [x, y] = graph.getCanvasByClient([
- rect.left + rect.width / 2 + Math.random() * 120 - 60,
- rect.top + rect.height / 2 + Math.random() * 120 - 60,
- ]);
- void addNodeAt(kind, x, y);
- };
- const handlePaletteDragStart = (kind: EntityKindKey, event: DragEvent) => {
- event.dataTransfer?.setData('application/x-g6-entity-kind', kind);
- event.dataTransfer?.setData('text/plain', kind);
- if (event.dataTransfer) {
- event.dataTransfer.effectAllowed = 'copy';
- }
- };
- const handleCanvasDrop = (event: DragEvent) => {
- const graph = graphRef.value;
- if (!graph) return;
- const kind = (event.dataTransfer?.getData('application/x-g6-entity-kind') ||
- event.dataTransfer?.getData('text/plain')) as EntityKindKey;
- if (!entityKinds.some((item) => item.key === kind)) return;
- const [x, y] = graph.getCanvasByClient([event.clientX, event.clientY]);
- void addNodeAt(kind, x, y);
- };
- const completeEdgeCreate = async (targetNodeId: string) => {
- const graph = graphRef.value;
- const sourceNodeId = pendingEdgeSourceId.value;
- if (!graph || !sourceNodeId) return;
- if (sourceNodeId === targetNodeId) {
- message.warning('不能连接到自身');
- return;
- }
- await withGraphMutation(async () => {
- clearAnalysisTotalCounts();
- graph.addEdgeData([
- {
- id: `edge-${Date.now()}`,
- source: sourceNodeId,
- target: targetNodeId,
- data: { label: '关联' },
- },
- ]);
- await refreshCurrentGraphEdgeCurves();
- });
- pendingEdgeSourceId.value = '';
- syncSelection();
- };
- const cancelEdgeCreate = () => {
- pendingEdgeSourceId.value = '';
- };
- const handleCreateCombo = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const selectedNodes = selectedNodeIds.value.length
- ? selectedNodeIds.value
- : graph.getElementDataByState('node', 'selected').map((node) => String(node.id));
- if (selectedNodes.length < 2) {
- message.warning('至少选择两个节点再创建分组');
- return;
- }
- const comboId = `combo-${Date.now()}`;
- await withGraphMutation(async () => {
- graph.addComboData([
- {
- id: comboId,
- data: { label: `分组 ${graphStats.combos + 1}` },
- },
- ]);
- graph.updateNodeData(selectedNodes.map((id) => ({ id, combo: comboId })));
- await graph.draw();
- });
- await clearSelection();
- await graph.setElementState(comboId, ['selected'], false);
- syncSelection();
- };
- const ungroupCombo = async (comboId: string) => {
- const graph = graphRef.value;
- if (!graph) return;
- const comboNodeIds = graph
- .getNodeData()
- .filter((node) => String(node.combo || '') === comboId)
- .map((node) => String(node.id));
- await withGraphMutation(async () => {
- if (comboNodeIds.length) {
- graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
- }
- graph.removeComboData([comboId]);
- await graph.draw();
- });
- syncSelection();
- };
- const deleteNodes = (nodeIds: string[]) => {
- const graph = graphRef.value;
- if (!graph || !nodeIds.length) return;
- const nodeIdSet = new Set(nodeIds);
- const relatedEdgeIds = graph
- .getEdgeData()
- .filter((edge) => nodeIdSet.has(String(edge.source)) || nodeIdSet.has(String(edge.target)))
- .map((edge) => (edge.id ? String(edge.id) : ''))
- .filter(Boolean);
- if (relatedEdgeIds.length) graph.removeEdgeData(relatedEdgeIds);
- graph.removeNodeData(nodeIds);
- };
- const handleDeleteSelected = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const edgeIds = [...selectedEdgeIds.value];
- const nodeIds = [...selectedNodeIds.value];
- const comboIds = [...selectedComboIds.value];
- if (!edgeIds.length && !nodeIds.length && !comboIds.length) {
- message.warning('请先选择要删除的元素');
- return;
- }
- await withGraphMutation(async () => {
- if (edgeIds.length || nodeIds.length) {
- clearAnalysisTotalCounts();
- }
- if (edgeIds.length) graph.removeEdgeData(edgeIds);
- if (nodeIds.length) deleteNodes(nodeIds);
- comboIds.forEach((comboId) => {
- const comboNodeIds = graph
- .getNodeData()
- .filter((node) => String(node.combo || '') === comboId)
- .map((node) => String(node.id));
- if (comboNodeIds.length) graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
- graph.removeComboData([comboId]);
- });
- if (edgeIds.length || nodeIds.length) {
- await refreshCurrentGraphEdgeCurves();
- } else {
- await graph.draw();
- }
- });
- selectedNodeIds.value = [];
- selectedEdgeIds.value = [];
- selectedComboIds.value = [];
- activeElement.value = null;
- };
- const loadAllRespondentOptions = async () => {
- if (loadedRespondentOptions.value.length) return;
- try {
- const list = await getPersons();
- const persons = Array.isArray(list) ? list : [];
- loadedRespondentOptions.value = persons
- .filter((item) => item.personName || item.id)
- .map((item) => ({
- id: String(item.objId ?? item.id ?? item.personName ?? ''),
- name: item.personName || String(item.id || ''),
- objId: String(item.objId ?? item.id ?? ''),
- identityCode: item.personCertNo || undefined,
- photograph: item.photograph || '',
- }));
- } catch {
- loadedRespondentOptions.value = [];
- }
- };
- const handleOpenAnalysisCondition = () => {
- analysisConditionVisible.value = true;
- void loadAllRespondentOptions();
- };
- const resolveAnalysisPayload = (payload: AnalysisConditionPayload): AnalysisConditionPayload => {
- const routeRespondents = routeGraphRespondents.value;
- if (!routeRespondents.length) return payload;
- const routeRespondentMap = new Map(routeRespondents.map((item) => [item.id, item]));
- return {
- ...payload,
- respondents: payload.respondents.map((item) => ({
- ...(routeRespondentMap.get(item.id) || {}),
- ...item,
- photograph: item.photograph || routeRespondentMap.get(item.id)?.photograph || '',
- })),
- };
- };
- const buildRouteAnalysisPayload = (): AnalysisConditionPayload | null => {
- if (!routeGraphRespondents.value.length) return null;
- const startDate =
- typeof routeGraphQuery.value.date1 === 'string'
- ? routeGraphQuery.value.date1
- : typeof routeGraphQuery.value.startDate === 'string'
- ? routeGraphQuery.value.startDate
- : '';
- const endDate =
- typeof routeGraphQuery.value.date2 === 'string'
- ? routeGraphQuery.value.date2
- : typeof routeGraphQuery.value.endDate === 'string'
- ? routeGraphQuery.value.endDate
- : '';
- return {
- respondents: JSON.parse(JSON.stringify(routeGraphRespondents.value)) as AnalysisRespondent[],
- queryTime: startDate && endDate ? [startDate, endDate] : undefined,
- tradeMode: 'trade',
- communicationMode: 'contact',
- };
- };
- const clearGraphLayout = (graph = graphRef.value) => {
- if (!graph) return;
- graph.stopLayout?.();
- graph.setLayout(undefined as any);
- };
- const applySmartLayoutToGraphData = async (graphData: GraphData) => {
- const graph = graphRef.value;
- if (!graph) return false;
- const normalizedGraphData = normalizeGraphDataEdgeCurves(graphData);
- const largeGraph = isLargeGraphData(normalizedGraphData);
- graphPerformanceMode.value = largeGraph;
- applyGraphInteractionOptions();
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- graph.setCombo(createComboOptions() as any);
- try {
- // Large graphs are rendered more reliably when we precompute positions
- // outside G6's native force-layout pipeline and then render them statically.
- if (largeGraph) {
- clearGraphLayout(graph);
- await applyAnalysisRelationLayout(normalizedGraphData, { preset: 'mingjing-g6' });
- expandAnalysisLeafRingNodes(normalizedGraphData);
- graph.setData(normalizedGraphData);
- await graph.render();
- } else {
- graph.setLayout(createMingjingG6LayoutOptions(normalizedGraphData) as any);
- graph.setData(normalizedGraphData);
- await graph.render();
- syncGraphNodePositionsToData(normalizedGraphData, graph);
- expandAnalysisLeafRingNodes(normalizedGraphData);
- clearGraphLayout(graph);
- graph.setData(normalizedGraphData);
- await graph.draw();
- }
- } catch (error) {
- console.warn('g6 native mingjing layout fallback to legacy layout', error);
- clearGraphLayout(graph);
- await applyAnalysisRelationLayout(normalizedGraphData, { preset: 'mingjing-g6' });
- expandAnalysisLeafRingNodes(normalizedGraphData);
- graph.setData(normalizedGraphData);
- await graph.render();
- }
- syncRenderedNodePositionsToGraphData(graph);
- await fitGraphViewForData(graph.getData(), false, { focusLargeGraph: true });
- return true;
- };
- const applySmartLayoutPasses = async (
- initialGraphData: GraphData,
- passes = getGraphLayoutPassCount(initialGraphData),
- ) => {
- const graph = graphRef.value;
- if (!graph) return false;
- let graphData = cloneGraphData(initialGraphData);
- const normalizedPasses = Math.max(1, Math.floor(passes));
- for (let index = 0; index < normalizedPasses; index += 1) {
- const applied = await applySmartLayoutToGraphData(graphData);
- if (!applied) return false;
- graphData = cloneGraphData(graph.getData());
- }
- return true;
- };
- const applyAnalysisResponseToGraph = async (response: GraphAnalysisResponse) => {
- const graph = graphRef.value;
- if (!graph) return false;
- const graphData = buildAnalysisGraphData(response);
- if (!graphData.nodes?.length) return false;
- const responseCounts = getAnalysisResponseCounts(response);
- pendingEdgeSourceId.value = '';
- await applySmartLayoutPasses(graphData);
- clearLocalSelection();
- setAnalysisTotalCounts(responseCounts.nodeCount, responseCounts.edgeCount);
- refreshGraphSnapshot();
- saveActiveCanvasState();
- if (graphPerformanceMode.value) {
- message.info('节点较多,已启用高性能渲染,布局仍使用力导向');
- }
- return true;
- };
- const executeStartAnalysis = async (payload: AnalysisConditionPayload) => {
- if (analysisLoading.value || analysisLayoutLoading.value) return;
- analysisLoading.value = true;
- const shouldCloseModal = analysisConditionVisible.value;
- try {
- const effectivePayload = resolveAnalysisPayload(payload);
- const response = await fetchGraphAnalysis(effectivePayload, routeGraphQuery.value);
- if (shouldCloseModal) {
- analysisConditionVisible.value = false;
- }
- analysisLoading.value = false;
- analysisLayoutLoading.value = true;
- await nextTick();
- const rendered = await applyAnalysisResponseToGraph(response);
- if (!rendered) {
- if (shouldCloseModal) {
- analysisConditionVisible.value = true;
- }
- message.warning('当前接口返回的数据没有匹配到可展示的图谱关系');
- return;
- }
- lastAnalysisPayload.value = JSON.parse(JSON.stringify(effectivePayload)) as AnalysisConditionPayload;
- syncAnalysisDataRefreshState(true);
- message.success(
- `已加载关系图:节点 ${analysisTotalNodeCount.value || graphStats.nodes},关系 ${analysisTotalEdgeCount.value || graphStats.edges}`,
- );
- } catch (error) {
- if (shouldCloseModal) {
- analysisConditionVisible.value = true;
- }
- console.error('load g6 graph analysis failed', error);
- const errorMessage = error instanceof Error ? error.message : '';
- message.error(errorMessage || '加载图谱数据失败,请稍后重试');
- } finally {
- analysisLoading.value = false;
- analysisLayoutLoading.value = false;
- }
- };
- const handleStartAnalysis = (payload: AnalysisConditionPayload) => {
- const graph = graphRef.value;
- const hasGraphData = Boolean(graph?.getData().nodes?.length || graph?.getData().edges?.length);
- if (!hasGraphData) {
- void executeStartAnalysis(payload);
- return;
- }
- Modal.confirm({
- title: '开始分析',
- content: '当前画布已有图谱数据,重新分析将重新渲染图谱,是否继续?',
- centered: true,
- okText: '继续分析',
- cancelText: '取消',
- onOk: () => executeStartAnalysis(payload),
- });
- };
- const selectContextNodes = async (nodeIds: string[]) => {
- const graph = graphRef.value;
- const uniqueNodeIds = uniqueValues(nodeIds.filter(Boolean));
- if (!graph || !uniqueNodeIds.length) return;
- await clearSelection();
- await setManyElementSelected(uniqueNodeIds);
- syncSelection();
- };
- const deleteContextElement = async (elementId: string, kind: ElementKind | null) => {
- const graph = graphRef.value;
- if (!graph || !elementId || !kind) {
- await handleDeleteSelected();
- return;
- }
- await withGraphMutation(async () => {
- if (kind === 'node') {
- deleteNodes(getSelectedNodeIdsForContext(elementId));
- } else if (kind === 'edge') {
- graph.removeEdgeData([elementId]);
- } else {
- const comboNodeIds = graph
- .getNodeData()
- .filter((node) => String(node.combo || '') === elementId)
- .map((node) => String(node.id));
- if (comboNodeIds.length) {
- graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
- }
- graph.removeComboData([elementId]);
- }
- if (kind === 'node' || kind === 'edge') {
- await refreshCurrentGraphEdgeCurves();
- } else {
- await graph.draw();
- }
- });
- syncSelection();
- };
- const handleLookNodeFlow = async (nodeId: string, direction: 'incoming' | 'outgoing') => {
- const graph = graphRef.value;
- if (!graph || !findNodeData(nodeId)) {
- message.warning('请先选择一个分析节点');
- return;
- }
- const flowEdges = getNodeFlowEdges(nodeId, direction);
- if (!flowEdges.length) {
- message.warning(`当前节点没有${direction === 'incoming' ? '流入' : '流出'}关系`);
- return;
- }
- const nodeIds = new Set<string>([nodeId]);
- const edgeIds: string[] = [];
- flowEdges.forEach((edge) => {
- nodeIds.add(String(edge.source));
- nodeIds.add(String(edge.target));
- if (edge.id) edgeIds.push(String(edge.id));
- });
- await clearSelection();
- await setManyElementSelected([...nodeIds, ...edgeIds]);
- await graph.focusElement(nodeId, { duration: 240 });
- syncSelection();
- message.success(`已选中 ${flowEdges.length} 条${direction === 'incoming' ? '流入' : '流出'}关系`);
- };
- const getAnalysisNodePosition = (node?: NodeData | null) => ({
- x: getFiniteNumber((node?.style as any)?.x, getGraphCenterCanvasPoint().x),
- y: getFiniteNumber((node?.style as any)?.y, getGraphCenterCanvasPoint().y),
- });
- const getCollapsedAnalysisNodePositions = (anchorNode: NodeData, nodes: NodeData[]) => {
- const anchorPosition = getAnalysisNodePosition(anchorNode);
- const positionMap = new Map<string, { x: number; y: number }>();
- let cursor = 0;
- let ringIndex = 0;
- while (cursor < nodes.length) {
- const radius = ANALYSIS_EXPAND_FIRST_RING_RADIUS + ringIndex * ANALYSIS_EXPAND_RING_RADIUS_STEP;
- const maxRingCount = Math.max(10, Math.floor((2 * Math.PI * radius) / ANALYSIS_EXPAND_ARC_GAP));
- const ringNodes = nodes.slice(cursor, cursor + maxRingCount);
- const angleOffset = -Math.PI / 2 + ringIndex * 0.17;
- ringNodes.forEach((node, index) => {
- const nodeId = String(node.id || '');
- if (!nodeId) return;
- const angle = angleOffset + (2 * Math.PI * index) / Math.max(1, ringNodes.length);
- positionMap.set(nodeId, {
- x: anchorPosition.x + Math.cos(angle) * radius,
- y: anchorPosition.y + Math.sin(angle) * radius,
- });
- });
- cursor += ringNodes.length;
- ringIndex += 1;
- }
- return positionMap;
- };
- const getAnalysisEdgeDuplicateKey = (edge: EdgeData) =>
- `${String(edge.source || '')}->${String(edge.target || '')}:${getEdgeLabel(edge)}`;
- const resolveAnalysisExpandedEdgeId = (edge: EdgeData, index: number, existingEdgeIdSet: Set<string>) => {
- const rawId = String(edge.id || '').trim();
- if (rawId && !existingEdgeIdSet.has(rawId)) {
- existingEdgeIdSet.add(rawId);
- return rawId;
- }
- const suffix = Date.now();
- let edgeId = `${ANALYSIS_EXPANDED_EDGE_PREFIX}-${suffix}-${index}`;
- let nextIndex = index;
- while (existingEdgeIdSet.has(edgeId)) {
- nextIndex += 1;
- edgeId = `${ANALYSIS_EXPANDED_EDGE_PREFIX}-${suffix}-${nextIndex}`;
- }
- existingEdgeIdSet.add(edgeId);
- return edgeId;
- };
- const refreshGraphOptionsAfterDataChange = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- graphPerformanceMode.value = isLargeGraphData(graph.getData());
- applyGraphInteractionOptions();
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- await refreshCurrentGraphEdgeCurves();
- };
- const handleExpandCollapsedAnalysisNode = async (nodeId: string) => {
- const graph = graphRef.value;
- const anchorNode = findNodeData(nodeId);
- if (!graph || !anchorNode) {
- message.warning('当前节点不可展开');
- return;
- }
- const collapsedNodes = getAnalysisCollapsedNodes(anchorNode);
- const collapsedEdges = getAnalysisCollapsedEdges(anchorNode);
- if (!collapsedNodes.length || getAnalysisCollapsedNeighborCount(anchorNode) <= 0) {
- message.warning('当前节点没有可展开的关联节点');
- return;
- }
- if (canCollapseExpandedAnalysisNode(nodeId)) {
- message.warning('当前节点已展开');
- return;
- }
- const existingNodeIdSet = new Set(graph.getNodeData().map((node) => String(node.id)));
- const existingEdgeIdSet = new Set(
- graph
- .getEdgeData()
- .map((edge) => String(edge.id || ''))
- .filter(Boolean),
- );
- const existingEdgeKeySet = new Set(graph.getEdgeData().map((edge) => getAnalysisEdgeDuplicateKey(edge)));
- const positionMap = getCollapsedAnalysisNodePositions(anchorNode, collapsedNodes);
- const expandedGroupId = `analysis-expanded-${nodeId}-${Date.now()}`;
- const expandedNodes = collapsedNodes
- .filter((node) => {
- const collapsedNodeId = String(node.id || '');
- return collapsedNodeId && !existingNodeIdSet.has(collapsedNodeId);
- })
- .map((node) => {
- const expandedNodeId = String(node.id);
- return {
- ...cloneAnalysisNodeData(node),
- combo: undefined,
- data: {
- ...(node.data || {}),
- analysisExpandedGroupId: expandedGroupId,
- analysisExpandedAnchorId: nodeId,
- },
- style: {
- ...(node.style || {}),
- ...(positionMap.get(expandedNodeId) || getAnalysisNodePosition(anchorNode)),
- },
- } as NodeData;
- });
- const visibleNodeIdSet = new Set([
- ...existingNodeIdSet,
- ...expandedNodes.map((node) => String(node.id || '')).filter(Boolean),
- ]);
- const expandedEdges = collapsedEdges
- .map((edge, index) => {
- const source = String(edge.source || '');
- const target = String(edge.target || '');
- if (!source || !target || source === target || !visibleNodeIdSet.has(source) || !visibleNodeIdSet.has(target)) {
- return null;
- }
- const edgeKey = getAnalysisEdgeDuplicateKey(edge);
- if (existingEdgeKeySet.has(edgeKey)) return null;
- existingEdgeKeySet.add(edgeKey);
- const edgeId = resolveAnalysisExpandedEdgeId(edge, index, existingEdgeIdSet);
- return {
- ...cloneAnalysisEdgeData(edge),
- id: edgeId,
- source,
- target,
- data: {
- ...(edge.data || {}),
- analysisExpandedGroupId: expandedGroupId,
- analysisExpandedAnchorId: nodeId,
- },
- } as EdgeData;
- })
- .filter((edge): edge is EdgeData => Boolean(edge));
- const expandedNodeIds = expandedNodes.map((node) => String(node.id));
- const expandedEdgeIds = expandedEdges.map((edge) => String(edge.id || '')).filter(Boolean);
- await withGraphMutation(async () => {
- if (expandedNodes.length) graph.addNodeData(expandedNodes as any);
- if (expandedEdges.length) graph.addEdgeData(expandedEdges);
- graph.updateNodeData([
- {
- id: nodeId,
- data: {
- ...(anchorNode.data || {}),
- analysisExpandedGroupId: expandedGroupId,
- analysisExpandedNeighborCount: collapsedNodes.length,
- analysisExpandedNodeIds: expandedNodeIds,
- analysisExpandedEdgeIds: expandedEdgeIds,
- analysisCollapsedNeighborCount: 0,
- },
- },
- ]);
- await refreshGraphOptionsAfterDataChange();
- });
- await clearSelection();
- await setManyElementSelected([nodeId, ...expandedNodeIds.slice(0, 80)]);
- await graph.focusElement(nodeId, { duration: 240 } as any);
- syncSelection();
- message.success(`已展开 ${collapsedNodes.length} 个关联节点`);
- };
- const handleCollapseExpandedAnalysisNode = async (nodeId: string) => {
- const graph = graphRef.value;
- const anchorNode = findNodeData(nodeId);
- if (!graph || !anchorNode) {
- message.warning('当前节点不可收起');
- return;
- }
- const expandedNodeIds = getAnalysisExpandedNodeIds(anchorNode);
- const expandedEdgeIdSet = new Set(getAnalysisExpandedEdgeIds(anchorNode));
- if (!expandedNodeIds.length && !expandedEdgeIdSet.size) {
- message.warning('当前节点没有已展开的关联节点');
- return;
- }
- const removableNodeIds = expandedNodeIds.filter((expandedNodeId) => {
- const expandedNode = findNodeData(expandedNodeId);
- return getAnalysisNodeData(expandedNode).analysisExpandedAnchorId === nodeId;
- });
- const removableNodeIdSet = new Set(removableNodeIds);
- const edgeIdsToRemove = graph
- .getEdgeData()
- .filter((edge) => {
- const edgeId = String(edge.id || '');
- const source = String(edge.source || '');
- const target = String(edge.target || '');
- const edgeData = getAnalysisEdgeData(edge);
- return (
- expandedEdgeIdSet.has(edgeId) ||
- edgeData.analysisExpandedAnchorId === nodeId ||
- removableNodeIdSet.has(source) ||
- removableNodeIdSet.has(target)
- );
- })
- .map((edge) => String(edge.id || ''))
- .filter(Boolean);
- const collapsedCount = getAnalysisCollapsedNodes(anchorNode).length || getAnalysisExpandedNeighborCount(anchorNode);
- await withGraphMutation(async () => {
- if (edgeIdsToRemove.length) {
- graph.removeEdgeData(uniqueValues(edgeIdsToRemove));
- }
- if (removableNodeIds.length) {
- graph.removeNodeData(removableNodeIds);
- }
- graph.updateNodeData([
- {
- id: nodeId,
- data: {
- ...(anchorNode.data || {}),
- analysisExpandedGroupId: undefined,
- analysisExpandedAnchorId: undefined,
- analysisExpandedNeighborCount: undefined,
- analysisExpandedNodeIds: undefined,
- analysisExpandedEdgeIds: undefined,
- analysisCollapsedNeighborCount: collapsedCount,
- },
- },
- ]);
- await refreshGraphOptionsAfterDataChange();
- });
- await clearSelection();
- await setManyElementSelected([nodeId]);
- syncSelection();
- message.success(`已收起 ${collapsedCount} 个关联节点`);
- };
- const openAssociativeObject = (nodeId: string) => {
- if (!canShowAssociativeObject(nodeId)) {
- message.warning('当前节点不支持关联对象');
- return;
- }
- associativeObjectAnchorId.value = nodeId;
- associativeObjectForm.entityKind = 'person';
- associativeObjectForm.name = '';
- associativeObjectForm.identityCode = '';
- associativeObjectVisible.value = true;
- };
- const getAssociativeObjectPosition = (anchorNodeId: string) => {
- const graph = graphRef.value;
- const anchorNode = findNodeData(anchorNodeId);
- const anchorX = Number((anchorNode?.style as any)?.x || 0);
- const anchorY = Number((anchorNode?.style as any)?.y || 0);
- const relatedCount =
- graph
- ?.getEdgeData()
- .filter((edge) => String(edge.source) === anchorNodeId || String(edge.target) === anchorNodeId).length || 0;
- const ring = Math.floor(relatedCount / 8);
- const radius = 170 + ring * 88;
- const angle = -Math.PI / 2 + (relatedCount % 8) * (Math.PI / 4);
- return {
- x: anchorX + Math.cos(angle) * radius,
- y: anchorY + Math.sin(angle) * radius,
- };
- };
- const handleConfirmAssociativeObject = async () => {
- const graph = graphRef.value;
- const anchorNodeId = associativeObjectAnchorId.value;
- const anchorNode = findNodeData(anchorNodeId);
- const name = associativeObjectForm.name.trim();
- const identityCode = associativeObjectForm.identityCode.trim();
- if (!graph || !anchorNode) {
- message.warning('未找到关联对象锚点');
- return;
- }
- if (!name) {
- message.warning('请输入关联对象名称');
- return;
- }
- const kind = getEntityKind(associativeObjectForm.entityKind).key;
- const fullLabel = [name, identityCode].filter(Boolean).join(' ');
- const position = getAssociativeObjectPosition(anchorNodeId);
- const suffix = Date.now();
- const nodeId = `manual-object-${suffix}`;
- const edgeId = `manual-edge-${suffix}`;
- await withGraphMutation(async () => {
- clearAnalysisTotalCounts();
- graph.addNodeData([
- {
- id: nodeId,
- data: {
- kind,
- label: fullLabel || name,
- fullLabel: fullLabel || name,
- isAnalysis: true,
- rawData: {
- id: nodeId,
- bm: identityCode || name,
- mc: name,
- class: kind,
- },
- },
- style: position,
- },
- ]);
- graph.addEdgeData([
- {
- id: edgeId,
- source: anchorNodeId,
- target: nodeId,
- data: {
- label: '关联对象',
- isAnalysis: true,
- rawData: {
- id: edgeId,
- dx1: String(anchorNode.data?.fullLabel || anchorNode.data?.label || anchorNode.id),
- dx2: identityCode || name,
- clazz: 'custom_assoc',
- mc: '关联对象',
- sm: '关联对象',
- },
- },
- },
- ]);
- await refreshCurrentGraphEdgeCurves();
- });
- await clearSelection();
- await setManyElementSelected([anchorNodeId, nodeId, edgeId]);
- syncSelection();
- associativeObjectVisible.value = false;
- associativeObjectAnchorId.value = '';
- message.success('已创建关联对象');
- };
- const handleContextMenuClick = async (value: ContextMenuActionKey, current: { id?: string } | null) => {
- const graph = graphRef.value;
- const state = contextMenuState.value;
- const elementId = String(current?.id || state?.id || '');
- const kind = resolveElementKind(elementId, state?.type);
- const menuPosition = state ? { x: state.canvasX, y: state.canvasY } : getGraphCenterCanvasPoint();
- if (!graph) return;
- if (value === 'copy') {
- if (kind === 'node') {
- await selectContextNodes(getSelectedNodeIdsForContext(elementId));
- }
- copySelectionToClipboard();
- return;
- }
- if (value === 'paste') {
- await pasteClipboardToCanvas(menuPosition);
- return;
- }
- if (value === 'delete') {
- await deleteContextElement(elementId, kind);
- return;
- }
- if (value === 'group') {
- await selectContextNodes(getSelectedNodeIdsForContext(elementId));
- await handleCreateCombo();
- return;
- }
- if (value === 'ungroup') {
- if (kind === 'combo' && elementId) {
- await ungroupCombo(elementId);
- return;
- }
- const nodeComboId = kind === 'node' ? String(findNodeData(elementId)?.combo || '') : '';
- if (nodeComboId) {
- await ungroupCombo(nodeComboId);
- return;
- }
- await handleRemoveGroup();
- return;
- }
- if (value === 'newCanvas') {
- if (kind === 'node') {
- await selectContextNodes(getSelectedNodeIdsForContext(elementId));
- }
- await handleCreateNewCanvas();
- return;
- }
- if (value === 'analysisDetail' && elementId && kind && canOpenAnalysisDetail(elementId, kind)) {
- await openAnalysisDetail(elementId, kind);
- return;
- }
- if (value === 'startEdge' && elementId && kind === 'node') {
- pendingEdgeSourceId.value = elementId;
- message.info('点击另一个节点即可完成连线');
- return;
- }
- if (value === 'onlyLookFlowIn' && elementId && kind === 'node') {
- await handleLookNodeFlow(elementId, 'incoming');
- return;
- }
- if (value === 'onlyLookFlowOut' && elementId && kind === 'node') {
- await handleLookNodeFlow(elementId, 'outgoing');
- return;
- }
- if (value === 'associativeObject' && elementId && kind === 'node') {
- openAssociativeObject(elementId);
- return;
- }
- if (value === 'createPerson') {
- await addNodeAt('person', menuPosition.x, menuPosition.y);
- return;
- }
- if (value === 'fitView') {
- await handleFitView();
- }
- };
- const handleFitView = async () => {
- await fitGraphViewForData();
- };
- const handleCenterView = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- await graph.fitCenter(false);
- refreshZoomLabel();
- };
- const compareNumericValue = (value: number, operator: FilterForm['targetCondition'], target: number) => {
- if (operator === '>') return value > target;
- if (operator === '>=') return value >= target;
- if (operator === '=') return value === target;
- if (operator === '<') return value < target;
- return value <= target;
- };
- const extractEdgeNumericValues = (edge: EdgeData) => {
- const rawData = (edge.data?.rawData || {}) as Record<string, any>;
- return [rawData.nums, rawData.money, rawData.count, edge.data?.label]
- .map((item) => String(item ?? '').match(/-?\d+(?:\.\d+)?/)?.[0])
- .filter(Boolean)
- .map((item) => Number(item))
- .filter((item) => !Number.isNaN(item));
- };
- const refreshGraphStyleOptions = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- await graph.draw();
- refreshGraphSnapshot();
- };
- const applyFilterPresentation = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const mutedNodeSet = new Set(filterMutedNodeIds.value);
- const hiddenNodeSet = new Set(filterHidden.value ? filterMutedNodeIds.value : []);
- const matchedNodeSet = new Set(filterMatchedNodeIds.value);
- const matchedEdgeSet = new Set(filterMatchedEdgeIds.value);
- graph.updateNodeData(
- graph.getNodeData().map((node) => ({
- id: node.id,
- data: {
- ...(node.data || {}),
- dimmed: mutedNodeSet.has(String(node.id)),
- filterHidden: hiddenNodeSet.has(String(node.id)),
- },
- })),
- );
- graph.updateEdgeData(
- graph.getEdgeData().map((edge) => ({
- id: edge.id,
- data: {
- ...(edge.data || {}),
- dimmed: filterMatchedNodeIds.value.length ? !matchedEdgeSet.has(String(edge.id)) : false,
- filterHidden:
- filterHidden.value &&
- (!matchedEdgeSet.has(String(edge.id)) ||
- hiddenNodeSet.has(String(edge.source)) ||
- hiddenNodeSet.has(String(edge.target))),
- },
- })),
- );
- await refreshGraphStyleOptions();
- if (matchedNodeSet.size) {
- await clearSelection();
- await setManyElementSelected([...matchedNodeSet]);
- syncSelection();
- }
- };
- const executeFilterCells = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- if (!filterForm.findEntityList.length) {
- message.warning('请选择过滤实体');
- return;
- }
- if (filterForm.setResult === undefined || Number.isNaN(Number(filterForm.setResult))) {
- message.warning('请输入设定结果');
- return;
- }
- const targetKinds = new Set(filterForm.findEntityList);
- const targetValue = Number(filterForm.setResult);
- const matchedNodeIds: string[] = [];
- const mutedNodeIds: string[] = [];
- const matchedEdgeIds = new Set<string>();
- const edgeList = graph.getEdgeData();
- graph.getNodeData().forEach((node) => {
- const nodeId = String(node.id);
- if (!targetKinds.has((node.data?.kind as EntityKindKey) || 'unknown')) {
- mutedNodeIds.push(nodeId);
- return;
- }
- const connectedEdges = edgeList.filter(
- (edge) => String(edge.source) === nodeId || String(edge.target) === nodeId,
- );
- const matched =
- filterForm.filterCondition === 'neighborTotal'
- ? compareNumericValue(connectedEdges.length, filterForm.targetCondition, targetValue)
- : connectedEdges.some((edge) =>
- extractEdgeNumericValues(edge).some((value) =>
- compareNumericValue(value, filterForm.targetCondition, targetValue),
- ),
- );
- if (matched) {
- matchedNodeIds.push(nodeId);
- connectedEdges.forEach((edge) => edge.id && matchedEdgeIds.add(String(edge.id)));
- } else {
- mutedNodeIds.push(nodeId);
- }
- });
- filterMatchedNodeIds.value = matchedNodeIds;
- filterMutedNodeIds.value = mutedNodeIds;
- filterMatchedEdgeIds.value = [...matchedEdgeIds];
- filterHidden.value = false;
- await applyFilterPresentation();
- message.success(`过滤完成:匹配 ${matchedNodeIds.length} 个实体`);
- };
- const handleFilterCancel = async () => {
- filterMatchedNodeIds.value = [];
- filterMutedNodeIds.value = [];
- filterMatchedEdgeIds.value = [];
- filterHidden.value = false;
- await applyFilterPresentation();
- };
- const handleFilterSelectAll = async () => {
- const graph = graphRef.value;
- if (!graph || !filterMatchedNodeIds.value.length) return;
- await clearSelection();
- await setManyElementSelected(filterMatchedNodeIds.value);
- syncSelection();
- };
- const handleFilterToggleDisplay = async () => {
- filterHidden.value = !filterHidden.value;
- await applyFilterPresentation();
- };
- const getEdgeSearchNumericValues = (edge: EdgeData) => {
- const rawData = (edge.data?.rawData || {}) as Record<string, any>;
- const directValues = [rawData.money, rawData.nums, rawData.count]
- .map((item) => Number(item))
- .filter((item) => Number.isFinite(item));
- const labelValues = getEdgeLabel(edge)
- .replace(/[^0-9.,]/g, ' ')
- .split(/\s+/)
- .filter(Boolean)
- .map((item) => Number(item.replace(/,/g, '')))
- .filter((item) => Number.isFinite(item));
- return Array.from(new Set([...directValues, ...labelValues]));
- };
- const findElements = () => {
- const graph = graphRef.value;
- if (!graph) return [];
- const getNodeSearchTexts = (node: NodeData) => [
- getNodeLabel(node),
- getNodeSecondaryLabel(node),
- getPerformanceNodeSecondaryLabel(node),
- String(node.data?.fullLabel || ''),
- String(node.id),
- ];
- if (findForm.scope === 'entity') {
- const keyword = findForm.keyword.trim();
- if (!keyword) return [];
- const normalizedKeyword = sanitizeEdgeLabelText(keyword);
- const matcher = (texts: Array<string | number | null | undefined>) =>
- texts.some((text) => {
- const value = String(text ?? '').trim();
- if (!value) return false;
- const normalizedValue = sanitizeEdgeLabelText(value);
- return findForm.manner === 'precise'
- ? value === keyword || normalizedValue === normalizedKeyword
- : value.includes(keyword) || normalizedValue.includes(normalizedKeyword);
- });
- return graph
- .getNodeData()
- .filter((node) => matcher(getNodeSearchTexts(node)))
- .map((node) => String(node.id));
- }
- const rangeMin =
- findForm.rangeMin === undefined || findForm.rangeMin === null ? undefined : Number(findForm.rangeMin);
- const rangeMax =
- findForm.rangeMax === undefined || findForm.rangeMax === null ? undefined : Number(findForm.rangeMax);
- if (rangeMin === undefined && rangeMax === undefined) return [];
- return graph
- .getEdgeData()
- .filter((edge) => isAnalysisEdge(edge))
- .filter((edge) => {
- const values = getEdgeSearchNumericValues(edge);
- if (!values.length) return false;
- return values.some((value) => {
- if (rangeMin !== undefined && value < rangeMin) return false;
- if (rangeMax !== undefined && value > rangeMax) return false;
- return true;
- });
- })
- .map((edge) => String(edge.id || ''))
- .filter(Boolean);
- };
- const focusFindResult = async (index: number) => {
- const graph = graphRef.value;
- const targetId = findResultIds.value[index];
- if (!graph || !targetId) return;
- await clearSelection();
- await graph.setElementState(targetId, ['selected'], false);
- const targetData = findForm.scope === 'entity' ? findNodeData(targetId) : findEdgeData(targetId);
- if (findForm.scope === 'entity' && targetData?.style) {
- await graph.focusElement(targetId, { animation: { duration: 240 } } as any);
- }
- syncSelection();
- };
- const handleFindConfirm = async () => {
- if (findForm.scope === 'entity') {
- if (!findForm.keyword.trim()) {
- message.warning('请输入查找内容');
- return;
- }
- } else {
- const rangeMin =
- findForm.rangeMin === undefined || findForm.rangeMin === null ? undefined : Number(findForm.rangeMin);
- const rangeMax =
- findForm.rangeMax === undefined || findForm.rangeMax === null ? undefined : Number(findForm.rangeMax);
- if (rangeMin === undefined && rangeMax === undefined) {
- message.warning('请输入关系数值范围');
- return;
- }
- if (
- (rangeMin !== undefined && !Number.isFinite(rangeMin)) ||
- (rangeMax !== undefined && !Number.isFinite(rangeMax))
- ) {
- message.warning('请输入有效的数字范围');
- return;
- }
- if (rangeMin !== undefined && rangeMax !== undefined && rangeMin > rangeMax) {
- message.warning('最小值不能大于最大值');
- return;
- }
- }
- findResultIds.value = findElements();
- findResultIndex.value = findResultIds.value.length ? 0 : -1;
- if (!findResultIds.value.length) {
- message.warning('未找到匹配结果');
- return;
- }
- await focusFindResult(0);
- };
- const handleFindPrev = async () => {
- if (!findResultIds.value.length) return;
- findResultIndex.value = (findResultIndex.value - 1 + findResultIds.value.length) % findResultIds.value.length;
- await focusFindResult(findResultIndex.value);
- };
- const handleFindNext = async () => {
- if (!findResultIds.value.length) return;
- findResultIndex.value = (findResultIndex.value + 1) % findResultIds.value.length;
- await focusFindResult(findResultIndex.value);
- };
- const handleFindSelectAll = async () => {
- const graph = graphRef.value;
- if (!graph || !findResultIds.value.length) return;
- await clearSelection();
- await setManyElementSelected(findResultIds.value);
- syncSelection();
- };
- const handleSetMarker = async (colorIndex: number) => {
- const graph = graphRef.value;
- if (!graph || !selectedNodeIds.value.length) {
- message.warning('请至少选择一个实体后再进行操作');
- return;
- }
- const nextColor = markerColors[colorIndex];
- await withGraphMutation(async () => {
- graph.updateNodeData(
- selectedNodeIds.value.map((id) => {
- const node = findNodeData(id);
- return {
- id,
- data: {
- ...(node?.data || {}),
- markerColor: node?.data?.markerColor === nextColor ? undefined : nextColor,
- },
- };
- }),
- );
- await refreshGraphStyleOptions();
- });
- };
- const handleSelectNeighborEntity = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- const selectedNodeIdSet = new Set(selectedNodeIds.value);
- const selectedEdgeIdSet = new Set(selectedEdgeIds.value);
- if (!selectedNodeIdSet.size && !selectedEdgeIdSet.size) {
- message.warning('请至少选择一个实体或关系后再进行操作');
- return;
- }
- const relatedNodeIds = new Set(selectedNodeIdSet);
- const relatedEdgeIds = new Set(selectedEdgeIdSet);
- const edgeList = graph.getEdgeData();
- selectedEdgeIdSet.forEach((edgeId) => {
- const edge = findEdgeData(edgeId);
- if (edge) {
- relatedNodeIds.add(String(edge.source));
- relatedNodeIds.add(String(edge.target));
- }
- });
- edgeList.forEach((edge) => {
- const source = String(edge.source);
- const target = String(edge.target);
- if (selectedNodeIdSet.has(source) || selectedNodeIdSet.has(target)) {
- edge.id && relatedEdgeIds.add(String(edge.id));
- relatedNodeIds.add(source);
- relatedNodeIds.add(target);
- }
- });
- if (relatedNodeIds.size <= selectedNodeIdSet.size && relatedEdgeIds.size <= selectedEdgeIdSet.size) {
- message.warning('当前选中的实体关系无法进行扩散');
- return;
- }
- await clearSelection();
- await setManyElementSelected([...relatedNodeIds, ...relatedEdgeIds]);
- syncSelection();
- message.success(`已选中 ${relatedNodeIds.size} 个实体`);
- };
- const copySelectionToClipboard = () => {
- const graph = graphRef.value;
- if (!graph || (!selectedNodeIds.value.length && !selectedEdgeIds.value.length)) {
- message.warning('请先选择要复制的元素');
- return;
- }
- syncRenderedNodePositionsToGraphData(graph);
- const nodeIdSet = new Set(selectedNodeIds.value);
- const nodes = cloneGraphData({ nodes: graph.getNodeData().filter((node) => nodeIdSet.has(String(node.id))) }).nodes;
- const edges = cloneGraphData({
- edges: graph
- .getEdgeData()
- .filter(
- (edge) =>
- selectedEdgeIds.value.includes(String(edge.id || '')) ||
- (nodeIdSet.has(String(edge.source)) && nodeIdSet.has(String(edge.target))),
- ),
- }).edges;
- graphClipboard.value = { nodes, edges };
- message.success(`已复制 ${nodes.length} 个实体,${edges.length} 条关系`);
- };
- const pasteClipboardToCanvas = async (position?: { x: number; y: number }) => {
- const graph = graphRef.value;
- const payload = graphClipboard.value;
- if (!graph || !payload || !payload.nodes.length) {
- message.warning('剪贴板为空');
- return;
- }
- const suffix = Date.now();
- const idMap = new Map<string, string>();
- const nodePositions = payload.nodes.map((node) => ({
- x: Number((node.style as any)?.x || 0),
- y: Number((node.style as any)?.y || 0),
- }));
- const minX = Math.min(...nodePositions.map((item) => item.x));
- const minY = Math.min(...nodePositions.map((item) => item.y));
- const offsetX = position ? position.x - minX : 56;
- const offsetY = position ? position.y - minY : 56;
- const nodes: NodeData[] = payload.nodes.map((node, index) => {
- const nextId = `${node.id}-copy-${suffix}-${index}`;
- idMap.set(String(node.id), nextId);
- return {
- ...node,
- id: nextId,
- combo: undefined,
- style: {
- ...(node.style || {}),
- x: Number((node.style as any)?.x || 0) + offsetX,
- y: Number((node.style as any)?.y || 0) + offsetY,
- },
- } as NodeData;
- });
- const edges: EdgeData[] = payload.edges
- .map((edge, index) => {
- const source = idMap.get(String(edge.source));
- const target = idMap.get(String(edge.target));
- if (!source || !target) return null;
- return {
- ...edge,
- id: `${edge.id || 'edge'}-copy-${suffix}-${index}`,
- source,
- target,
- } as EdgeData;
- })
- .filter((edge): edge is EdgeData => Boolean(edge));
- await withGraphMutation(async () => {
- clearAnalysisTotalCounts();
- graph.addNodeData(nodes as any);
- if (edges.length) graph.addEdgeData(edges);
- if (edges.length) {
- await refreshCurrentGraphEdgeCurves();
- } else {
- await graph.draw();
- }
- });
- await clearSelection();
- await setManyElementSelected(nodes.map((node) => String(node.id)));
- syncSelection();
- };
- const handleRemoveGroup = async () => {
- const comboId = selectedComboIds.value[0];
- if (!comboId) {
- message.warning('请先选择分组');
- return;
- }
- await ungroupCombo(comboId);
- };
- const handleCreateNewCanvas = async () => {
- saveActiveCanvasState();
- const shouldCopySelection = selectedSummary.value.total > 0;
- if (shouldCopySelection) {
- copySelectionToClipboard();
- }
- const label = `画布 ${graphCanvasTabs.value.length + 1}`;
- const preserveAnalysisTotals = !shouldCopySelection;
- const snapshot =
- shouldCopySelection && graphClipboard.value
- ? createSnapshotFromData({
- nodes: graphClipboard.value.nodes as NodeData[],
- edges: graphClipboard.value.edges as EdgeData[],
- combos: [],
- })
- : serializeGraph();
- graphCanvasTabs.value.push({
- id: `canvas-${Date.now()}`,
- label,
- snapshot: snapshot || EMPTY_GRAPH_SNAPSHOT,
- zoom: 1,
- lastSavedAt: '',
- analysisTotalNodeCount: preserveAnalysisTotals ? analysisTotalNodeCount.value : 0,
- analysisTotalEdgeCount: preserveAnalysisTotals ? analysisTotalEdgeCount.value : 0,
- dirty: false,
- });
- activeGraphCanvasIndex.value = graphCanvasTabs.value.length - 1;
- await restoreGraphSnapshot(graphCanvasTabs.value[activeGraphCanvasIndex.value].snapshot);
- };
- const handleToggleFullscreen = async () => {
- const target = graphWrapperRef.value || graphContainerRef.value;
- if (!target) return;
- try {
- if (document.fullscreenElement) {
- await document.exitFullscreen?.();
- } else {
- await target.requestFullscreen?.();
- }
- } catch (error) {
- console.error('toggle g6 fullscreen failed', error);
- message.error('切换全屏失败,请稍后重试');
- }
- };
- const handleForceLayout = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- toolbarRunningKey.value = 'forceLayout';
- analysisLayoutLoading.value = true;
- closeActiveToolbarPanel();
- try {
- await nextTick();
- await applySmartLayoutToGraphData(graph.getData());
- syncRenderedNodePositionsToGraphData(graph);
- refreshGraphSnapshot(true);
- saveActiveCanvasState();
- message.success(`已完成智能重布局:节点 ${graphStats.nodes},关系 ${graphStats.edges}`);
- } catch (error) {
- console.error('g6 force layout failed', error);
- message.error('智能布局失败,请稍后重试');
- } finally {
- analysisLayoutLoading.value = false;
- toolbarRunningKey.value = '';
- }
- };
- const openAnalysisDetail = async (elementId: string, kind: ElementKind) => {
- const target =
- kind === 'node' ? findNodeData(elementId) : kind === 'edge' ? findEdgeData(elementId) : findComboData(elementId);
- if (!target) {
- message.warning('当前节点没有可查看的详情');
- return;
- }
- if (kind === 'edge') {
- const edge = target as EdgeData;
- if (openCommunicationCallDetail(edge, kind)) return;
- if (openTradeEdgeTransDetail(edge)) return;
- callDetailVisible.value = false;
- transDetailVisible.value = false;
- const sourceName = getEdgeEndpointName(edge, 'source');
- const targetName = getEdgeEndpointName(edge, 'target');
- analysisDetailTitle.value = `${sourceName} -> ${targetName}`;
- const rawData = (edge.data?.rawData || edge.data || {}) as Record<string, any>;
- analysisDetailEntries.value = [
- { label: 'ID', value: String(edge.id || '-') },
- { label: '名称', value: getEdgeLabel(edge) },
- ...Object.entries(rawData)
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
- .slice(0, 24)
- .map(([label, value]) => ({ label, value: stringifyDetailValue(value) })),
- ];
- analysisDetailQueryBase.value = null;
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- analysisDetailPagination.value.total = 0;
- analysisDetailVisible.value = true;
- return;
- }
- if (kind === 'node') {
- const node = target as NodeData;
- if (openCommunicationCallDetail(node, kind)) return;
- callDetailVisible.value = false;
- transDetailVisible.value = false;
- analysisDetailTitle.value = String(node.data?.fullLabel || node.data?.label || '实体详情');
- analysisDetailLoading.value = true;
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- resetAnalysisDetailTableFilters();
- analysisDetailPagination.value = {
- page: 1,
- limit: componentSetting.table.defaultPageSize,
- total: 0,
- };
- if (shouldFetchNodeDetail(node)) {
- const query = buildGraphNodeDetailQuery(node);
- if (!query) {
- analysisDetailLoading.value = false;
- message.warning('当前节点缺少详情查询参数');
- return;
- }
- analysisDetailQueryBase.value = query;
- analysisDetailVisible.value = true;
- await fetchAnalysisDetailTable();
- return;
- }
- analysisDetailQueryBase.value = null;
- analysisDetailEntries.value = buildNodeDetailEntriesFromPayload(node.data?.rawData || node.data || {});
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- analysisDetailPagination.value.total = 0;
- analysisDetailLoading.value = false;
- analysisDetailVisible.value = true;
- return;
- }
- const rawData = (target.data?.rawData || target.data || {}) as Record<string, any>;
- callDetailVisible.value = false;
- transDetailVisible.value = false;
- analysisDetailTitle.value = '分组详情';
- analysisDetailQueryBase.value = null;
- analysisDetailTableColumns.value = [];
- analysisDetailTableRows.value = [];
- analysisDetailPagination.value.total = 0;
- analysisDetailEntries.value = [
- { label: 'ID', value: String(target.id || '-') },
- {
- label: '名称',
- value: getComboLabel(target as ComboData),
- },
- ...Object.entries(rawData)
- .filter(([, value]) => value !== undefined && value !== null && value !== '')
- .slice(0, 24)
- .map(([label, value]) => ({ label, value: stringifyDetailValue(value) })),
- ];
- analysisDetailVisible.value = true;
- };
- const isToolbarItemActive = (item: AnalysisToolbarItem) =>
- item.panelKey === activeToolbarPanel.value ||
- (item.key === 'fullScreen' && isFullscreen.value) ||
- toolbarRunningKey.value === item.key;
- const getToolbarItemDisabled = (key: AnalysisToolbarKey) => {
- if (key === 'undo') return !undoStack.value.length;
- if (key === 'redo') return !redoStack.value.length;
- if (key === 'forceLayout') return toolbarRunningKey.value === key;
- if (key === 'paste') return !graphClipboard.value;
- if (key === 'copy') return !selectedNodeIds.value.length;
- if (key === 'delete') return !selectedSummary.value.total;
- if (key === 'group') return selectedNodeIds.value.length < 2;
- if (key === 'ungroup') return !selectedComboIds.value.length;
- if (key === 'newCanvas') return !selectedNodeIds.value.length;
- if (key === 'mark') return !selectedNodeIds.value.length;
- if (key === 'selectNeighbor') return !selectedNodeIds.value.length && !selectedEdgeIds.value.length;
- return false;
- };
- const toggleToolbarPanel = (panel: ToolbarPanelKey) => {
- activeToolbarPanel.value = activeToolbarPanel.value === panel ? null : panel;
- };
- const closeActiveToolbarPanel = () => {
- activeToolbarPanel.value = null;
- };
- const handleAnalysisToolbarClick = (item: AnalysisToolbarItem) => {
- if (getToolbarItemDisabled(item.key)) return;
- if (item.panelKey) {
- toggleToolbarPanel(item.panelKey);
- return;
- }
- switch (item.key) {
- case 'undo':
- void handleUndo();
- return;
- case 'redo':
- void handleRedo();
- return;
- case 'mark':
- toggleToolbarPanel('mark');
- return;
- case 'selectNeighbor':
- void handleSelectNeighborEntity();
- return;
- case 'boxSelect':
- message.info('G6 实验页当前使用 Shift + 拖拽框选');
- return;
- case 'copy':
- copySelectionToClipboard();
- return;
- case 'paste':
- void pasteClipboardToCanvas();
- return;
- case 'delete':
- void handleDeleteSelected();
- return;
- case 'group':
- void handleCreateCombo();
- return;
- case 'ungroup':
- void handleRemoveGroup();
- return;
- case 'newCanvas':
- void handleCreateNewCanvas();
- return;
- case 'forceLayout':
- void handleForceLayout();
- return;
- case 'fullScreen':
- void handleToggleFullscreen();
- return;
- default:
- return;
- }
- };
- const handleZoomIn = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- await graph.zoomBy(1.12, { duration: 180 });
- refreshZoomLabel();
- };
- const handleZoomOut = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- await graph.zoomBy(0.88, { duration: 180 });
- refreshZoomLabel();
- };
- const handleResetGraph = async () => {
- const graph = graphRef.value;
- if (!graph) return;
- graphPerformanceMode.value = false;
- pendingEdgeSourceId.value = '';
- graph.setOptions({ animation: false, autoFit: false } as any);
- applyGraphInteractionOptions(false);
- graph.setNode(createNodeOptions() as any);
- graph.setEdge(createEdgeOptions() as any);
- graph.setCombo(createComboOptions() as any);
- clearGraphLayout(graph);
- graph.setData(createInitialData());
- await graph.render();
- await graph.fitView({ when: 'always' }, false);
- syncSelection();
- clearAnalysisTotalCounts();
- saveActiveCanvasState();
- };
- const getCurrentDateTimeLabel = () => {
- const date = new Date();
- const pad = (value: number) => String(value).padStart(2, '0');
- return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
- date.getMinutes(),
- )}`;
- };
- const normalizeHistoryItem = (item: GraphHistoryItem, index: number): LayoutSnapshot | null => {
- const id = String(item.id || item.filePath || item.path || `history-${index}`);
- const payload = String(item.graphData || item.graphJson || item.content || item.data || '');
- if (!id || !payload) return null;
- return {
- id,
- remoteId: item.id ? String(item.id) : undefined,
- name: String(item.graphHisName || item.name || item.graphName || item.title || `历史记录 ${index + 1}`),
- createdAt: String(item.createDateTime || item.createdAt || item.createTime || item.createDate || ''),
- nodeCount: Number(item.nodeCount || 0),
- edgeCount: Number(item.edgeCount || 0),
- payload,
- filePath: String(item.filePath || item.path || ''),
- };
- };
- const fetchSavedLayouts = async () => {
- historyLoading.value = true;
- try {
- const result = await fetchGraphHistory(routeGraphQuery.value);
- const rawList = Array.isArray(result)
- ? result
- : Array.isArray(result?.list)
- ? result.list
- : Array.isArray(result?.records)
- ? result.records
- : Array.isArray(result?.data)
- ? result.data
- : [];
- savedLayouts.value = rawList
- .map((item: GraphHistoryItem, index: number) => normalizeHistoryItem(item, index))
- .filter((item: LayoutSnapshot | null): item is LayoutSnapshot => Boolean(item))
- .slice(0, MAX_HISTORY_SNAPSHOTS);
- } catch (error) {
- console.error('load g6 graph history failed', error);
- savedLayouts.value = [];
- message.error('加载历史记录失败');
- } finally {
- historyLoading.value = false;
- }
- };
- const handleHistoryVisibleChange = (open: boolean) => {
- if (open) {
- void fetchSavedLayouts();
- }
- };
- const handleOpenSaveLayoutModal = () => {
- saveLayoutError.value = '';
- saveLayoutForm.name = activeGraphCanvas.value?.label || `G6关系图谱 ${getCurrentDateTimeLabel()}`;
- saveLayoutVisible.value = true;
- };
- const handleCloseSaveLayoutModal = () => {
- saveLayoutVisible.value = false;
- saveLayoutError.value = '';
- };
- const handleSaveLayout = async () => {
- const name = saveLayoutForm.name.trim();
- if (!name) {
- saveLayoutError.value = '请输入可视化关系名称';
- return;
- }
- const graph = graphRef.value;
- if (!graph) return;
- saveLayoutLoading.value = true;
- try {
- saveActiveCanvasState();
- const payload = serializeGraph();
- await createGraphHistory({
- ...routeGraphQuery.value,
- graphHisName: name,
- name,
- graphName: name,
- content: payload,
- graphData: payload,
- nodeCount: graphStats.nodes,
- edgeCount: graphStats.edges,
- });
- if (activeGraphCanvas.value) {
- activeGraphCanvas.value.label = name;
- activeGraphCanvas.value.lastSavedAt = getCurrentDateTimeLabel();
- }
- saveLayoutVisible.value = false;
- message.success('已保存 G6 图谱布局');
- if (historyVisible.value) {
- await fetchSavedLayouts();
- }
- } catch (error) {
- console.error('save g6 graph history failed', error);
- message.error('保存图谱布局失败');
- } finally {
- saveLayoutLoading.value = false;
- }
- };
- const handleRestoreSnapshot = async (snapshotId: string) => {
- const snapshot = savedLayouts.value.find((item) => item.id === snapshotId);
- if (!snapshot) return;
- activeHistoryId.value = snapshot.id;
- historyVisible.value = false;
- await restoreGraphSnapshot(snapshot.payload);
- if (activeGraphCanvas.value) {
- activeGraphCanvas.value.label = snapshot.name;
- activeGraphCanvas.value.snapshot = snapshot.payload;
- activeGraphCanvas.value.lastSavedAt = snapshot.createdAt;
- activeGraphCanvas.value.analysisTotalNodeCount = 0;
- activeGraphCanvas.value.analysisTotalEdgeCount = 0;
- }
- restoreAnalysisTotalCountsFromActiveCanvas();
- message.success(`已恢复历史记录:${snapshot.name}`);
- };
- const handleRemoveSnapshot = async (snapshotId: string) => {
- const snapshot = savedLayouts.value.find((item) => item.id === snapshotId);
- if (!snapshot) return;
- try {
- if (snapshot.remoteId) {
- await deleteGraphHistory({ id: snapshot.remoteId });
- }
- savedLayouts.value = savedLayouts.value.filter((item) => item.id !== snapshotId);
- if (activeHistoryId.value === snapshotId) activeHistoryId.value = '';
- message.success('已删除历史记录');
- } catch (error) {
- console.error('delete g6 graph history failed', error);
- message.error('删除历史记录失败');
- }
- };
- const ensureCanvasTabs = () => {
- if (graphCanvasTabs.value.length) return;
- graphCanvasTabs.value = [
- {
- id: `canvas-${Date.now()}`,
- label: '画布 1',
- snapshot: serializeGraph(),
- zoom: 1,
- lastSavedAt: '',
- analysisTotalNodeCount: 0,
- analysisTotalEdgeCount: 0,
- dirty: false,
- },
- ];
- activeGraphCanvasIndex.value = 0;
- restoreAnalysisTotalCountsFromActiveCanvas();
- };
- const isGraphCanvasDirty = (index: number) => {
- const tab = graphCanvasTabs.value[index];
- if (!tab || index !== activeGraphCanvasIndex.value) return false;
- return tab.dirty;
- };
- const switchGraphCanvas = async (index: number) => {
- if (index === activeGraphCanvasIndex.value) return;
- saveActiveCanvasState();
- activeGraphCanvasIndex.value = index;
- undoStack.value = [];
- redoStack.value = [];
- await restoreGraphSnapshot(graphCanvasTabs.value[index]?.snapshot || EMPTY_GRAPH_SNAPSHOT);
- };
- const handleAddGraphCanvas = async () => {
- saveActiveCanvasState();
- graphCanvasTabs.value.push({
- id: `canvas-${Date.now()}`,
- label: `画布 ${graphCanvasTabs.value.length + 1}`,
- snapshot: EMPTY_GRAPH_SNAPSHOT,
- zoom: 1,
- lastSavedAt: '',
- analysisTotalNodeCount: 0,
- analysisTotalEdgeCount: 0,
- dirty: false,
- });
- activeGraphCanvasIndex.value = graphCanvasTabs.value.length - 1;
- undoStack.value = [];
- redoStack.value = [];
- await restoreGraphSnapshot(EMPTY_GRAPH_SNAPSHOT, false);
- };
- const executeRemoveGraphCanvas = async (index: number) => {
- if (graphCanvasTabs.value.length <= 1) {
- message.info('至少保留一个画布');
- return;
- }
- if (index === activeGraphCanvasIndex.value) {
- saveActiveCanvasState();
- }
- graphCanvasTabs.value.splice(index, 1);
- activeGraphCanvasIndex.value = Math.min(activeGraphCanvasIndex.value, graphCanvasTabs.value.length - 1);
- await restoreGraphSnapshot(graphCanvasTabs.value[activeGraphCanvasIndex.value]?.snapshot || EMPTY_GRAPH_SNAPSHOT);
- };
- const handleRemoveGraphCanvas = (index: number) => {
- const targetCanvas = graphCanvasTabs.value[index];
- if (!targetCanvas) return;
- Modal.confirm({
- title: '关闭画布',
- content: `确认关闭“${targetCanvas.label}”吗?关闭后将删除该画布中的图谱内容。`,
- centered: true,
- okText: '确认关闭',
- cancelText: '取消',
- onOk: () => executeRemoveGraphCanvas(index),
- });
- };
- const handleUndo = async () => {
- const previousSnapshot = undoStack.value[undoStack.value.length - 1];
- if (!previousSnapshot) return;
- redoStack.value = [...redoStack.value, serializeGraph()].slice(-MAX_UNDO_STACK);
- undoStack.value = undoStack.value.slice(0, -1);
- await restoreGraphSnapshot(previousSnapshot, false);
- saveActiveCanvasState();
- };
- const handleRedo = async () => {
- const nextSnapshot = redoStack.value[redoStack.value.length - 1];
- if (!nextSnapshot) return;
- undoStack.value = [...undoStack.value, serializeGraph()].slice(-MAX_UNDO_STACK);
- redoStack.value = redoStack.value.slice(0, -1);
- await restoreGraphSnapshot(nextSnapshot, false);
- saveActiveCanvasState();
- };
- const handleClearCanvas = () => {
- if (!hasGraphData.value) {
- message.info('当前画布已经是空的');
- return;
- }
- Modal.confirm({
- title: '清空当前图谱',
- content: '清空后将移除当前画布中的节点和关系,可通过撤销恢复。',
- centered: true,
- okText: '确认清空',
- cancelText: '取消',
- onOk: async () => {
- await withGraphMutation(async () => {
- graphRef.value?.setData({ nodes: [], edges: [], combos: [] });
- await graphRef.value?.render();
- });
- clearAnalysisTotalCounts();
- saveActiveCanvasState();
- clearLocalSelection();
- },
- });
- };
- const handleDocumentFullscreenChange = () => {
- isFullscreen.value = Boolean(document.fullscreenElement);
- };
- onMounted(async () => {
- await nextTick();
- await createGraph();
- ensureCanvasTabs();
- document.addEventListener('fullscreenchange', handleDocumentFullscreenChange);
- const routePayload = buildRouteAnalysisPayload();
- if (routePayload) {
- void handleStartAnalysis(routePayload);
- }
- });
- onBeforeUnmount(() => {
- terminateAnalysisLayoutWorker();
- document.removeEventListener('fullscreenchange', handleDocumentFullscreenChange);
- graphRef.value?.destroy();
- graphRef.value = null;
- });
- </script>
- <style lang="less">
- .visual-relation-page {
- height: 100%;
- min-height: 0;
- width: calc(100% + 24px);
- margin: 0 -12px;
- display: flex;
- flex-direction: column;
- padding: 0;
- gap: 0;
- background: #edf3fb;
- box-sizing: border-box;
- overflow: hidden;
- .page-header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- padding: 10px 16px;
- border-radius: 0;
- border: 1px solid rgba(148, 163, 184, 0.24);
- background: rgba(255, 255, 255, 0.92);
- box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
- backdrop-filter: blur(10px);
- }
- .page-title-wrap {
- min-width: 0;
- }
- .page-title {
- font-size: 18px;
- font-weight: 700;
- color: #172554;
- line-height: 24px;
- }
- .page-subtitle {
- margin-top: 2px;
- font-size: 12px;
- color: #64748b;
- line-height: 18px;
- }
- .page-meta {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-wrap: wrap;
- justify-content: center;
- padding: 0 8px;
- color: #475569;
- font-size: 12px;
- }
- .page-meta__text {
- white-space: nowrap;
- }
- .page-actions {
- display: flex;
- align-items: center;
- gap: 8px;
- flex-shrink: 0;
- }
- .canvas-tabs-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- min-width: 0;
- padding: 2px 16px 0;
- }
- .canvas-tabs {
- display: flex;
- align-items: center;
- gap: 6px;
- flex: 1;
- min-width: 0;
- overflow-x: auto;
- padding: 0 2px 1px;
- }
- .canvas-tab {
- position: relative;
- display: inline-flex;
- align-items: center;
- gap: 5px;
- flex: 0 0 auto;
- max-width: 220px;
- height: 30px;
- padding: 0 9px 0 11px;
- border-radius: 7px;
- border: 1px solid #e2e8f0;
- background: #ffffff;
- box-shadow: none;
- cursor: pointer;
- transition:
- border-color 0.18s ease,
- background-color 0.18s ease,
- color 0.18s ease;
- }
- .canvas-tab:hover {
- border-color: rgba(59, 130, 246, 0.42);
- }
- .canvas-tab.is-active {
- border-color: #4d6bfe;
- background: #4d6bfe;
- box-shadow: none;
- }
- .canvas-tab__label {
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- color: #334155;
- font-size: 12px;
- font-weight: 600;
- line-height: 1;
- transition: color 0.18s ease;
- }
- .canvas-tab__dirty {
- width: 6px;
- height: 6px;
- flex-shrink: 0;
- border-radius: 999px;
- background: #f59e0b;
- box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.16);
- }
- .canvas-tab__close {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 16px;
- height: 16px;
- flex-shrink: 0;
- padding: 0;
- border: 0;
- border-radius: 5px;
- background: transparent;
- color: #94a3b8;
- cursor: pointer;
- transition:
- background-color 0.18s ease,
- color 0.18s ease;
- }
- .canvas-tab__close :deep(.anticon) {
- font-size: 12px;
- line-height: 1;
- }
- .canvas-tab__close:hover {
- background: rgba(15, 23, 42, 0.06);
- color: #334155;
- }
- .canvas-tab.is-active .canvas-tab__label {
- color: #ffffff;
- }
- .canvas-tab.is-active .canvas-tab__close {
- color: rgba(255, 255, 255, 0.88);
- }
- .canvas-tab.is-active .canvas-tab__close:hover {
- background: rgba(255, 255, 255, 0.14);
- color: #ffffff;
- }
- .canvas-tab.is-active .canvas-tab__dirty {
- background: #ffffff;
- box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.24);
- }
- .canvas-tab--add {
- justify-content: center;
- width: 30px;
- max-width: 30px;
- padding: 0;
- color: #2563eb;
- }
- .canvas-tabs::-webkit-scrollbar {
- height: 6px;
- }
- .canvas-tabs::-webkit-scrollbar-thumb {
- border-radius: 999px;
- background: rgba(148, 163, 184, 0.45);
- }
- .relation-stage {
- flex: 1;
- min-height: 0;
- overflow: hidden;
- }
- .relation-stage__canvas {
- position: relative;
- height: 100%;
- min-height: 0;
- overflow: hidden;
- border-radius: 0;
- border: 1px solid rgba(148, 163, 184, 0.24);
- background: #f4f7fb;
- box-shadow:
- inset 0 1px 0 rgba(255, 255, 255, 0.75),
- 0 18px 36px rgba(15, 23, 42, 0.06);
- }
- .relation-stage__graph {
- position: relative;
- z-index: 1;
- width: 100%;
- height: 100%;
- contain: layout;
- :deep(.g6-canvas),
- :deep(canvas) {
- transform: translateZ(0);
- backface-visibility: hidden;
- }
- }
- .relation-stage__layout-loading {
- position: absolute;
- inset: 0;
- z-index: 60;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 24px;
- background:
- linear-gradient(rgba(244, 247, 251, 0.8), rgba(244, 247, 251, 0.8)),
- repeating-linear-gradient(
- -45deg,
- rgba(148, 163, 184, 0.08) 0,
- rgba(148, 163, 184, 0.08) 10px,
- rgba(255, 255, 255, 0.08) 10px,
- rgba(255, 255, 255, 0.08) 20px
- );
- backdrop-filter: blur(2px);
- }
- .relation-stage__layout-loading-card {
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 10px;
- min-width: 220px;
- max-width: min(360px, calc(100% - 32px));
- padding: 28px 32px;
- border: 1px solid rgba(226, 232, 240, 0.9);
- border-radius: 16px;
- background: rgba(255, 255, 255, 0.96);
- box-shadow:
- 0 18px 36px rgba(15, 23, 42, 0.1),
- 0 2px 8px rgba(15, 23, 42, 0.05);
- text-align: center;
- }
- .relation-stage__layout-loading-spinner {
- width: 34px;
- height: 34px;
- border: 3px solid rgba(37, 99, 235, 0.16);
- border-top-color: #2563eb;
- border-radius: 999px;
- animation: relation-stage-layout-spin 0.9s linear infinite;
- }
- .relation-stage__layout-loading-title {
- font-size: 18px;
- font-weight: 700;
- line-height: 1.2;
- color: #111827;
- }
- .relation-stage__layout-loading-desc {
- font-size: 13px;
- line-height: 1.6;
- color: #6b7280;
- }
- .entity-palette {
- position: absolute;
- top: 20px;
- left: 20px;
- z-index: 12;
- width: 292px;
- padding: 14px 18px 12px;
- border-radius: 22px;
- border: 1px solid rgba(148, 163, 184, 0.16);
- background: rgba(255, 255, 255, 0.98);
- box-shadow:
- 0 18px 36px rgba(15, 23, 42, 0.1),
- inset 0 1px 0 rgba(255, 255, 255, 0.82);
- backdrop-filter: blur(12px);
- overflow: hidden;
- &::before {
- content: none;
- }
- &.is-collapsed {
- padding-bottom: 18px;
- }
- }
- .entity-palette__header {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- }
- .entity-palette__title {
- position: relative;
- font-size: 18px;
- font-weight: 700;
- color: #0f172a;
- letter-spacing: 0.01em;
- }
- .entity-palette__toggle {
- position: relative;
- width: 30px;
- min-width: 30px;
- height: 30px;
- padding: 0;
- border-radius: 10px;
- color: #5b6b82;
- background: rgba(255, 255, 255, 0.72);
- border: 1px solid rgba(148, 163, 184, 0.2);
- box-shadow: 0 8px 16px rgba(15, 23, 42, 0.06);
- &:hover,
- &:focus {
- color: #2563eb;
- background: rgba(239, 246, 255, 0.96);
- border-color: rgba(96, 165, 250, 0.36);
- }
- }
- .entity-palette__desc {
- position: relative;
- margin-top: 4px;
- max-width: 230px;
- font-size: 12px;
- line-height: 1.6;
- color: #64748b;
- }
- .entity-grid {
- position: relative;
- display: grid;
- grid-template-columns: repeat(4, minmax(0, 1fr));
- gap: 10px 14px;
- margin-top: 8px;
- padding: 2px 6px 0;
- border: 0;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- }
- .entity-grid__item {
- position: relative;
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: flex-start;
- gap: 10px;
- min-height: 108px;
- padding: 12px 8px 10px;
- border: none;
- border-radius: 0;
- background: transparent;
- box-shadow: none;
- overflow: hidden;
- cursor: grab;
- appearance: none;
- transition:
- transform 0.2s ease,
- background-color 0.2s ease;
- &::before {
- content: none;
- }
- }
- .entity-grid__item:hover {
- transform: translateY(-3px);
- background: rgba(255, 255, 255, 0.72);
- }
- .entity-grid__item:active {
- cursor: grabbing;
- transform: translateY(0) scale(0.98);
- }
- .entity-grid__item:focus-visible {
- outline: 2px solid rgba(59, 130, 246, 0.35);
- outline-offset: 2px;
- }
- .entity-grid__icon {
- position: relative;
- display: flex;
- align-items: center;
- justify-content: center;
- width: 38px;
- height: 38px;
- border-radius: 12px;
- background: var(--node-accent-color, #667085);
- box-shadow:
- 0 10px 18px rgba(15, 23, 42, 0.12),
- inset 0 1px 0 rgba(255, 255, 255, 0.2);
- &::after {
- content: '';
- position: absolute;
- inset: -1px;
- border-radius: inherit;
- border: 1px solid rgba(255, 255, 255, 0.22);
- pointer-events: none;
- }
- }
- .entity-grid__icon img {
- position: relative;
- z-index: 1;
- width: 32px;
- height: 32px;
- object-fit: contain;
- }
- .entity-grid__label {
- display: flex;
- align-items: center;
- justify-content: center;
- width: 100%;
- min-height: 34px;
- padding: 2px 2px 4px;
- font-size: 15px;
- font-weight: 700;
- color: #16202a;
- line-height: 1.35;
- text-align: center;
- letter-spacing: 0;
- white-space: nowrap;
- word-break: keep-all;
- overflow: visible;
- text-overflow: ellipsis;
- }
- .relation-toggle {
- display: flex;
- gap: 10px;
- margin-top: 18px;
- padding: 6px;
- border-radius: 14px;
- background: #f1f5f9;
- }
- .relation-toggle__item {
- flex: 1;
- height: 36px;
- border: none;
- border-radius: 10px;
- background: transparent;
- color: #475569;
- font-size: 13px;
- font-weight: 600;
- cursor: pointer;
- transition: all 0.2s ease;
- }
- .relation-toggle__item.is-active {
- color: #1d4ed8;
- background: #ffffff;
- box-shadow: 0 8px 18px rgba(59, 130, 246, 0.14);
- }
- .entity-palette__action {
- margin-top: 8px;
- height: 42px;
- border-radius: 14px;
- font-weight: 700;
- box-shadow: 0 14px 28px rgba(49, 94, 251, 0.24);
- &:hover {
- box-shadow: 0 16px 30px rgba(49, 94, 251, 0.28);
- }
- }
- .control-tools-box {
- position: absolute;
- top: 24px;
- right: 24px;
- z-index: 40;
- display: flex;
- flex-direction: column;
- align-items: flex-end;
- gap: 12px;
- max-width: calc(100% - 48px);
- pointer-events: none;
- }
- .custom-atlas-tools-container {
- position: relative;
- overflow: visible;
- border-radius: 10px;
- pointer-events: auto;
- max-width: min(760px, calc(100vw - 360px));
- ul {
- display: flex;
- align-items: center;
- justify-content: flex-end;
- flex-wrap: wrap;
- margin: 0;
- padding: 0;
- list-style: none;
- }
- }
- .tool-item {
- box-sizing: border-box;
- width: 40px;
- height: 44px;
- display: inline-flex;
- align-items: center;
- justify-content: center;
- background: rgba(255, 255, 255, 0.96);
- border: 1px solid rgba(148, 163, 184, 0.18);
- box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
- cursor: pointer;
- transition:
- background-color 0.2s ease,
- box-shadow 0.2s ease,
- transform 0.2s ease;
- &:first-child {
- border-top-left-radius: 8px;
- border-bottom-left-radius: 8px;
- }
- &:last-child {
- border-top-right-radius: 8px;
- border-bottom-right-radius: 8px;
- }
- &:hover {
- background-color: #e8ebfb;
- box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
- transform: translateY(-1px);
- .icon-style {
- color: #2563eb;
- }
- }
- }
- .tool-item.is-active {
- background-color: #e8ebfb;
- box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
- .icon-style {
- color: #2563eb;
- }
- }
- .tool-item.disabled-tool-item {
- cursor: not-allowed;
- background: rgba(255, 255, 255, 0.88);
- box-shadow: none;
- .icon-style {
- color: #94a3b8;
- }
- }
- .tool-item.disabled-tool-item:hover {
- transform: none;
- background: rgba(255, 255, 255, 0.88);
- box-shadow: none;
- }
- .tool-item-icon {
- display: inline-flex;
- align-items: center;
- justify-content: center;
- width: 24px;
- height: 24px;
- }
- .tool-item-icon--running {
- animation: tool-icon-rotate 2s linear infinite;
- }
- .gap-item {
- margin-right: 16px;
- border-top-right-radius: 8px;
- border-bottom-right-radius: 8px;
- & + .tool-item {
- border-top-left-radius: 8px;
- border-bottom-left-radius: 8px;
- }
- }
- .icon-style {
- font-size: 20px;
- color: #303133;
- transition: color 0.2s ease;
- }
- @keyframes tool-icon-rotate {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
- @keyframes relation-stage-layout-spin {
- from {
- transform: rotate(0deg);
- }
- to {
- transform: rotate(360deg);
- }
- }
- .tool-panel {
- width: min(360px, calc(100vw - 48px));
- padding: 14px;
- border-radius: 16px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(255, 255, 255, 0.96);
- box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12);
- pointer-events: auto;
- }
- .tool-panel :deep(.ant-input),
- .tool-panel :deep(.ant-select),
- .tool-panel :deep(.ant-input-number) {
- width: 100%;
- }
- .tool-panel__row {
- width: 100%;
- display: block;
- }
- .tool-panel__entity-select {
- display: block;
- width: 100%;
- min-width: 0;
- }
- .tool-panel__entity-select :deep(.ant-select-selector) {
- min-height: 40px;
- padding-inline: 12px;
- }
- .tool-panel__entity-select :deep(.ant-select-selection-wrap),
- .tool-panel__entity-select :deep(.ant-select-selection-overflow) {
- width: 100%;
- min-width: 0;
- }
- .tool-panel__entity-select :deep(.ant-select-selection-item),
- .tool-panel__entity-select :deep(.ant-select-selection-placeholder) {
- max-width: 100%;
- }
- .tool-panel__header {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 12px;
- }
- .tool-panel__title {
- color: #1e293b;
- font-size: 14px;
- font-weight: 700;
- }
- .tool-panel__close {
- padding: 0 4px;
- color: #64748b;
- }
- .tool-panel__row + .tool-panel__row,
- .tool-panel__row + .tool-panel__switch-row,
- .tool-panel__switch-row + .tool-panel__row,
- .tool-panel__switch-row + .tool-panel__switch-row {
- margin-top: 10px;
- }
- .tool-panel__switch-row {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- }
- .tool-panel__range-row {
- display: flex;
- align-items: center;
- gap: 8px;
- }
- .tool-panel__range-separator {
- flex: 0 0 auto;
- color: #64748b;
- font-size: 12px;
- white-space: nowrap;
- }
- .tool-panel__switch {
- border: 1px solid rgba(148, 163, 184, 0.3);
- background: #f8fafc;
- color: #475569;
- border-radius: 10px;
- padding: 6px 12px;
- cursor: pointer;
- transition:
- background-color 0.18s ease,
- border-color 0.18s ease,
- color 0.18s ease;
- }
- .tool-panel__switch--compact {
- min-width: 48px;
- padding-inline: 0;
- }
- .tool-panel__switch.is-active {
- color: #1d4ed8;
- border-color: rgba(59, 130, 246, 0.42);
- background: rgba(59, 130, 246, 0.12);
- }
- .tool-panel__number {
- flex: 1;
- min-width: 110px;
- }
- .tool-panel__footer {
- margin-top: 12px;
- }
- .tool-panel__footer--find {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .tool-panel__result,
- .tool-panel__tip {
- color: #64748b;
- font-size: 12px;
- margin-bottom: 10px;
- }
- .tool-panel__footer--find .tool-panel__result {
- margin-bottom: 0;
- white-space: nowrap;
- }
- .tool-panel__actions {
- display: flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 8px;
- }
- .tool-panel__actions--find {
- flex-wrap: nowrap;
- justify-content: flex-start;
- gap: 8px;
- }
- .tool-panel__actions--find :deep(.ant-btn) {
- height: 32px;
- padding: 0 14px;
- border-radius: 8px;
- white-space: nowrap;
- flex: 0 0 auto;
- }
- .tool-panel__swatches {
- display: flex;
- flex-wrap: wrap;
- gap: 12px;
- padding: 2px 2px 6px;
- }
- .tool-panel__swatch {
- width: 28px;
- height: 28px;
- border-radius: 999px;
- border: 2px solid rgba(255, 255, 255, 0.82);
- box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
- cursor: pointer;
- }
- .canvas-toolbar {
- position: relative;
- z-index: 1;
- display: flex;
- align-items: center;
- gap: 4px;
- padding: 8px;
- border-radius: 18px;
- border: 1px solid rgba(148, 163, 184, 0.2);
- background: rgba(255, 255, 255, 0.97);
- box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
- pointer-events: auto;
- }
- .canvas-toolbar__button {
- width: 38px;
- height: 38px;
- border-radius: 12px;
- color: #334155;
- }
- .canvas-toolbar__button:hover:not(:disabled) {
- color: #2563eb;
- background: rgba(59, 130, 246, 0.1);
- }
- .canvas-toolbar__divider {
- width: 1px;
- height: 18px;
- background: rgba(148, 163, 184, 0.35);
- margin: 0 2px;
- }
- .box-select-rect {
- position: absolute;
- z-index: 10;
- border: 1px solid rgba(37, 99, 235, 0.9);
- border-radius: 12px;
- background: rgba(59, 130, 246, 0.12);
- box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.58);
- pointer-events: none;
- }
- .graph-context-menu {
- position: fixed;
- z-index: 30;
- display: flex;
- flex-direction: column;
- min-width: 168px;
- padding: 4px;
- border: 1px solid rgba(148, 163, 184, 0.24);
- border-radius: 10px;
- background: rgba(255, 255, 255, 0.98);
- box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
- }
- .graph-context-menu__item {
- border: 0;
- background: transparent;
- color: #334155;
- text-align: left;
- padding: 9px 12px;
- border-radius: 8px;
- cursor: pointer;
- font-size: 13px;
- transition:
- background-color 0.18s ease,
- color 0.18s ease;
- }
- .graph-context-menu__item:hover:not(:disabled) {
- background: #eff6ff;
- color: #1d4ed8;
- }
- .graph-context-menu__item:disabled {
- color: #94a3b8;
- cursor: not-allowed;
- }
- .graph-context-menu__divider {
- height: 1px;
- margin: 4px 8px;
- background: rgba(148, 163, 184, 0.24);
- }
- .graph-context-menu__item.is-danger:hover:not(:disabled) {
- background: #fff1f2;
- color: #dc2626;
- }
- .canvas-empty {
- position: absolute;
- inset: 50% auto auto 50%;
- z-index: 6;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 10px;
- width: min(420px, calc(100% - 80px));
- transform: translate(-50%, -50%);
- text-align: center;
- pointer-events: none;
- }
- .canvas-empty__icon {
- position: relative;
- width: 130px;
- height: 96px;
- }
- .canvas-empty__icon .node {
- position: absolute;
- display: block;
- border-radius: 999px;
- background: linear-gradient(180deg, #8b5cf6 0%, #3b82f6 100%);
- box-shadow: 0 14px 24px rgba(99, 102, 241, 0.24);
- }
- .canvas-empty__icon .node-main {
- width: 26px;
- height: 26px;
- left: 52px;
- top: 34px;
- }
- .canvas-empty__icon .node-branch {
- width: 18px;
- height: 18px;
- background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
- }
- .canvas-empty__icon .node-branch::before {
- content: '';
- position: absolute;
- inset: 50% auto auto 50%;
- width: 38px;
- height: 2px;
- transform-origin: left center;
- background: rgba(96, 165, 250, 0.5);
- }
- .canvas-empty__icon .node-branch--left {
- left: 0;
- top: 40px;
- }
- .canvas-empty__icon .node-branch--left::before {
- transform: rotate(0deg);
- }
- .canvas-empty__icon .node-branch--right {
- right: 0;
- top: 40px;
- }
- .canvas-empty__icon .node-branch--right::before {
- width: 36px;
- left: -2px;
- transform-origin: right center;
- transform: rotate(180deg);
- }
- .canvas-empty__icon .node-branch--top {
- left: 56px;
- top: 0;
- }
- .canvas-empty__icon .node-branch--top::before {
- width: 34px;
- left: 50%;
- top: calc(100% + 8px);
- transform-origin: center top;
- transform: translateX(-50%) rotate(90deg);
- }
- .canvas-empty__title {
- font-size: 20px;
- font-weight: 700;
- color: #0f172a;
- }
- .canvas-empty__desc {
- max-width: 420px;
- font-size: 13px;
- line-height: 1.7;
- color: #64748b;
- }
- .canvas-status {
- position: absolute;
- right: 24px;
- bottom: 20px;
- z-index: 8;
- display: inline-flex;
- flex-wrap: wrap;
- justify-content: flex-end;
- gap: 10px;
- max-width: calc(100% - 48px);
- }
- .canvas-status__item {
- padding: 8px 12px;
- border-radius: 999px;
- background: rgba(15, 23, 42, 0.86);
- color: rgba(255, 255, 255, 0.88);
- font-size: 12px;
- line-height: 1;
- }
- .history-loading {
- padding: 42px 0;
- text-align: center;
- color: #64748b;
- font-size: 13px;
- }
- }
- .graph-history-popover {
- .ant-popover-inner {
- padding: 0;
- border-radius: 0;
- box-shadow: 10px 0 12px rgba(173, 174, 177, 0.24);
- }
- .ant-popover-inner-content {
- padding: 0;
- }
- }
- .graph-history-action-popover {
- .ant-popover-inner {
- padding: 0;
- border-radius: 4px;
- box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
- }
- .ant-popover-inner-content {
- padding: 0;
- }
- }
- .history-panel {
- box-sizing: border-box;
- width: 367px;
- height: 426px;
- padding: 16px;
- background: #fff;
- }
- .history-table {
- height: 100%;
- min-height: 0;
- border-bottom: 1px solid #eef2f7;
- overflow: hidden;
- }
- .history-table__head,
- .history-table__row {
- display: grid;
- grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr) 60px;
- align-items: center;
- }
- .history-table__head {
- height: 32px;
- color: #334155;
- font-size: 14px;
- border-bottom: 1px solid #edf1f7;
- }
- .history-table__body {
- max-height: calc(100% - 32px);
- overflow: auto;
- }
- .history-table__row {
- height: 44px;
- color: #1f2937;
- cursor: pointer;
- transition: background-color 0.16s ease;
- &:hover,
- &.is-active {
- background: #f5f7fb;
- }
- }
- .history-table__cell {
- min-width: 0;
- padding: 0 10px;
- font-size: 14px;
- line-height: 20px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- & + .history-table__cell {
- border-left: 1px solid #edf1f7;
- }
- }
- .history-table__cell--action {
- display: flex;
- align-items: center;
- justify-content: center;
- }
- .history-table__more {
- width: 28px;
- height: 28px;
- color: #1f2937;
- }
- .history-actions-menu {
- width: 96px;
- padding: 10px 0;
- background: #fff;
- }
- .history-actions-menu__item {
- width: 100%;
- height: 38px;
- padding: 0 14px;
- border: 0;
- background: transparent;
- color: #334155;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- font-size: 14px;
- text-align: left;
- &:hover {
- background: rgba(15, 23, 42, 0.04);
- color: #111827;
- }
- }
- .history-empty {
- padding-top: 96px;
- }
- .g6-editor-page .edge-create-tip {
- position: absolute;
- top: 86px;
- left: 50%;
- z-index: 40;
- display: flex;
- align-items: center;
- gap: 8px;
- padding: 8px 12px;
- border-radius: 999px;
- border: 1px solid rgba(96, 165, 250, 0.32);
- background: rgba(239, 246, 255, 0.96);
- color: #1d4ed8;
- box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
- transform: translateX(-50%);
- }
- .custom-atlas-analyse-node-container {
- width: 100%;
- height: 100%;
- position: relative;
- box-sizing: border-box;
- overflow: visible;
- border: 1px solid transparent;
- background: transparent;
- contain: layout paint style;
- transform: translateZ(0);
- backface-visibility: hidden;
- .node-icon-tile {
- position: absolute;
- top: 16px;
- left: 50%;
- z-index: 2;
- width: 40px;
- height: 40px;
- border-radius: 11px;
- transform: translateX(-50%);
- background: var(--node-accent-color, #667085);
- display: flex;
- align-items: center;
- justify-content: center;
- box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1);
- pointer-events: none;
- img {
- width: 32px;
- height: 32px;
- object-fit: contain;
- }
- .node-expand-badge {
- position: absolute;
- top: -7px;
- right: -10px;
- min-width: 18px;
- height: 18px;
- padding: 0 5px;
- border-radius: 999px;
- background: #2563eb;
- color: #ffffff;
- font-size: 10px;
- font-weight: 700;
- line-height: 18px;
- text-align: center;
- box-shadow: 0 6px 12px rgba(37, 99, 235, 0.28);
- }
- }
- .input-value-box {
- position: absolute;
- top: 60px;
- left: 50%;
- width: 96px;
- min-height: 40px;
- padding: 0 8px;
- display: flex;
- flex-direction: column;
- align-items: center;
- justify-content: center;
- transform: translateX(-50%);
- color: #334155;
- text-align: center;
- pointer-events: none;
- gap: 2px;
- overflow: hidden;
- box-sizing: border-box;
- }
- .input-value-box span {
- display: block;
- max-width: 100%;
- overflow: hidden;
- white-space: nowrap;
- word-break: normal;
- text-overflow: ellipsis;
- }
- .input-value-box__primary {
- font-size: 14px;
- line-height: 18px;
- font-weight: 600;
- color: #334155;
- }
- .input-value-box__secondary {
- max-width: none;
- font-size: 12px;
- line-height: 16px;
- font-weight: 500;
- color: #94a3b8;
- }
- }
- .custom-atlas-analyse-node-container.is-selected {
- border-color: var(--themeColor);
- background: rgba(49, 94, 251, 0.06);
- border-radius: 6px;
- }
- .custom-atlas-analyse-node-container.is-dimmed {
- opacity: 0.42;
- }
- .custom-atlas-analyse-node-container.is-large-graph-lite {
- .node-icon-tile {
- top: 18px;
- width: 42px;
- height: 42px;
- }
- .input-value-box {
- top: 62px;
- width: 88px;
- min-height: 28px;
- gap: 0;
- }
- .input-value-box__primary {
- font-size: 12px;
- line-height: 16px;
- font-weight: 600;
- }
- }
- .x6-node-selected .custom-atlas-analyse-node-container,
- .custom-atlas-analyse-node-container:hover {
- border-color: var(--themeColor);
- background: rgba(49, 94, 251, 0.06);
- border-radius: 6px;
- }
- .graph-analysis-detail-modal {
- .ant-modal {
- max-width: calc(100vw - 48px);
- }
- .ant-modal-body > .scroll-container,
- .ant-modal-body > .scroll-container > .scrollbar__wrap,
- .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view,
- .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view > div {
- height: 100%;
- min-height: 0;
- }
- .ant-modal-body > .scroll-container > .scrollbar__wrap {
- overflow: hidden;
- }
- .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view {
- margin: 0 !important;
- }
- .ant-modal-content {
- border-radius: 6px;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- height: min(720px, calc(100vh - 80px));
- max-height: min(720px, calc(100vh - 80px));
- }
- .ant-modal-header {
- padding: 12px 16px;
- border-bottom: 1px solid #e5e7eb;
- flex: 0 0 auto;
- background: @modal-header-bg-color;
- }
- .ant-modal-body {
- flex: 1 1 auto;
- min-height: 0;
- overflow: hidden;
- display: flex;
- flex-direction: column;
- padding: 16px;
- }
- }
- .analysis-detail {
- display: flex;
- flex-direction: column;
- flex: 1 1 auto;
- min-height: 0;
- gap: 16px;
- }
- .analysis-detail__summary {
- display: grid;
- grid-template-columns: repeat(2, minmax(0, 1fr));
- gap: 12px;
- }
- .analysis-detail__entry {
- padding: 14px 16px;
- border: 1px solid #e2e8f0;
- border-radius: 14px;
- background: #f8fbff;
- }
- .analysis-detail__entry-label {
- font-size: 12px;
- color: #64748b;
- line-height: 1;
- }
- .analysis-detail__entry-value {
- margin-top: 8px;
- font-size: 14px;
- color: #0f172a;
- line-height: 1.6;
- word-break: break-all;
- }
- .analysis-associate-form {
- display: flex;
- flex-direction: column;
- gap: 14px;
- }
- .analysis-associate-form__item {
- display: flex;
- flex-direction: column;
- gap: 8px;
- }
- .analysis-associate-form__label {
- font-size: 13px;
- font-weight: 600;
- color: #334155;
- }
- .analysis-associate-form__hint {
- padding: 10px 12px;
- border-radius: 12px;
- background: #f8fafc;
- color: #64748b;
- font-size: 12px;
- line-height: 1.7;
- }
- @media screen and (max-width: 1280px) {
- .visual-relation-page {
- .page-header {
- flex-wrap: wrap;
- }
- .page-meta {
- justify-content: flex-start;
- padding: 0;
- }
- .canvas-tabs-bar {
- align-items: center;
- flex-wrap: wrap;
- }
- .control-tools-box {
- left: 332px;
- right: 16px;
- align-items: stretch;
- max-width: none;
- }
- .custom-atlas-tools-container {
- max-width: 100%;
- }
- .custom-atlas-tools-container ul {
- justify-content: flex-start;
- }
- .canvas-toolbar {
- align-self: flex-end;
- max-width: 100%;
- flex-wrap: wrap;
- justify-content: flex-end;
- }
- }
- }
- @media screen and (max-width: 960px) {
- .visual-relation-page {
- padding: 10px;
- .relation-stage__canvas {
- min-height: 560px;
- }
- .entity-palette {
- width: calc(100% - 32px);
- }
- .entity-grid {
- grid-template-columns: repeat(4, minmax(0, 1fr));
- }
- .control-tools-box {
- top: 16px;
- left: 16px;
- right: 16px;
- gap: 10px;
- align-items: stretch;
- }
- .custom-atlas-tools-container {
- max-width: 100%;
- }
- .tool-panel {
- width: min(320px, calc(100vw - 32px));
- }
- .canvas-toolbar {
- align-self: flex-end;
- max-width: 100%;
- padding: 6px;
- flex-wrap: wrap;
- justify-content: flex-end;
- }
- .canvas-status {
- left: 16px;
- right: 16px;
- justify-content: flex-start;
- }
- }
- .analysis-detail__summary {
- grid-template-columns: 1fr;
- }
- }
- </style>
|