index.vue 262 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832483348344835483648374838483948404841484248434844484548464847484848494850485148524853485448554856485748584859486048614862486348644865486648674868486948704871487248734874487548764877487848794880488148824883488448854886488748884889489048914892489348944895489648974898489949004901490249034904490549064907490849094910491149124913491449154916491749184919492049214922492349244925492649274928492949304931493249334934493549364937493849394940494149424943494449454946494749484949495049514952495349544955495649574958495949604961496249634964496549664967496849694970497149724973497449754976497749784979498049814982498349844985498649874988498949904991499249934994499549964997499849995000500150025003500450055006500750085009501050115012501350145015501650175018501950205021502250235024502550265027502850295030503150325033503450355036503750385039504050415042504350445045504650475048504950505051505250535054505550565057505850595060506150625063506450655066506750685069507050715072507350745075507650775078507950805081508250835084508550865087508850895090509150925093509450955096509750985099510051015102510351045105510651075108510951105111511251135114511551165117511851195120512151225123512451255126512751285129513051315132513351345135513651375138513951405141514251435144514551465147514851495150515151525153515451555156515751585159516051615162516351645165516651675168516951705171517251735174517551765177517851795180518151825183518451855186518751885189519051915192519351945195519651975198519952005201520252035204520552065207520852095210521152125213521452155216521752185219522052215222522352245225522652275228522952305231523252335234523552365237523852395240524152425243524452455246524752485249525052515252525352545255525652575258525952605261526252635264526552665267526852695270527152725273527452755276527752785279528052815282528352845285528652875288528952905291529252935294529552965297529852995300530153025303530453055306530753085309531053115312531353145315531653175318531953205321532253235324532553265327532853295330533153325333533453355336533753385339534053415342534353445345534653475348534953505351535253535354535553565357535853595360536153625363536453655366536753685369537053715372537353745375537653775378537953805381538253835384538553865387538853895390539153925393539453955396539753985399540054015402540354045405540654075408540954105411541254135414541554165417541854195420542154225423542454255426542754285429543054315432543354345435543654375438543954405441544254435444544554465447544854495450545154525453545454555456545754585459546054615462546354645465546654675468546954705471547254735474547554765477547854795480548154825483548454855486548754885489549054915492549354945495549654975498549955005501550255035504550555065507550855095510551155125513551455155516551755185519552055215522552355245525552655275528552955305531553255335534553555365537553855395540554155425543554455455546554755485549555055515552555355545555555655575558555955605561556255635564556555665567556855695570557155725573557455755576557755785579558055815582558355845585558655875588558955905591559255935594559555965597559855995600560156025603560456055606560756085609561056115612561356145615561656175618561956205621562256235624562556265627562856295630563156325633563456355636563756385639564056415642564356445645564656475648564956505651565256535654565556565657565856595660566156625663566456655666566756685669567056715672567356745675567656775678567956805681568256835684568556865687568856895690569156925693569456955696569756985699570057015702570357045705570657075708570957105711571257135714571557165717571857195720572157225723572457255726572757285729573057315732573357345735573657375738573957405741574257435744574557465747574857495750575157525753575457555756575757585759576057615762576357645765576657675768576957705771577257735774577557765777577857795780578157825783578457855786578757885789579057915792579357945795579657975798579958005801580258035804580558065807580858095810581158125813581458155816581758185819582058215822582358245825582658275828582958305831583258335834583558365837583858395840584158425843584458455846584758485849585058515852585358545855585658575858585958605861586258635864586558665867586858695870587158725873587458755876587758785879588058815882588358845885588658875888588958905891589258935894589558965897589858995900590159025903590459055906590759085909591059115912591359145915591659175918591959205921592259235924592559265927592859295930593159325933593459355936593759385939594059415942594359445945594659475948594959505951595259535954595559565957595859595960596159625963596459655966596759685969597059715972597359745975597659775978597959805981598259835984598559865987598859895990599159925993599459955996599759985999600060016002600360046005600660076008600960106011601260136014601560166017601860196020602160226023602460256026602760286029603060316032603360346035603660376038603960406041604260436044604560466047604860496050605160526053605460556056605760586059606060616062606360646065606660676068606960706071607260736074607560766077607860796080608160826083608460856086608760886089609060916092609360946095609660976098609961006101610261036104610561066107610861096110611161126113611461156116611761186119612061216122612361246125612661276128612961306131613261336134613561366137613861396140614161426143614461456146614761486149615061516152615361546155615661576158615961606161616261636164616561666167616861696170617161726173617461756176617761786179618061816182618361846185618661876188618961906191619261936194619561966197619861996200620162026203620462056206620762086209621062116212621362146215621662176218621962206221622262236224622562266227622862296230623162326233623462356236623762386239624062416242624362446245624662476248624962506251625262536254625562566257625862596260626162626263626462656266626762686269627062716272627362746275627662776278627962806281628262836284628562866287628862896290629162926293629462956296629762986299630063016302630363046305630663076308630963106311631263136314631563166317631863196320632163226323632463256326632763286329633063316332633363346335633663376338633963406341634263436344634563466347634863496350635163526353635463556356635763586359636063616362636363646365636663676368636963706371637263736374637563766377637863796380638163826383638463856386638763886389639063916392639363946395639663976398639964006401640264036404640564066407640864096410641164126413641464156416641764186419642064216422642364246425642664276428642964306431643264336434643564366437643864396440644164426443644464456446644764486449645064516452645364546455645664576458645964606461646264636464646564666467646864696470647164726473647464756476647764786479648064816482648364846485648664876488648964906491649264936494649564966497649864996500650165026503650465056506650765086509651065116512651365146515651665176518651965206521652265236524652565266527652865296530653165326533653465356536653765386539654065416542654365446545654665476548654965506551655265536554655565566557655865596560656165626563656465656566656765686569657065716572657365746575657665776578657965806581658265836584658565866587658865896590659165926593659465956596659765986599660066016602660366046605660666076608660966106611661266136614661566166617661866196620662166226623662466256626662766286629663066316632663366346635663666376638663966406641664266436644664566466647664866496650665166526653665466556656665766586659666066616662666366646665666666676668666966706671667266736674667566766677667866796680668166826683668466856686668766886689669066916692669366946695669666976698669967006701670267036704670567066707670867096710671167126713671467156716671767186719672067216722672367246725672667276728672967306731673267336734673567366737673867396740674167426743674467456746674767486749675067516752675367546755675667576758675967606761676267636764676567666767676867696770677167726773677467756776677767786779678067816782678367846785678667876788678967906791679267936794679567966797679867996800680168026803680468056806680768086809681068116812681368146815681668176818681968206821682268236824682568266827682868296830683168326833683468356836683768386839684068416842684368446845684668476848684968506851685268536854685568566857685868596860686168626863686468656866686768686869687068716872687368746875687668776878687968806881688268836884688568866887688868896890689168926893689468956896689768986899690069016902690369046905690669076908690969106911691269136914691569166917691869196920692169226923692469256926692769286929693069316932693369346935693669376938693969406941694269436944694569466947694869496950695169526953695469556956695769586959696069616962696369646965696669676968696969706971697269736974697569766977697869796980698169826983698469856986698769886989699069916992699369946995699669976998699970007001700270037004700570067007700870097010701170127013701470157016701770187019702070217022702370247025702670277028702970307031703270337034703570367037703870397040704170427043704470457046704770487049705070517052705370547055705670577058705970607061706270637064706570667067706870697070707170727073707470757076707770787079708070817082708370847085708670877088708970907091709270937094709570967097709870997100710171027103710471057106710771087109711071117112711371147115711671177118711971207121712271237124712571267127712871297130713171327133713471357136713771387139714071417142714371447145714671477148714971507151715271537154715571567157715871597160716171627163716471657166716771687169717071717172717371747175717671777178717971807181718271837184718571867187718871897190719171927193719471957196719771987199720072017202720372047205720672077208720972107211721272137214721572167217721872197220722172227223722472257226722772287229723072317232723372347235723672377238723972407241724272437244724572467247724872497250725172527253725472557256725772587259726072617262726372647265726672677268726972707271727272737274727572767277727872797280728172827283728472857286728772887289729072917292729372947295729672977298729973007301730273037304730573067307730873097310731173127313731473157316731773187319732073217322732373247325732673277328732973307331733273337334733573367337733873397340734173427343734473457346734773487349735073517352735373547355735673577358735973607361736273637364736573667367736873697370737173727373737473757376737773787379738073817382738373847385738673877388738973907391739273937394739573967397739873997400740174027403740474057406740774087409741074117412741374147415741674177418741974207421742274237424742574267427742874297430743174327433743474357436743774387439744074417442744374447445744674477448744974507451745274537454745574567457745874597460746174627463746474657466746774687469747074717472747374747475747674777478747974807481748274837484748574867487748874897490749174927493749474957496749774987499750075017502750375047505750675077508750975107511751275137514751575167517751875197520752175227523752475257526752775287529753075317532753375347535753675377538753975407541754275437544754575467547754875497550755175527553755475557556755775587559756075617562756375647565756675677568756975707571757275737574757575767577757875797580758175827583758475857586758775887589759075917592759375947595759675977598759976007601760276037604760576067607760876097610761176127613761476157616761776187619762076217622762376247625762676277628762976307631763276337634763576367637763876397640764176427643764476457646764776487649765076517652765376547655765676577658765976607661766276637664766576667667766876697670767176727673767476757676767776787679768076817682768376847685768676877688768976907691769276937694769576967697769876997700770177027703770477057706770777087709771077117712771377147715771677177718771977207721772277237724772577267727772877297730773177327733773477357736773777387739774077417742774377447745774677477748774977507751775277537754775577567757775877597760776177627763776477657766776777687769777077717772777377747775777677777778777977807781778277837784778577867787778877897790779177927793779477957796779777987799780078017802780378047805780678077808780978107811781278137814781578167817781878197820782178227823782478257826782778287829783078317832783378347835783678377838783978407841784278437844784578467847784878497850785178527853785478557856785778587859786078617862786378647865786678677868786978707871787278737874787578767877787878797880788178827883788478857886788778887889789078917892789378947895789678977898789979007901790279037904790579067907790879097910791179127913791479157916791779187919792079217922792379247925792679277928792979307931793279337934793579367937793879397940794179427943
  1. <template>
  2. <div class="visual-relation-page g6-editor-page">
  3. <div class="page-header">
  4. <div class="page-title-wrap">
  5. <div class="page-title">对象关系分析</div>
  6. <div class="page-subtitle">支持通过图谱中对象和实体之间相互、共同关系线索分析</div>
  7. </div>
  8. <div class="page-meta">
  9. <span class="page-meta__text" v-if="activeGraphCanvas">当前 {{ activeGraphCanvas.label }}</span>
  10. <span class="page-meta__text" :title="nodeCountTitle">节点 {{ nodeCountDisplayText }}</span>
  11. <span class="page-meta__text" :title="edgeCountTitle">关系 {{ edgeCountDisplayText }}</span>
  12. <span class="page-meta__text">分组 {{ graphStats.combos }}</span>
  13. <span class="page-meta__text">缩放 {{ zoomLabel }}</span>
  14. <span class="page-meta__text" v-if="lastSavedAt">最近保存 {{ lastSavedAt }}</span>
  15. </div>
  16. <div class="page-actions">
  17. <Button type="primary" @click="handleOpenSaveLayoutModal">保存</Button>
  18. <Popover
  19. v-model:open="historyVisible"
  20. trigger="click"
  21. placement="bottomRight"
  22. overlayClassName="graph-history-popover"
  23. :arrow="true"
  24. @openChange="handleHistoryVisibleChange"
  25. >
  26. <template #content>
  27. <div class="history-panel">
  28. <div class="history-table">
  29. <div class="history-table__head">
  30. <div class="history-table__cell history-table__cell--name">记录名称</div>
  31. <div class="history-table__cell history-table__cell--time">添加时间</div>
  32. <div class="history-table__cell history-table__cell--action">操作</div>
  33. </div>
  34. <div v-if="historyLoading" class="history-loading">正在加载历史记录...</div>
  35. <div v-else-if="savedLayouts.length" class="history-table__body">
  36. <div
  37. v-for="item in savedLayouts"
  38. :key="item.id"
  39. class="history-table__row"
  40. :class="{ 'is-active': item.id === activeHistoryId }"
  41. @click="handleRestoreSnapshot(item.id)"
  42. >
  43. <div class="history-table__cell history-table__cell--name" :title="item.name">
  44. {{ item.name }}
  45. </div>
  46. <div class="history-table__cell history-table__cell--time" :title="item.createdAt">
  47. {{ item.createdAt }}
  48. </div>
  49. <div class="history-table__cell history-table__cell--action" @click.stop>
  50. <Button type="text" size="small" danger @click="handleRemoveSnapshot(item.id)">删除</Button>
  51. </div>
  52. </div>
  53. </div>
  54. <Empty v-else class="history-empty" description="暂无保存记录" />
  55. </div>
  56. </div>
  57. </template>
  58. <Button>历史记录</Button>
  59. </Popover>
  60. </div>
  61. </div>
  62. <div class="canvas-tabs-bar" data-export-ignore="true">
  63. <div class="canvas-tabs">
  64. <div
  65. v-for="(item, index) in graphCanvasTabs"
  66. :key="item.id"
  67. class="canvas-tab"
  68. :class="{ 'is-active': index === activeGraphCanvasIndex }"
  69. :title="item.label"
  70. @click="switchGraphCanvas(index)"
  71. >
  72. <span class="canvas-tab__label">{{ item.label }}</span>
  73. <span v-if="isGraphCanvasDirty(index)" class="canvas-tab__dirty"></span>
  74. <button type="button" class="canvas-tab__close" @click.stop="handleRemoveGraphCanvas(index)">
  75. <CloseOutlined />
  76. </button>
  77. </div>
  78. <button type="button" class="canvas-tab canvas-tab--add" @click="handleAddGraphCanvas">
  79. <PlusOutlined />
  80. </button>
  81. </div>
  82. </div>
  83. <div class="relation-stage">
  84. <div ref="graphWrapperRef" class="relation-stage__canvas" @dragover.prevent @drop="handleCanvasDrop">
  85. <div ref="graphContainerRef" class="relation-stage__graph"></div>
  86. <div
  87. v-if="analysisLoading || analysisLayoutLoading"
  88. class="relation-stage__layout-loading"
  89. data-export-ignore="true"
  90. >
  91. <div class="relation-stage__layout-loading-card">
  92. <div class="relation-stage__layout-loading-spinner"></div>
  93. <div class="relation-stage__layout-loading-title">
  94. {{ analysisLayoutLoading ? '布局中' : '正在加载分析图谱' }}
  95. </div>
  96. <div class="relation-stage__layout-loading-desc">
  97. {{
  98. analysisLayoutLoading ? '接口数据已返回,正在计算节点位置并渲染图谱。' : '正在请求对象关系分析接口。'
  99. }}
  100. </div>
  101. </div>
  102. </div>
  103. <div class="entity-palette" :class="{ 'is-collapsed': entityPaletteCollapsed }" data-export-ignore="true">
  104. <div class="entity-palette__header">
  105. <div class="entity-palette__title">工具箱</div>
  106. <Button
  107. type="text"
  108. size="small"
  109. class="entity-palette__toggle"
  110. :title="entityPaletteCollapsed ? '展开工具箱' : '收起工具箱'"
  111. @click="entityPaletteCollapsed = !entityPaletteCollapsed"
  112. >
  113. <template #icon>
  114. <PlusOutlined v-if="entityPaletteCollapsed" />
  115. <MinusOutlined v-else />
  116. </template>
  117. </Button>
  118. </div>
  119. <template v-if="!entityPaletteCollapsed">
  120. <div class="entity-grid">
  121. <button
  122. v-for="entity in entityKinds"
  123. :key="entity.key"
  124. type="button"
  125. class="entity-grid__item"
  126. :title="`拖入${entity.label}`"
  127. draggable="true"
  128. @click="handleQuickAddEntity(entity.key)"
  129. @dragstart="handlePaletteDragStart(entity.key, $event)"
  130. >
  131. <span class="entity-grid__icon" :style="{ '--node-accent-color': entity.color }">
  132. <img :src="entity.paletteIcon" :alt="entity.label" />
  133. </span>
  134. <span class="entity-grid__label">{{ entity.label }}</span>
  135. </button>
  136. </div>
  137. <Button
  138. type="primary"
  139. block
  140. class="entity-palette__action"
  141. :loading="analysisRunning"
  142. @click="handleOpenAnalysisCondition"
  143. >
  144. 设置分析条件
  145. </Button>
  146. </template>
  147. </div>
  148. <div class="control-tools-box">
  149. <div class="custom-atlas-tools-container">
  150. <ul>
  151. <Tooltip
  152. v-for="item in analysisToolbarItems"
  153. :key="item.key"
  154. :title="item.tipsContent ? `${item.label}: ${item.tipsContent}` : item.label"
  155. >
  156. <li
  157. class="tool-item"
  158. :class="{
  159. 'gap-item': item.gap,
  160. 'is-active': isToolbarItemActive(item),
  161. 'disabled-tool-item': getToolbarItemDisabled(item.key),
  162. }"
  163. @click="handleAnalysisToolbarClick(item)"
  164. >
  165. <span class="tool-item-icon">
  166. <component :is="item.icon" class="icon-style" />
  167. </span>
  168. </li>
  169. </Tooltip>
  170. </ul>
  171. </div>
  172. <div v-if="activeToolbarPanel" class="tool-panel">
  173. <div class="tool-panel__header">
  174. <div class="tool-panel__title">{{ activeToolbarPanelLabel }}</div>
  175. <Button type="text" size="small" class="tool-panel__close" @click="closeActiveToolbarPanel">收起</Button>
  176. </div>
  177. <template v-if="activeToolbarPanel === 'find'">
  178. <div class="tool-panel__row">
  179. <Input
  180. v-if="findForm.scope === 'entity'"
  181. v-model:value="findForm.keyword"
  182. placeholder="请输入查找内容"
  183. allow-clear
  184. @pressEnter="handleFindConfirm"
  185. />
  186. <div v-else class="tool-panel__range-row">
  187. <InputNumber
  188. v-model:value="findForm.rangeMin"
  189. :min="0"
  190. :precision="0"
  191. class="tool-panel__number"
  192. placeholder="最小值"
  193. @pressEnter="handleFindConfirm"
  194. />
  195. <span class="tool-panel__range-separator">至</span>
  196. <InputNumber
  197. v-model:value="findForm.rangeMax"
  198. :min="0"
  199. :precision="0"
  200. class="tool-panel__number"
  201. placeholder="最大值"
  202. @pressEnter="handleFindConfirm"
  203. />
  204. </div>
  205. </div>
  206. <div v-if="findForm.scope === 'entity'" class="tool-panel__switch-row">
  207. <button
  208. type="button"
  209. class="tool-panel__switch"
  210. :class="{ 'is-active': findForm.manner === 'obscure' }"
  211. @click="findForm.manner = 'obscure'"
  212. >
  213. 模糊查找
  214. </button>
  215. <button
  216. type="button"
  217. class="tool-panel__switch"
  218. :class="{ 'is-active': findForm.manner === 'precise' }"
  219. @click="findForm.manner = 'precise'"
  220. >
  221. 精确查找
  222. </button>
  223. </div>
  224. <div class="tool-panel__switch-row">
  225. <button
  226. type="button"
  227. class="tool-panel__switch"
  228. :class="{ 'is-active': findForm.scope === 'entity' }"
  229. @click="findForm.scope = 'entity'"
  230. >
  231. 查找实体
  232. </button>
  233. <button
  234. type="button"
  235. class="tool-panel__switch"
  236. :class="{ 'is-active': findForm.scope === 'connect' }"
  237. @click="findForm.scope = 'connect'"
  238. >
  239. 查找关系
  240. </button>
  241. </div>
  242. <div class="tool-panel__footer tool-panel__footer--find">
  243. <div class="tool-panel__result" v-if="findResultCount">
  244. 共 {{ findResultCount }} 个结果,当前第 {{ findResultIndex + 1 }} 个
  245. </div>
  246. <div class="tool-panel__actions tool-panel__actions--find">
  247. <Button v-if="findResultCount > 1" @click="handleFindSelectAll">全选结果</Button>
  248. <Button v-if="findResultCount > 1" @click="handleFindPrev">上一个</Button>
  249. <Button type="primary" @click="handleFindConfirm">查找</Button>
  250. <Button v-if="findResultCount > 1" @click="handleFindNext">下一个</Button>
  251. </div>
  252. </div>
  253. </template>
  254. <template v-else-if="activeToolbarPanel === 'filter'">
  255. <div class="tool-panel__row">
  256. <Select
  257. v-model:value="filterForm.findEntityList"
  258. class="tool-panel__entity-select"
  259. mode="multiple"
  260. allow-clear
  261. placeholder="请选择过滤实体"
  262. :options="entityKinds.map((item) => ({ label: item.label, value: item.key }))"
  263. />
  264. </div>
  265. <div class="tool-panel__switch-row">
  266. <button
  267. type="button"
  268. class="tool-panel__switch"
  269. :class="{ 'is-active': filterForm.filterCondition === 'neighborTotal' }"
  270. @click="filterForm.filterCondition = 'neighborTotal'"
  271. >
  272. 实体相邻个数
  273. </button>
  274. <button
  275. type="button"
  276. class="tool-panel__switch"
  277. :class="{ 'is-active': filterForm.filterCondition === 'neighborEdgeNumericValue' }"
  278. @click="filterForm.filterCondition = 'neighborEdgeNumericValue'"
  279. >
  280. 关系数值
  281. </button>
  282. </div>
  283. <div class="tool-panel__switch-row">
  284. <button
  285. v-for="operator in filterOperators"
  286. :key="operator"
  287. type="button"
  288. class="tool-panel__switch tool-panel__switch--compact"
  289. :class="{ 'is-active': filterForm.targetCondition === operator }"
  290. @click="filterForm.targetCondition = operator"
  291. >
  292. {{ operator }}
  293. </button>
  294. <InputNumber v-model:value="filterForm.setResult" :min="0" :precision="0" class="tool-panel__number" />
  295. </div>
  296. <div class="tool-panel__footer">
  297. <div class="tool-panel__actions">
  298. <Button type="primary" @click="executeFilterCells">开始过滤</Button>
  299. <Button @click="handleFilterCancel">取消过滤</Button>
  300. <Button v-if="filterMatchedNodeIds.length" @click="handleFilterSelectAll">全选结果</Button>
  301. <Button v-if="filterMatchedNodeIds.length" @click="handleFilterToggleDisplay">
  302. {{ filterHidden ? '显示过滤' : '隐藏过滤' }}
  303. </Button>
  304. </div>
  305. </div>
  306. </template>
  307. <template v-else-if="activeToolbarPanel === 'mark'">
  308. <div class="tool-panel__swatches">
  309. <button
  310. v-for="(color, index) in markerColors"
  311. :key="color"
  312. type="button"
  313. class="tool-panel__swatch"
  314. :style="{ backgroundColor: color }"
  315. @click="handleSetMarker(index)"
  316. ></button>
  317. </div>
  318. <div class="tool-panel__footer">
  319. <div class="tool-panel__tip">对当前选中实体切换标记色</div>
  320. </div>
  321. </template>
  322. </div>
  323. <div class="canvas-toolbar">
  324. <Tooltip title="缩小">
  325. <Button type="text" class="canvas-toolbar__button" @click="handleZoomOut">
  326. <MinusOutlined />
  327. </Button>
  328. </Tooltip>
  329. <Tooltip title="放大">
  330. <Button type="text" class="canvas-toolbar__button" @click="handleZoomIn">
  331. <PlusOutlined />
  332. </Button>
  333. </Tooltip>
  334. <Tooltip title="适配画布">
  335. <Button type="text" class="canvas-toolbar__button" @click="handleFitView">
  336. <FullscreenOutlined />
  337. </Button>
  338. </Tooltip>
  339. <Tooltip title="居中内容">
  340. <Button type="text" class="canvas-toolbar__button" @click="handleCenterView">
  341. <AimOutlined />
  342. </Button>
  343. </Tooltip>
  344. <Tooltip title="设置分析条件">
  345. <Button type="text" class="canvas-toolbar__button" @click="handleOpenAnalysisCondition">
  346. <ReloadOutlined />
  347. </Button>
  348. </Tooltip>
  349. <Tooltip title="清空画布">
  350. <Button type="text" class="canvas-toolbar__button" @click="handleClearCanvas">
  351. <DeleteOutlined />
  352. </Button>
  353. </Tooltip>
  354. </div>
  355. </div>
  356. <div v-if="pendingEdgeSourceLabel" class="edge-create-tip">
  357. <span>连线起点:{{ pendingEdgeSourceLabel }}</span>
  358. <Button type="link" size="small" @click="cancelEdgeCreate">取消</Button>
  359. </div>
  360. <div v-if="!hasGraphData" class="canvas-empty">
  361. <div class="canvas-empty__icon">
  362. <span class="node node-main"></span>
  363. <span class="node node-branch node-branch--left"></span>
  364. <span class="node node-branch node-branch--right"></span>
  365. <span class="node node-branch node-branch--top"></span>
  366. </div>
  367. <div class="canvas-empty__title">请设置分析条件或拖动左上方实体创建关系</div>
  368. <div class="canvas-empty__desc">当前默认展示对象关系模式,支持拖入实体、移动节点和右键连线。</div>
  369. </div>
  370. <div class="canvas-status" data-export-ignore="true">
  371. <span class="canvas-status__item">提示:拖动画布可平移,滚轮可缩放</span>
  372. <span class="canvas-status__item">框选:按住 Shift 拖动画布</span>
  373. <span class="canvas-status__item" v-if="activeElement">选中:{{ activeElement.label }}</span>
  374. </div>
  375. </div>
  376. </div>
  377. <AnalysisConditionModal
  378. v-model:open="analysisConditionVisible"
  379. :loading="analysisRunning"
  380. :respondent-options="allRespondentOptions.length ? allRespondentOptions : undefined"
  381. :default-selected-ids="routeGraphRespondents.length ? routeGraphDefaultSelectedIds : undefined"
  382. @start="handleStartAnalysis"
  383. />
  384. <Modal
  385. v-model:open="saveLayoutVisible"
  386. title="关系分析"
  387. :confirmLoading="saveLayoutLoading"
  388. :width="560"
  389. :centered="true"
  390. wrapClassName="graph-save-layout-modal"
  391. destroyOnClose
  392. @ok="handleSaveLayout"
  393. @cancel="handleCloseSaveLayoutModal"
  394. >
  395. <Form layout="vertical" class="graph-save-layout-form">
  396. <Form.Item
  397. label="可视化关系名称"
  398. :validate-status="saveLayoutError ? 'error' : ''"
  399. :help="saveLayoutError"
  400. required
  401. >
  402. <Input v-model:value="saveLayoutForm.name" :maxlength="20" placeholder="请输入可视化关系名称" allow-clear />
  403. </Form.Item>
  404. </Form>
  405. </Modal>
  406. <QueryTableModal
  407. :open="analysisDetailTableModalOpen"
  408. :title="analysisDetailTitle"
  409. :width="960"
  410. :loading="analysisDetailLoading"
  411. :filters="analysisDetailFilters"
  412. :filter-schema="analysisDetailFilterSchema"
  413. :columns="analysisDetailTableColumns"
  414. :data-source="analysisDetailTableRows"
  415. :pagination="analysisDetailPagination"
  416. :total-pages="analysisDetailTotalPages"
  417. :page-size-options="analysisDetailPageSizeOptions"
  418. :show-index-column="true"
  419. :show-table-setting="false"
  420. :table-setting="{ size: true, setting: true, redo: false, fullScreen: false }"
  421. :can-fullscreen="true"
  422. wrap-class-name="graph-analysis-detail-table-modal"
  423. @update:open="handleAnalysisDetailVisibleChange"
  424. @search="handleAnalysisDetailSearch"
  425. @reset="handleAnalysisDetailReset"
  426. @prev-page="handleAnalysisDetailPrevPage"
  427. @next-page="handleAnalysisDetailNextPage"
  428. @page-size-change="handleAnalysisDetailPageSizeChange"
  429. @page-change="handleAnalysisDetailPageChange"
  430. @sort-change="handleAnalysisDetailSortChange"
  431. />
  432. <BasicModal
  433. :open="analysisDetailSummaryModalOpen"
  434. :title="analysisDetailTitle"
  435. :width="960"
  436. :showOkBtn="false"
  437. :showCancelBtn="false"
  438. :canFullscreen="true"
  439. :centered="true"
  440. wrapClassName="graph-analysis-detail-modal"
  441. @update:open="handleAnalysisDetailVisibleChange"
  442. @cancel="handleAnalysisDetailVisibleChange(false)"
  443. >
  444. <div class="analysis-detail">
  445. <div v-if="analysisDetailEntries.length" class="analysis-detail__summary">
  446. <div v-for="item in analysisDetailEntries" :key="item.label" class="analysis-detail__entry">
  447. <div class="analysis-detail__entry-label">{{ item.label }}</div>
  448. <div class="analysis-detail__entry-value">{{ item.value }}</div>
  449. </div>
  450. </div>
  451. <Empty v-else description="暂无详情信息" />
  452. </div>
  453. </BasicModal>
  454. <TransRecordModal
  455. v-model:open="transDetailVisible"
  456. :title="transDetailTitle"
  457. :base-query="transDetailBaseQuery"
  458. :fetch-api="fetchGraphTransDetailRecords"
  459. :filter-fields="['startDate', 'endDate', 'transAmount']"
  460. :trans-amount-operator-options="graphTransAmountOperatorOptions"
  461. />
  462. <CallRecordModal
  463. v-model:open="callDetailVisible"
  464. :title="callDetailTitle"
  465. :base-query="callDetailBaseQuery"
  466. :fetch-api="fetchGraphCallDetailRecords"
  467. :filter-fields="['startDate', 'endDate']"
  468. />
  469. <Modal
  470. v-model:open="associativeObjectVisible"
  471. title="关联对象"
  472. :width="520"
  473. :centered="true"
  474. okText="创建"
  475. cancelText="取消"
  476. @ok="handleConfirmAssociativeObject"
  477. >
  478. <div class="analysis-associate-form">
  479. <div class="analysis-associate-form__item">
  480. <div class="analysis-associate-form__label">对象类型</div>
  481. <Select
  482. v-model:value="associativeObjectForm.entityKind"
  483. :options="entityKinds.map((item) => ({ label: item.label, value: item.key }))"
  484. />
  485. </div>
  486. <div class="analysis-associate-form__item">
  487. <div class="analysis-associate-form__label">对象名称</div>
  488. <Input v-model:value="associativeObjectForm.name" placeholder="请输入关联对象名称" :maxlength="32" />
  489. </div>
  490. <div class="analysis-associate-form__item">
  491. <div class="analysis-associate-form__label">对象标识</div>
  492. <Input
  493. v-model:value="associativeObjectForm.identityCode"
  494. placeholder="可填写身份证号、机构编码或其他唯一标识"
  495. :maxlength="64"
  496. />
  497. </div>
  498. <div class="analysis-associate-form__hint">
  499. 当前为本地关联对象创建,会在当前节点周围新增一个实体并建立一条“关联对象”关系。
  500. </div>
  501. </div>
  502. </Modal>
  503. </div>
  504. </template>
  505. <script lang="ts" setup name="ViewsGraphG6Editor">
  506. import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref, shallowRef, type Component } from 'vue';
  507. import { useRoute } from 'vue-router';
  508. import { Button, Empty, Form, Input, InputNumber, Modal, Popover, Select, Tooltip, message } from 'ant-design-vue';
  509. import { useAnalysisRefreshPrompt } from '@jeesite/core/hooks/web/useAnalysisRefreshPrompt';
  510. import {
  511. AimOutlined,
  512. ApartmentOutlined,
  513. BorderOutlined,
  514. CloseOutlined,
  515. CopyOutlined,
  516. DeleteOutlined,
  517. FileAddOutlined,
  518. FilterOutlined,
  519. FullscreenOutlined,
  520. GroupOutlined,
  521. MinusOutlined,
  522. PlusOutlined,
  523. ReloadOutlined,
  524. SearchOutlined,
  525. ShareAltOutlined,
  526. SnippetsOutlined,
  527. StarOutlined,
  528. UngroupOutlined,
  529. UndoOutlined,
  530. RedoOutlined,
  531. } from '@ant-design/icons-vue';
  532. import {
  533. BaseEdge,
  534. BaseNode,
  535. CanvasEvent,
  536. ComboEvent,
  537. EdgeEvent,
  538. Graph,
  539. GraphEvent,
  540. NodeEvent,
  541. register,
  542. type ComboData,
  543. type EdgeOptions,
  544. type EdgeData,
  545. type GraphData,
  546. type IElementEvent,
  547. type LayoutOptions,
  548. type NodeOptions,
  549. type NodeData,
  550. } from '@antv/g6';
  551. import { Image as GImage, Path as GPath, Rect as GRect, Text as GText, type Group } from '@antv/g';
  552. import type { PathArray } from '@antv/util';
  553. import type {
  554. AnalysisConditionPayload,
  555. AnalysisRespondent,
  556. GraphCallDetailQuery,
  557. GraphAnalysisEdge,
  558. GraphAnalysisNode,
  559. GraphAnalysisResponse,
  560. GraphHistoryItem,
  561. GraphNodeDetailQuery,
  562. GraphTransDetailQuery,
  563. } from '../../api/analysis';
  564. import {
  565. buildGraphCallDetailQuery,
  566. buildGraphTransDetailQuery,
  567. buildGraphViewQuery,
  568. createGraphHistory,
  569. deleteGraphHistory,
  570. fetchGraphAnalysis,
  571. fetchGraphCallDetail,
  572. fetchGraphHistory,
  573. fetchGraphNodeDetail,
  574. fetchGraphTransDetail,
  575. resolveGraphRespondentsFromRouteQuery,
  576. } from '../../api/analysis';
  577. import { getPersons } from '@jeesite/person/api/personApi';
  578. import { BasicModal } from '@jeesite/core/components/Modal';
  579. import componentSetting from '@jeesite/core/settings/componentSetting';
  580. import QueryTableModal from '@jeesite/case/views/dm/components/QueryTableModal.vue';
  581. import type { CallRecordQuery } from '@jeesite/call/api/types/query';
  582. import CallRecordModal from '@jeesite/call/views/components/CallRecordModal.vue';
  583. import type { CompareOperator } from '@jeesite/core/utils/compareOperator';
  584. import type { TransRecordQuery } from '@jeesite/trans/api/types/query';
  585. import TransRecordModal from '@jeesite/trans/views/components/TransRecordModal.vue';
  586. import AnalysisConditionModal from '../graph/components/AnalysisConditionModal.vue';
  587. import { runLegacyForceLayout } from '../graph/analysisLegacyForce';
  588. import { computeAnalysisLayout } from '../graph/analysisLayoutEngine';
  589. import type {
  590. AnalysisLayoutWorkerPayload,
  591. AnalysisLayoutWorkerPosition,
  592. AnalysisLayoutWorkerResponseMessage,
  593. } from '../graph/analysisLayoutShared';
  594. import personIcon from '../../assets/relation/personl.png';
  595. import groupIcon from '../../assets/relation/devl.png';
  596. import houseIcon from '../../assets/relation/homel.png';
  597. import carIcon from '../../assets/relation/carl.png';
  598. import bankIcon from '../../assets/relation/cardl.png';
  599. import phoneIcon from '../../assets/relation/phonel.png';
  600. import unknownIcon from '../../assets/relation/wz.png';
  601. import otherIcon from '../../assets/relation/otherl.png';
  602. type EntityKindKey = 'person' | 'group' | 'house' | 'car' | 'bank' | 'phone' | 'unknown' | 'other';
  603. type ElementKind = 'node' | 'edge' | 'combo';
  604. interface EntityKind {
  605. key: EntityKindKey;
  606. label: string;
  607. icon: string;
  608. paletteIcon: string;
  609. color: string;
  610. description: string;
  611. }
  612. interface ActiveElement {
  613. id: string;
  614. label: string;
  615. type: ElementKind;
  616. typeLabel: string;
  617. }
  618. interface LayoutSnapshot {
  619. id: string;
  620. remoteId?: string;
  621. name: string;
  622. createdAt: string;
  623. nodeCount: number;
  624. edgeCount: number;
  625. payload: string;
  626. filePath?: string;
  627. }
  628. interface GraphCanvasTab {
  629. id: string;
  630. label: string;
  631. snapshot: string;
  632. zoom: number;
  633. lastSavedAt: string;
  634. analysisTotalNodeCount: number;
  635. analysisTotalEdgeCount: number;
  636. dirty: boolean;
  637. }
  638. type ToolbarPanelKey = 'find' | 'filter' | 'mark';
  639. type AnalysisToolbarKey =
  640. | 'undo'
  641. | 'redo'
  642. | 'find'
  643. | 'filter'
  644. | 'mark'
  645. | 'selectNeighbor'
  646. | 'boxSelect'
  647. | 'copy'
  648. | 'paste'
  649. | 'delete'
  650. | 'group'
  651. | 'ungroup'
  652. | 'newCanvas'
  653. | 'forceLayout'
  654. | 'fullScreen';
  655. type ContextMenuActionKey =
  656. | 'copy'
  657. | 'paste'
  658. | 'delete'
  659. | 'group'
  660. | 'ungroup'
  661. | 'newCanvas'
  662. | 'analysisDetail'
  663. | 'startEdge'
  664. | 'onlyLookFlowIn'
  665. | 'onlyLookFlowOut'
  666. | 'associativeObject'
  667. | 'createPerson'
  668. | 'fitView';
  669. interface AnalysisToolbarItem {
  670. key: AnalysisToolbarKey;
  671. label: string;
  672. icon: Component;
  673. gap?: boolean;
  674. tipsContent?: string;
  675. panelKey?: ToolbarPanelKey;
  676. }
  677. interface FindForm {
  678. keyword: string;
  679. manner: 'obscure' | 'precise';
  680. scope: 'entity' | 'connect';
  681. rangeMin: number | undefined;
  682. rangeMax: number | undefined;
  683. }
  684. interface FilterForm {
  685. findEntityList: EntityKindKey[];
  686. filterCondition: 'neighborTotal' | 'neighborEdgeNumericValue';
  687. targetCondition: '>' | '>=' | '=' | '<' | '<=';
  688. setResult: number | undefined;
  689. }
  690. interface GraphClipboardPayload {
  691. nodes: NodeData[];
  692. edges: EdgeData[];
  693. }
  694. interface AnalysisDefaultCollapseResult {
  695. nodes: NodeData[];
  696. edges: EdgeData[];
  697. hiddenNodeIds: Set<string>;
  698. }
  699. interface AnalysisLayoutDraft {
  700. positions: Map<string, { x: number; y: number }>;
  701. fixedIds: Set<string>;
  702. }
  703. interface AnalysisLayoutResolveResult {
  704. draft: AnalysisLayoutDraft | null;
  705. positions: Map<string, { x: number; y: number }>;
  706. }
  707. interface AnalysisRelaxItem {
  708. id: string;
  709. x: number;
  710. y: number;
  711. }
  712. interface AnalysisRelaxBounds {
  713. minX: number;
  714. maxX: number;
  715. minY: number;
  716. maxY: number;
  717. }
  718. interface AnalysisRelaxOverlap {
  719. left: AnalysisRelaxItem;
  720. right: AnalysisRelaxItem;
  721. overlapX: number;
  722. overlapY: number;
  723. deltaX: number;
  724. deltaY: number;
  725. }
  726. interface AnalysisDetailEntry {
  727. label: string;
  728. value: string;
  729. }
  730. interface AnalysisDetailTableColumn {
  731. title: string;
  732. dataIndex: string;
  733. key: string;
  734. width: number;
  735. }
  736. interface AnalysisDetailFilterItem {
  737. field: string;
  738. label: string;
  739. type: 'input' | 'date' | 'date-range' | 'select' | 'compare-input';
  740. placeholder?: string;
  741. width?: number;
  742. maxWidth?: number;
  743. options?: ReadonlyArray<{ label: string; value: string | number }>;
  744. operatorField?: string;
  745. operatorWidth?: number;
  746. inputWidth?: number;
  747. inputMaxWidth?: number;
  748. startField?: string;
  749. endField?: string;
  750. rangePlaceholder?: [string, string];
  751. valueFormat?: string;
  752. }
  753. interface ContextMenuItem {
  754. name: string;
  755. value: ContextMenuActionKey;
  756. }
  757. interface ContextMenuState {
  758. id: string;
  759. type: IElementEvent['targetType'];
  760. canvasX: number;
  761. canvasY: number;
  762. }
  763. interface GraphDataBounds {
  764. minX: number;
  765. minY: number;
  766. maxX: number;
  767. maxY: number;
  768. }
  769. interface AnalysisLeafRingCenterMeta {
  770. id: string;
  771. node: NodeData;
  772. position: { x: number; y: number };
  773. leafCount: number;
  774. degree: number;
  775. }
  776. interface StressNodeStyle {
  777. x?: number;
  778. y?: number;
  779. size?: number;
  780. fill?: string;
  781. fillOpacity?: number;
  782. labelText?: string;
  783. labelFill?: string;
  784. labelFontSize?: number;
  785. labelOpacity?: number;
  786. secondaryLabelText?: string;
  787. secondaryLabelFill?: string;
  788. secondaryLabelFontSize?: number;
  789. secondaryLabelOpacity?: number;
  790. }
  791. interface RelationLayoutOptions {
  792. nodeSize?: number;
  793. seedIds?: string[];
  794. preset?: 'default' | 'mingjing-g6';
  795. }
  796. type GraphTransDetailBaseQuery = Partial<TransRecordQuery & GraphTransDetailQuery> & Record<string, any>;
  797. type GraphCallDetailBaseQuery = Partial<CallRecordQuery & GraphCallDetailQuery> & Record<string, any>;
  798. const graphTransAmountOperatorOptions: Array<{ label: string; value: CompareOperator }> = [
  799. { label: '>=', value: 'GE' },
  800. { label: '<=', value: 'LE' },
  801. ];
  802. class AnalysisNode extends BaseNode {
  803. protected drawKeyShape(attributes: Record<string, any>, container: Group) {
  804. const iconSize = Number(attributes.iconBoxSize || 40);
  805. const radius = Number(attributes.iconBoxRadius || 11);
  806. const hidden = Number(attributes.opacity ?? 1) <= 0;
  807. this.upsert(
  808. 'key',
  809. GRect,
  810. {
  811. x: -iconSize / 2,
  812. y: -iconSize / 2,
  813. width: iconSize,
  814. height: iconSize,
  815. className: 'key',
  816. radius,
  817. fill: attributes.fill,
  818. opacity: hidden ? 0 : Number(attributes.fillOpacity ?? 1),
  819. stroke: attributes.stroke || 'rgba(255, 255, 255, 0.22)',
  820. strokeOpacity: hidden ? 0 : Number(attributes.strokeOpacity ?? 0.22),
  821. lineWidth: Number(attributes.lineWidth || 1),
  822. shadowColor: 'rgba(15, 23, 42, 0.12)',
  823. shadowBlur: 10,
  824. shadowOffsetY: 4,
  825. cursor: attributes.cursor || 'pointer',
  826. } as any,
  827. container,
  828. );
  829. if (attributes.iconSrc && !hidden) {
  830. const imageSize = Number(attributes.iconSize || 32);
  831. this.upsert(
  832. 'icon',
  833. GImage,
  834. {
  835. x: -imageSize / 2,
  836. y: -imageSize / 2,
  837. width: imageSize,
  838. height: imageSize,
  839. className: 'icon',
  840. src: attributes.iconSrc,
  841. opacity: Number(attributes.iconOpacity ?? 1),
  842. pointerEvents: 'none',
  843. } as any,
  844. container,
  845. );
  846. } else {
  847. this.upsert('icon', GImage, false, container);
  848. }
  849. if (attributes.collapsedBadgeText && !hidden) {
  850. const badgeText = String(attributes.collapsedBadgeText);
  851. const badgeWidth = Math.max(18, badgeText.length * 7 + 10);
  852. const badgeHeight = 18;
  853. const badgeX = iconSize / 2 - badgeWidth + 8;
  854. const badgeY = -iconSize / 2 - 8;
  855. this.upsert(
  856. 'collapsed-badge',
  857. GRect,
  858. {
  859. x: badgeX,
  860. y: badgeY,
  861. width: badgeWidth,
  862. height: badgeHeight,
  863. className: 'collapsed-badge',
  864. radius: 9,
  865. fill: attributes.collapsedBadgeFill || '#2563eb',
  866. stroke: '#ffffff',
  867. lineWidth: 1.5,
  868. shadowColor: 'rgba(37, 99, 235, 0.28)',
  869. shadowBlur: 8,
  870. shadowOffsetY: 3,
  871. pointerEvents: 'none',
  872. } as any,
  873. container,
  874. );
  875. this.upsert(
  876. 'collapsed-badge-label',
  877. GText,
  878. {
  879. x: badgeX + badgeWidth / 2,
  880. y: badgeY + badgeHeight / 2,
  881. className: 'collapsed-badge-label',
  882. text: badgeText,
  883. fill: '#ffffff',
  884. fontSize: 10,
  885. fontWeight: 700,
  886. textAlign: 'center',
  887. textBaseline: 'middle',
  888. pointerEvents: 'none',
  889. } as any,
  890. container,
  891. );
  892. } else {
  893. this.upsert('collapsed-badge', GRect, false, container);
  894. this.upsert('collapsed-badge-label', GText, false, container);
  895. }
  896. return this.getShape('key');
  897. }
  898. public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this) {
  899. const iconSize = Number(attributes.iconBoxSize || 40);
  900. const hidden = Number(attributes.opacity ?? 1) <= 0;
  901. this.drawKeyShape(attributes, container);
  902. if (!attributes.labelText || hidden) {
  903. this.upsert('label', GText, false, container);
  904. this.upsert('secondary-label', GText, false, container);
  905. return;
  906. }
  907. const hasSecondaryLabel = Boolean(attributes.secondaryLabelText);
  908. const labelBaseY = iconSize / 2 + (hasSecondaryLabel ? 17 : 20);
  909. this.upsert(
  910. 'label',
  911. GText,
  912. {
  913. x: 0,
  914. y: labelBaseY,
  915. className: 'label',
  916. text: String(attributes.labelText),
  917. fill: attributes.labelFill || '#334155',
  918. fontSize: Number(attributes.labelFontSize || 14),
  919. fontWeight: Number(attributes.labelFontWeight || 600),
  920. textAlign: 'center',
  921. textBaseline: 'middle',
  922. opacity: Number(attributes.labelOpacity ?? 1),
  923. maxLines: 1,
  924. wordWrap: false,
  925. pointerEvents: 'none',
  926. } as any,
  927. container,
  928. );
  929. if (hasSecondaryLabel) {
  930. this.upsert(
  931. 'secondary-label',
  932. GText,
  933. {
  934. x: 0,
  935. y: labelBaseY + 18,
  936. className: 'secondary-label',
  937. text: String(attributes.secondaryLabelText),
  938. fill: attributes.secondaryLabelFill || '#94a3b8',
  939. fontSize: Number(attributes.secondaryLabelFontSize || 12),
  940. fontWeight: Number(attributes.secondaryLabelFontWeight || 500),
  941. textAlign: 'center',
  942. textBaseline: 'middle',
  943. opacity: Number(attributes.secondaryLabelOpacity ?? attributes.labelOpacity ?? 1),
  944. maxLines: 1,
  945. wordWrap: false,
  946. pointerEvents: 'none',
  947. } as any,
  948. container,
  949. );
  950. } else {
  951. this.upsert('secondary-label', GText, false, container);
  952. }
  953. }
  954. }
  955. class StressNode extends BaseNode {
  956. protected drawKeyShape(attributes: Record<string, any>, container: Group) {
  957. const size = Number(attributes.size || 8);
  958. const keyShape = this.upsert(
  959. 'key',
  960. GRect,
  961. {
  962. x: -size / 2,
  963. y: -size / 2,
  964. width: size,
  965. height: size,
  966. className: 'key',
  967. radius: Math.max(4, size * 0.26),
  968. fill: attributes.fill,
  969. fillOpacity: attributes.fillOpacity,
  970. stroke: attributes.stroke || 'transparent',
  971. strokeOpacity: Number(attributes.strokeOpacity ?? 1),
  972. lineWidth: Number(attributes.lineWidth ?? 0),
  973. cursor: attributes.cursor || 'pointer',
  974. } as any,
  975. container,
  976. );
  977. if (attributes.iconSrc && size >= 12) {
  978. this.upsert(
  979. 'icon',
  980. GImage,
  981. {
  982. x: -size * 0.38,
  983. y: -size * 0.38,
  984. width: size * 0.76,
  985. height: size * 0.76,
  986. className: 'icon',
  987. src: attributes.iconSrc,
  988. opacity: attributes.fillOpacity,
  989. pointerEvents: 'none',
  990. } as any,
  991. container,
  992. );
  993. } else {
  994. this.upsert('icon', GImage, false, container);
  995. }
  996. if (attributes.collapsedBadgeText) {
  997. const badgeText = String(attributes.collapsedBadgeText);
  998. const badgeWidth = Math.max(16, badgeText.length * 6 + 8);
  999. const badgeHeight = 16;
  1000. const badgeX = size / 2 - badgeWidth / 2;
  1001. const badgeY = -size / 2 - 12;
  1002. this.upsert(
  1003. 'collapsed-badge',
  1004. GRect,
  1005. {
  1006. x: badgeX,
  1007. y: badgeY,
  1008. width: badgeWidth,
  1009. height: badgeHeight,
  1010. className: 'collapsed-badge',
  1011. radius: 8,
  1012. fill: attributes.collapsedBadgeFill || '#2563eb',
  1013. stroke: '#ffffff',
  1014. lineWidth: 1,
  1015. pointerEvents: 'none',
  1016. } as any,
  1017. container,
  1018. );
  1019. this.upsert(
  1020. 'collapsed-badge-label',
  1021. GText,
  1022. {
  1023. x: badgeX + badgeWidth / 2,
  1024. y: badgeY + badgeHeight / 2,
  1025. className: 'collapsed-badge-label',
  1026. text: badgeText,
  1027. fill: '#ffffff',
  1028. fontSize: 9,
  1029. fontWeight: 700,
  1030. textAlign: 'center',
  1031. textBaseline: 'middle',
  1032. pointerEvents: 'none',
  1033. } as any,
  1034. container,
  1035. );
  1036. } else {
  1037. this.upsert('collapsed-badge', GRect, false, container);
  1038. this.upsert('collapsed-badge-label', GText, false, container);
  1039. }
  1040. return keyShape;
  1041. }
  1042. public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this) {
  1043. const size = Number(attributes.size || 8);
  1044. this.drawKeyShape(attributes, container);
  1045. if (!attributes.labelText) {
  1046. this.upsert('label', GText, false, container);
  1047. this.upsert('secondary-label', GText, false, container);
  1048. return;
  1049. }
  1050. const hasSecondaryLabel = Boolean(attributes.secondaryLabelText);
  1051. const labelBaseY = size / 2 + (hasSecondaryLabel ? 10 : 12);
  1052. this.upsert(
  1053. 'label',
  1054. GText,
  1055. {
  1056. x: 0,
  1057. y: labelBaseY,
  1058. className: 'label',
  1059. text: String(attributes.labelText),
  1060. fill: attributes.labelFill || '#334155',
  1061. fontSize: Number(attributes.labelFontSize || 10),
  1062. textAlign: 'center',
  1063. textBaseline: 'middle',
  1064. opacity: Number(attributes.labelOpacity ?? 0.78),
  1065. pointerEvents: 'none',
  1066. } as any,
  1067. container,
  1068. );
  1069. if (hasSecondaryLabel) {
  1070. this.upsert(
  1071. 'secondary-label',
  1072. GText,
  1073. {
  1074. x: 0,
  1075. y: labelBaseY + 14,
  1076. className: 'secondary-label',
  1077. text: String(attributes.secondaryLabelText),
  1078. fill: attributes.secondaryLabelFill || '#94a3b8',
  1079. fontSize: Number(attributes.secondaryLabelFontSize || 9),
  1080. textAlign: 'center',
  1081. textBaseline: 'middle',
  1082. opacity: Number(attributes.secondaryLabelOpacity ?? attributes.labelOpacity ?? 0.72),
  1083. pointerEvents: 'none',
  1084. } as any,
  1085. container,
  1086. );
  1087. } else {
  1088. this.upsert('secondary-label', GText, false, container);
  1089. }
  1090. }
  1091. }
  1092. class StressEdge extends BaseEdge {
  1093. protected getKeyPath(attributes: Record<string, any>): PathArray {
  1094. const [sourcePoint, targetPoint] = this.getEndpoints(attributes as any, false);
  1095. return [
  1096. ['M', sourcePoint[0], sourcePoint[1]],
  1097. ['L', targetPoint[0], targetPoint[1]],
  1098. ] as unknown as PathArray;
  1099. }
  1100. public override render(attributes: Record<string, any> = this.parsedAttributes, container: Group = this): void {
  1101. this.upsert(
  1102. 'key',
  1103. GPath,
  1104. {
  1105. d: this.getKeyPath(attributes),
  1106. className: 'key',
  1107. stroke: attributes.stroke,
  1108. lineWidth: attributes.lineWidth,
  1109. opacity: attributes.opacity,
  1110. } as any,
  1111. container,
  1112. );
  1113. if (!attributes.labelText || Number(attributes.opacity ?? 1) <= 0) {
  1114. this.upsert('label', GText, false, container);
  1115. return;
  1116. }
  1117. const [sourcePoint, targetPoint] = this.getEndpoints(attributes as any, false);
  1118. this.upsert(
  1119. 'label',
  1120. GText,
  1121. {
  1122. x: (sourcePoint[0] + targetPoint[0]) / 2,
  1123. y: (sourcePoint[1] + targetPoint[1]) / 2 - 5,
  1124. className: 'label',
  1125. text: String(attributes.labelText),
  1126. fill: attributes.labelFill || '#64748b',
  1127. fontSize: Number(attributes.labelFontSize || 9),
  1128. textAlign: 'center',
  1129. textBaseline: 'middle',
  1130. opacity: Number(attributes.labelOpacity ?? 0.68),
  1131. pointerEvents: 'none',
  1132. } as any,
  1133. container,
  1134. );
  1135. }
  1136. }
  1137. const registerStressElements = (() => {
  1138. let registered = false;
  1139. return () => {
  1140. if (registered) return;
  1141. register('node', 'analysis-node', AnalysisNode as any);
  1142. register('node', 'performance-node', StressNode as any);
  1143. register('edge', 'performance-edge', StressEdge as any);
  1144. registered = true;
  1145. };
  1146. })();
  1147. const graphContainerRef = ref<HTMLDivElement | null>(null);
  1148. const graphWrapperRef = ref<HTMLDivElement | null>(null);
  1149. const graphRef = shallowRef<Graph | null>(null);
  1150. const route = useRoute();
  1151. const pendingEdgeSourceId = ref('');
  1152. const analysisConditionVisible = ref(false);
  1153. const analysisLoading = ref(false);
  1154. const analysisLayoutLoading = ref(false);
  1155. const loadedRespondentOptions = ref<AnalysisRespondent[]>([]);
  1156. const selectedNodeIds = ref<string[]>([]);
  1157. const selectedEdgeIds = ref<string[]>([]);
  1158. const selectedComboIds = ref<string[]>([]);
  1159. const activeElement = ref<ActiveElement | null>(null);
  1160. const serializedGraphData = ref('');
  1161. const zoomLabel = ref('100%');
  1162. const graphPerformanceMode = ref(false);
  1163. const graphInteractionMoving = ref(false);
  1164. const entityPaletteCollapsed = ref(false);
  1165. const graphCanvasTabs = ref<GraphCanvasTab[]>([]);
  1166. const activeGraphCanvasIndex = ref(0);
  1167. const historyVisible = ref(false);
  1168. const historyLoading = ref(false);
  1169. const savedLayouts = ref<LayoutSnapshot[]>([]);
  1170. const activeHistoryId = ref('');
  1171. const saveLayoutVisible = ref(false);
  1172. const saveLayoutLoading = ref(false);
  1173. const saveLayoutError = ref('');
  1174. const saveLayoutForm = reactive({ name: '' });
  1175. const undoStack = ref<string[]>([]);
  1176. const redoStack = ref<string[]>([]);
  1177. const graphClipboard = ref<GraphClipboardPayload | null>(null);
  1178. const activeToolbarPanel = ref<ToolbarPanelKey | null>(null);
  1179. const toolbarRunningKey = ref<AnalysisToolbarKey | ''>('');
  1180. const isFullscreen = ref(false);
  1181. const findForm = reactive<FindForm>({
  1182. keyword: '',
  1183. manner: 'obscure',
  1184. scope: 'entity',
  1185. rangeMin: undefined,
  1186. rangeMax: undefined,
  1187. });
  1188. const findResultIds = ref<string[]>([]);
  1189. const findResultIndex = ref(-1);
  1190. const filterForm = reactive<FilterForm>({
  1191. findEntityList: [],
  1192. filterCondition: 'neighborTotal',
  1193. targetCondition: '>=',
  1194. setResult: 1,
  1195. });
  1196. const filterMatchedNodeIds = ref<string[]>([]);
  1197. const filterMutedNodeIds = ref<string[]>([]);
  1198. const filterMatchedEdgeIds = ref<string[]>([]);
  1199. const filterHidden = ref(false);
  1200. const analysisDetailVisible = ref(false);
  1201. const analysisDetailTitle = ref('图谱详情');
  1202. const analysisDetailEntries = ref<AnalysisDetailEntry[]>([]);
  1203. const analysisDetailLoading = ref(false);
  1204. const analysisDetailQueryBase = ref<GraphNodeDetailQuery | null>(null);
  1205. const analysisDetailTableColumns = ref<AnalysisDetailTableColumn[]>([]);
  1206. const analysisDetailTableRows = ref<Record<string, any>[]>([]);
  1207. const analysisDetailFilters = reactive({
  1208. startDate: '',
  1209. endDate: '',
  1210. transAmount: '',
  1211. transAmountSymbol: 'GE' as CompareOperator,
  1212. orderKey: '',
  1213. sort: '' as '' | 'ASC' | 'DESC',
  1214. });
  1215. const analysisDetailPagination = ref({
  1216. page: 1,
  1217. limit: componentSetting.table.defaultPageSize,
  1218. total: 0,
  1219. });
  1220. const analysisDetailPageSizeOptions = componentSetting.table.pageSizeOptions.map((value) => ({
  1221. label: value,
  1222. value,
  1223. }));
  1224. const transDetailVisible = ref(false);
  1225. const transDetailTitle = ref('交易详情');
  1226. const transDetailBaseQuery = ref<GraphTransDetailBaseQuery>({});
  1227. const callDetailVisible = ref(false);
  1228. const callDetailTitle = ref('通信详情');
  1229. const callDetailBaseQuery = ref<GraphCallDetailBaseQuery>({});
  1230. const contextMenuState = ref<ContextMenuState | null>(null);
  1231. const associativeObjectVisible = ref(false);
  1232. const associativeObjectAnchorId = ref('');
  1233. const associativeObjectForm = reactive<{
  1234. entityKind: EntityKindKey;
  1235. name: string;
  1236. identityCode: string;
  1237. }>({
  1238. entityKind: 'person',
  1239. name: '',
  1240. identityCode: '',
  1241. });
  1242. const graphStats = reactive({
  1243. nodes: 0,
  1244. edges: 0,
  1245. combos: 0,
  1246. });
  1247. const analysisTotalNodeCount = ref(0);
  1248. const analysisTotalEdgeCount = ref(0);
  1249. const lastAnalysisPayload = ref<AnalysisConditionPayload | null>(null);
  1250. const { syncAnalysisDataRefreshState } = useAnalysisRefreshPrompt({
  1251. refresh: async () => {
  1252. if (!lastAnalysisPayload.value) return;
  1253. await executeStartAnalysis(lastAnalysisPayload.value);
  1254. },
  1255. hasData: () =>
  1256. !!lastAnalysisPayload.value && (graphStats.nodes > 0 || graphStats.edges > 0 || graphStats.combos > 0),
  1257. });
  1258. const LARGE_GRAPH_NODE_THRESHOLD = 800;
  1259. const LARGE_GRAPH_EDGE_THRESHOLD = 1000;
  1260. const ANALYSIS_LAYOUT_NODE_SIZE = 180;
  1261. const ANALYSIS_LAYOUT_COLLISION_SIZE = 96;
  1262. const ANALYSIS_LAYOUT_MINGJING_CENTER: [number, number] = [369, 180];
  1263. const ANALYSIS_LAYOUT_FORCE_GRAVITY = 18;
  1264. const ANALYSIS_LAYOUT_FORCE_FACTOR = 1.2;
  1265. const ANALYSIS_LAYOUT_FORCE_COLLIDE_STRENGTH = 0.72;
  1266. const ANALYSIS_LAYOUT_FORCE_LEAF_STRENGTH = 2200;
  1267. const ANALYSIS_LAYOUT_FORCE_DEGREE_STRENGTH = 480;
  1268. const ANALYSIS_LAYOUT_FORCE_EXPANDED_STRENGTH = 680;
  1269. const ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_SHORT = 22;
  1270. const ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_LONG = 180;
  1271. const ANALYSIS_LAYOUT_FORCE_CENTER_LINK_DISTANCE = 250;
  1272. const ANALYSIS_LAYOUT_RING_MIN_RADIUS = 230;
  1273. const ANALYSIS_LAYOUT_RING_SECTOR_SWEEP = (Math.PI * 4) / 3;
  1274. const ANALYSIS_LAYOUT_RING_MIN_SWEEP = (Math.PI * 3) / 4;
  1275. const ANALYSIS_LAYOUT_RING_DENSE_MIN_SWEEP = (Math.PI * 2) / 3;
  1276. const ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE = 540;
  1277. const ANALYSIS_LAYOUT_RING_DENSE_RADIUS_BOOST = 140;
  1278. const ANALYSIS_LAYOUT_CENTER_CLUSTER_MIN_GAP = 220;
  1279. const ANALYSIS_LAYOUT_CENTER_CLUSTER_MAX_GAP = 360;
  1280. const ANALYSIS_LAYOUT_CENTER_CLUSTER_PUSH_STEP = 34;
  1281. const ANALYSIS_LAYOUT_CENTER_CLUSTER_ITERATIONS = 16;
  1282. const ANALYSIS_LAYOUT_RELAX_MAX_NODES = 360;
  1283. const ANALYSIS_LAYOUT_WORKER_TIMEOUT = 60000;
  1284. const LEGACY_FORCE_WORKER_THRESHOLD = 12;
  1285. const ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES = 3000;
  1286. const ANALYSIS_LAYOUT_NODE_GAP_X = 20;
  1287. const ANALYSIS_LAYOUT_NODE_GAP_Y = 20;
  1288. const ANALYSIS_COLLAPSE_NEIGHBOR_THRESHOLD = 100;
  1289. const ANALYSIS_EXPAND_FIRST_RING_RADIUS = 180;
  1290. const ANALYSIS_EXPAND_RING_RADIUS_STEP = 96;
  1291. const ANALYSIS_EXPAND_ARC_GAP = 112;
  1292. const ANALYSIS_EXPANDED_EDGE_PREFIX = 'analysis-expanded-edge';
  1293. const ANALYSIS_PERFORMANCE_EDGE_LABEL_LIMIT = 300;
  1294. const LARGE_GRAPH_VIEW_FOCUS_LIMIT = 36;
  1295. const LARGE_GRAPH_DEFAULT_FOCUS_ZOOM = 0.18;
  1296. // Keep the default analysis layout close to Mingjing's single-pass force layout.
  1297. const DEFAULT_GRAPH_LAYOUT_PASS_COUNT = 1;
  1298. const LARGE_GRAPH_LAYOUT_PASS_COUNT = 1;
  1299. const EDGE_TARGET_ARROW_GAP = 14;
  1300. const PARALLEL_EDGE_CURVE_GAP = 28;
  1301. const PERSON_NEIGHBOR_CLUSTER_MIN_HUB_DEGREE = 4;
  1302. // Keep the G6 analysis layout aligned with Mingjing's force2 preset.
  1303. const PERSON_NEIGHBOR_CLUSTER_MIN_COUNT = 999;
  1304. const PERSON_NEIGHBOR_CLUSTER_RADIUS = 36;
  1305. const PERSON_NEIGHBOR_CLUSTER_RING_STEP = 44;
  1306. const PERSON_NEIGHBOR_CLUSTER_SPACING = 40;
  1307. const SNAPSHOT_PREVIEW_NODE_LIMIT = 80;
  1308. const SNAPSHOT_PREVIEW_EDGE_LIMIT = 120;
  1309. const MAX_HISTORY_SNAPSHOTS = 10;
  1310. const MAX_UNDO_STACK = 20;
  1311. const EMPTY_GRAPH_SNAPSHOT = JSON.stringify({ nodes: [], edges: [], combos: [] });
  1312. const markerColors = ['#ef5350', '#ffb300', '#4c91ff', '#00d156', '#be4ed1', '#27ab9e', '#707c9b'] as const;
  1313. const filterOperators = ['>', '>=', '=', '<', '<='] as const;
  1314. let analysisLayoutWorker: Worker | null = null;
  1315. const toSvgDataUri = (svgMarkup: string) =>
  1316. `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svgMarkup.trim())}`;
  1317. const getPaletteIconMarkup = (typeKey: EntityKindKey) => {
  1318. const common =
  1319. 'fill="none" stroke="rgba(255,255,255,0.96)" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"';
  1320. const glyphs: Record<EntityKindKey, string> = {
  1321. person: `
  1322. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1323. <circle cx="14" cy="9.2" r="3.8" ${common}></circle>
  1324. <path d="M7.8 20.8c1.8-4.2 10.6-4.2 12.4 0" ${common}></path>
  1325. </svg>
  1326. `,
  1327. group: `
  1328. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1329. <path d="M6.5 11.2 14 7l7.5 4.2" ${common}></path>
  1330. <path d="M8.2 11.6h11.6v9.2H8.2z" ${common}></path>
  1331. <path d="M11 20.8v-5.4M14 20.8v-5.4M17 20.8v-5.4" ${common}></path>
  1332. </svg>
  1333. `,
  1334. house: `
  1335. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1336. <path d="M6.4 13.2 14 7.2l7.6 6" ${common}></path>
  1337. <path d="M8.8 12.8v8h10.4v-8" ${common}></path>
  1338. <path d="M12.2 20.8v-4.8h3.6v4.8" ${common}></path>
  1339. </svg>
  1340. `,
  1341. car: `
  1342. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1343. <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>
  1344. <path d="M8.8 12.4 10.6 9.6h6.8l1.8 2.8" ${common}></path>
  1345. <circle cx="10" cy="19.4" r="1.5" ${common}></circle>
  1346. <circle cx="18" cy="19.4" r="1.5" ${common}></circle>
  1347. </svg>
  1348. `,
  1349. bank: `
  1350. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1351. <rect x="5.6" y="8.4" width="16.8" height="11.2" rx="2.4" ${common}></rect>
  1352. <path d="M5.6 12.4h16.8" ${common}></path>
  1353. <path d="M9 16.4h4.4M16.6 16.4h2.6" ${common}></path>
  1354. </svg>
  1355. `,
  1356. phone: `
  1357. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1358. <rect x="9.2" y="6.2" width="9.6" height="15.6" rx="2.2" ${common}></rect>
  1359. <path d="M12.2 8.8h3.6M13.1 18.9h1.8" ${common}></path>
  1360. </svg>
  1361. `,
  1362. unknown: `
  1363. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1364. <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>
  1365. <path d="M14 20.4h.1" ${common}></path>
  1366. <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>
  1367. </svg>
  1368. `,
  1369. other: `
  1370. <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" aria-hidden="true">
  1371. <path d="M14 6.8 19.8 10v8L14 21.2 8.2 18v-8z" ${common}></path>
  1372. <path d="M14 6.8v6.2M19.8 10 14 13 8.2 10M14 13v8.2" ${common}></path>
  1373. </svg>
  1374. `,
  1375. };
  1376. return glyphs[typeKey] || glyphs.unknown;
  1377. };
  1378. const getPaletteIconDataUri = (typeKey: EntityKindKey) => toSvgDataUri(getPaletteIconMarkup(typeKey));
  1379. const entityKinds: EntityKind[] = [
  1380. {
  1381. key: 'person',
  1382. label: '人物',
  1383. icon: personIcon,
  1384. paletteIcon: getPaletteIconDataUri('person'),
  1385. color: '#b92a31',
  1386. description: '重点对象、联系人',
  1387. },
  1388. {
  1389. key: 'group',
  1390. label: '机构',
  1391. icon: groupIcon,
  1392. paletteIcon: getPaletteIconDataUri('group'),
  1393. color: '#0f9f68',
  1394. description: '公司、门店、组织',
  1395. },
  1396. {
  1397. key: 'house',
  1398. label: '房产',
  1399. icon: houseIcon,
  1400. paletteIcon: getPaletteIconDataUri('house'),
  1401. color: '#0f4c8a',
  1402. description: '住址、房产、活动地点',
  1403. },
  1404. {
  1405. key: 'car',
  1406. label: '车辆',
  1407. icon: carIcon,
  1408. paletteIcon: getPaletteIconDataUri('car'),
  1409. color: '#f29a1f',
  1410. description: '车牌、车辆、轨迹对象',
  1411. },
  1412. {
  1413. key: 'bank',
  1414. label: '银行卡',
  1415. icon: bankIcon,
  1416. paletteIcon: getPaletteIconDataUri('bank'),
  1417. color: '#8441ae',
  1418. description: '银行卡、账户、资金实体',
  1419. },
  1420. {
  1421. key: 'phone',
  1422. label: '手机',
  1423. icon: phoneIcon,
  1424. paletteIcon: getPaletteIconDataUri('phone'),
  1425. color: '#0aa0b4',
  1426. description: '手机号、固话、虚拟号',
  1427. },
  1428. {
  1429. key: 'unknown',
  1430. label: '未知',
  1431. icon: unknownIcon,
  1432. paletteIcon: getPaletteIconDataUri('unknown'),
  1433. color: '#434c55',
  1434. description: '未识别实体',
  1435. },
  1436. {
  1437. key: 'other',
  1438. label: '其他',
  1439. icon: otherIcon,
  1440. paletteIcon: getPaletteIconDataUri('other'),
  1441. color: '#d4ac2b',
  1442. description: '扩展实体',
  1443. },
  1444. ];
  1445. const analysisToolbarItems: AnalysisToolbarItem[] = [
  1446. { key: 'undo', label: '撤销', icon: UndoOutlined },
  1447. { key: 'redo', label: '恢复', icon: RedoOutlined, gap: true },
  1448. { key: 'find', label: '查找', icon: SearchOutlined, panelKey: 'find' },
  1449. { key: 'filter', label: '过滤', icon: FilterOutlined, panelKey: 'filter' },
  1450. { key: 'mark', label: '标记', icon: StarOutlined },
  1451. { key: 'selectNeighbor', label: '扩散关系', icon: ShareAltOutlined, tipsContent: '选中当前实体的相邻实体和关系' },
  1452. { key: 'boxSelect', label: '框选', icon: BorderOutlined, tipsContent: 'G6 当前通过 Shift 拖拽框选' },
  1453. { key: 'copy', label: '复制', icon: CopyOutlined, gap: true },
  1454. { key: 'paste', label: '粘贴', icon: SnippetsOutlined },
  1455. { key: 'delete', label: '删除', icon: DeleteOutlined },
  1456. { key: 'group', label: '组合实体', icon: GroupOutlined },
  1457. { key: 'ungroup', label: '拆分实体', icon: UngroupOutlined },
  1458. { key: 'newCanvas', label: '复制到新画布', icon: FileAddOutlined, gap: true },
  1459. { key: 'forceLayout', label: '智能布局', icon: ApartmentOutlined, tipsContent: '按对象关系分析的力导向重新布局' },
  1460. { key: 'fullScreen', label: '全屏预览', icon: FullscreenOutlined },
  1461. ];
  1462. const routeGraphQuery = computed(() => buildGraphViewQuery(route.query));
  1463. const routeGraphRespondents = computed(() => resolveGraphRespondentsFromRouteQuery(route.query));
  1464. const routeGraphDefaultSelectedIds = computed(() => routeGraphRespondents.value.map((item) => item.id));
  1465. const allRespondentOptions = computed(() => {
  1466. const loaded = loadedRespondentOptions.value;
  1467. const fromRoute = routeGraphRespondents.value;
  1468. if (!loaded.length) return fromRoute;
  1469. if (!fromRoute.length) return loaded;
  1470. const seenKeys = new Set(loaded.map((item) => item.id));
  1471. const merged = [...loaded];
  1472. fromRoute.forEach((item) => {
  1473. if (!seenKeys.has(item.id)) {
  1474. seenKeys.add(item.id);
  1475. merged.push(item);
  1476. }
  1477. });
  1478. return merged;
  1479. });
  1480. const activeGraphCanvas = computed(() => graphCanvasTabs.value[activeGraphCanvasIndex.value] || null);
  1481. const lastSavedAt = computed(() => activeGraphCanvas.value?.lastSavedAt || '');
  1482. const nodeCountDisplayText = computed(() =>
  1483. analysisTotalNodeCount.value > 0 ? String(analysisTotalNodeCount.value) : String(graphStats.nodes),
  1484. );
  1485. const edgeCountDisplayText = computed(() =>
  1486. analysisTotalEdgeCount.value > 0 ? String(analysisTotalEdgeCount.value) : String(graphStats.edges),
  1487. );
  1488. const nodeCountTitle = computed(() =>
  1489. analysisTotalNodeCount.value > 0 && analysisTotalNodeCount.value !== graphStats.nodes
  1490. ? `当前显示 ${graphStats.nodes} 个节点,接口返回 ${analysisTotalNodeCount.value} 个节点`
  1491. : '',
  1492. );
  1493. const edgeCountTitle = computed(() =>
  1494. analysisTotalEdgeCount.value > 0 && analysisTotalEdgeCount.value !== graphStats.edges
  1495. ? `当前显示 ${graphStats.edges} 条关系,接口返回 ${analysisTotalEdgeCount.value} 条关系`
  1496. : '',
  1497. );
  1498. const analysisRunning = computed(() => analysisLoading.value || analysisLayoutLoading.value);
  1499. const hasGraphData = computed(() => graphStats.nodes > 0 || graphStats.edges > 0 || graphStats.combos > 0);
  1500. const findResultCount = computed(() => findResultIds.value.length);
  1501. const analysisDetailTotalPages = computed(() =>
  1502. Math.max(1, Math.ceil(analysisDetailPagination.value.total / analysisDetailPagination.value.limit)),
  1503. );
  1504. const analysisDetailTableModalOpen = computed(
  1505. () => analysisDetailVisible.value && (analysisDetailLoading.value || analysisDetailTableColumns.value.length > 0),
  1506. );
  1507. const analysisDetailSummaryModalOpen = computed(
  1508. () => analysisDetailVisible.value && !analysisDetailLoading.value && !analysisDetailTableColumns.value.length,
  1509. );
  1510. const isAnalysisDetailDateColumn = (column: AnalysisDetailTableColumn | undefined) => {
  1511. const title = String(column?.title ?? '').trim();
  1512. const dataIndex = String(column?.dataIndex ?? '').trim();
  1513. const key = String(column?.key ?? '').trim();
  1514. const lookupText = [title, dataIndex, key].filter(Boolean).join('|').toLowerCase();
  1515. return (
  1516. title.includes('联系日期') ||
  1517. title.includes('呼叫开始时间') ||
  1518. title.includes('通话日期时间') ||
  1519. title.includes('通话日期') ||
  1520. title.includes('交易日期') ||
  1521. title.includes('交易时间') ||
  1522. title.includes('日期') ||
  1523. title.includes('时间') ||
  1524. lookupText.includes('startdate') ||
  1525. lookupText.includes('enddate') ||
  1526. lookupText.includes('transdate') ||
  1527. lookupText.includes('transtime') ||
  1528. lookupText.includes('transdatetime') ||
  1529. lookupText.includes('date') ||
  1530. lookupText.includes('time')
  1531. );
  1532. };
  1533. const analysisDetailDateField = computed(() => {
  1534. const dateColumn = analysisDetailTableColumns.value.find((column) => isAnalysisDetailDateColumn(column));
  1535. return dateColumn ? String(dateColumn.dataIndex) : '';
  1536. });
  1537. const analysisDetailDateFilterLabel = computed(() => {
  1538. const dateColumn = analysisDetailTableColumns.value.find(
  1539. (column) => String(column?.dataIndex ?? '') === analysisDetailDateField.value,
  1540. );
  1541. const title = String(dateColumn?.title ?? '').trim();
  1542. if (
  1543. title.includes('联系日期') ||
  1544. title.includes('呼叫开始时间') ||
  1545. title.includes('通话日期时间') ||
  1546. title.includes('通话日期')
  1547. ) {
  1548. return '联系日期';
  1549. }
  1550. if (title.includes('交易时间') || title.includes('时间')) {
  1551. return '交易日期';
  1552. }
  1553. return '交易日期';
  1554. });
  1555. const analysisDetailDateFilterVisible = computed(() => Boolean(analysisDetailDateField.value));
  1556. const analysisDetailAmountField = computed(() => {
  1557. const amountColumn = analysisDetailTableColumns.value.find((column) => {
  1558. const title = String(column?.title ?? '').trim();
  1559. return title.includes('交易金额') || title.includes('金额');
  1560. });
  1561. return amountColumn ? String(amountColumn.dataIndex) : '';
  1562. });
  1563. const analysisDetailAmountFilterVisible = computed(() => Boolean(analysisDetailAmountField.value));
  1564. const analysisDetailFilterSchema = computed<AnalysisDetailFilterItem[]>(() => {
  1565. const schema: AnalysisDetailFilterItem[] = [];
  1566. if (analysisDetailDateFilterVisible.value) {
  1567. schema.push({
  1568. field: 'analysisDetailDateRange',
  1569. label: analysisDetailDateFilterLabel.value,
  1570. type: 'date-range',
  1571. startField: 'startDate',
  1572. endField: 'endDate',
  1573. width: 320,
  1574. maxWidth: 360,
  1575. rangePlaceholder: ['开始日期', '结束日期'],
  1576. valueFormat: 'YYYY-MM-DD',
  1577. });
  1578. }
  1579. if (analysisDetailAmountFilterVisible.value) {
  1580. schema.push({
  1581. field: 'transAmount',
  1582. label: '交易金额',
  1583. type: 'compare-input',
  1584. operatorField: 'transAmountSymbol',
  1585. options: graphTransAmountOperatorOptions,
  1586. operatorWidth: 72,
  1587. inputWidth: 180,
  1588. inputMaxWidth: 180,
  1589. placeholder: '请输入交易金额',
  1590. });
  1591. }
  1592. return schema;
  1593. });
  1594. const activeToolbarPanelLabel = computed(() => {
  1595. if (activeToolbarPanel.value === 'find') return '查找';
  1596. if (activeToolbarPanel.value === 'filter') return '过滤';
  1597. if (activeToolbarPanel.value === 'mark') return '标记';
  1598. return '';
  1599. });
  1600. const selectedSummary = computed(() => {
  1601. const nodes = selectedNodeIds.value.length;
  1602. const edges = selectedEdgeIds.value.length;
  1603. const combos = selectedComboIds.value.length;
  1604. return {
  1605. nodes,
  1606. edges,
  1607. combos,
  1608. total: nodes + edges + combos,
  1609. };
  1610. });
  1611. const pendingEdgeSourceLabel = computed(() => {
  1612. if (!pendingEdgeSourceId.value || !graphRef.value) return '';
  1613. const node = findNodeData(pendingEdgeSourceId.value);
  1614. return getNodeLabel(node);
  1615. });
  1616. const getEntityKind = (kind?: unknown) => entityKinds.find((item) => item.key === kind) || entityKinds[0];
  1617. const sanitizeEdgeLabelText = (value?: string | number | null) =>
  1618. String(value ?? '')
  1619. .trim()
  1620. .replace(/(?<=\d)[,\uFF0C](?=\d)/g, '');
  1621. const formatEdgeAmountLabel = (value: number) => (Number.isFinite(value) ? String(Math.round(value)) : String(value));
  1622. const getNodeLabel = (node?: NodeData | null) => String(node?.data?.label || node?.id || '');
  1623. const getNodeSecondaryLabel = (node?: NodeData | null) => String(node?.data?.secondaryLabel || '');
  1624. const getComboLabel = (combo?: ComboData | null) => String(combo?.data?.label || combo?.id || '');
  1625. const getEdgeLabel = (edge?: EdgeData | null) => sanitizeEdgeLabelText(edge?.data?.label || edge?.id || '关系');
  1626. const getPerformanceNodeLabel = (node?: NodeData | null) => String(node?.data?.performanceLabel || '');
  1627. const getPerformanceNodeSecondaryLabel = (node?: NodeData | null) =>
  1628. String(node?.data?.performanceSecondaryLabel || '');
  1629. const getPerformanceEdgeLabel = (edge?: EdgeData | null) => sanitizeEdgeLabelText(edge?.data?.performanceLabel || '');
  1630. const isLargeGraphData = (data: GraphData) =>
  1631. (data.nodes?.length || 0) >= LARGE_GRAPH_NODE_THRESHOLD || (data.edges?.length || 0) >= LARGE_GRAPH_EDGE_THRESHOLD;
  1632. const getBoundedLimit = (value: number, total: number) =>
  1633. Math.min(total, Math.max(0, Math.floor(Number(value) || 0)));
  1634. const normalizeLookupKey = (value?: string | number | null) =>
  1635. String(value || '')
  1636. .trim()
  1637. .toUpperCase();
  1638. const uniqueValues = <T,>(items: T[]) => Array.from(new Set(items));
  1639. const normalizeAnalysisEdgeClazz = (clazz?: string | null) =>
  1640. String(clazz || '')
  1641. .trim()
  1642. .toLowerCase();
  1643. const isTradeAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'card_card';
  1644. const isCommunicationAnalysisEdgeClazz = (clazz?: string | null) => normalizeAnalysisEdgeClazz(clazz) === 'tel_tel';
  1645. const shouldShowAnalysisEdgeCount = (clazz?: string | null) => {
  1646. const normalizedClazz = normalizeAnalysisEdgeClazz(clazz);
  1647. return normalizedClazz === 'card_card' || normalizedClazz === 'tel_tel';
  1648. };
  1649. const getAnalysisEdgeCountText = (edge: GraphAnalysisEdge) => {
  1650. if (!shouldShowAnalysisEdgeCount(edge.clazz)) return '';
  1651. const countValue =
  1652. edge.nums !== null && edge.nums !== undefined && edge.nums !== ''
  1653. ? edge.nums
  1654. : isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
  1655. ? edge.money
  1656. : undefined;
  1657. return countValue !== undefined ? `${String(countValue).replace(/\.0$/, '')}次` : '';
  1658. };
  1659. const stringifyDetailValue = (value: unknown) => {
  1660. if (value === undefined || value === null || value === '') return '--';
  1661. if (Array.isArray(value)) return value.length ? value.map((item) => stringifyDetailValue(item)).join(' / ') : '--';
  1662. if (typeof value === 'object') {
  1663. try {
  1664. return JSON.stringify(value);
  1665. } catch {
  1666. return String(value);
  1667. }
  1668. }
  1669. return String(value);
  1670. };
  1671. const appendDetailEntry = (entries: AnalysisDetailEntry[], label: string, value: unknown) => {
  1672. const text = stringifyDetailValue(value);
  1673. if (text === '--') return;
  1674. entries.push({ label, value: text });
  1675. };
  1676. const normalizeGraphDetailText = (value: unknown) => {
  1677. const text = String(value ?? '').trim();
  1678. if (!text || text === 'null' || text === 'undefined') return undefined;
  1679. return text;
  1680. };
  1681. const getAnalysisClassByKind = (kind: EntityKindKey) => {
  1682. switch (kind) {
  1683. case 'person':
  1684. return 'sfz';
  1685. case 'phone':
  1686. return 'tel';
  1687. case 'bank':
  1688. return 'card';
  1689. case 'house':
  1690. return 'dz_fc';
  1691. case 'car':
  1692. return 'car';
  1693. case 'group':
  1694. return 'obj';
  1695. case 'other':
  1696. return 'other';
  1697. default:
  1698. return 'x';
  1699. }
  1700. };
  1701. const inferEntityKindFromToken = (value?: string | null): EntityKindKey => {
  1702. const token = String(value || '').trim();
  1703. if (!token) return 'unknown';
  1704. if (/公司|超市|商店|经营部|企业|集团|合作社|银行|机构|组织/.test(token)) return 'group';
  1705. if (/^[\u4e00-\u9fa5][A-Z0-9]{5,}$/i.test(token)) return 'car';
  1706. if (/小区|楼|室|路|街|村|镇|区|县|市|地址|房产|住址/.test(token)) return 'house';
  1707. const digitOnly = token.replace(/\D/g, '');
  1708. if (/^\d{17}[0-9X*]$/i.test(token) || token.includes('****')) return 'person';
  1709. if (digitOnly.length >= 7 && digitOnly.length <= 11) return 'phone';
  1710. if (digitOnly.length >= 12) return 'bank';
  1711. return 'other';
  1712. };
  1713. const getEntityKindByClass = (value?: string | null): EntityKindKey | null => {
  1714. const normalized = String(value || '')
  1715. .trim()
  1716. .toLowerCase();
  1717. if (!normalized) return null;
  1718. if (normalized.includes('sfz') || normalized === 'obj') return 'person';
  1719. if (normalized.includes('tel')) return 'phone';
  1720. if (normalized.includes('card')) return 'bank';
  1721. if (normalized.includes('fc') || normalized.includes('house') || normalized.includes('addr')) return 'house';
  1722. if (normalized.includes('car')) return 'car';
  1723. if (normalized.includes('ins') || normalized.includes('org') || normalized.includes('group')) return 'group';
  1724. return null;
  1725. };
  1726. const getEntityKindByClazzPositionToken = (value?: string | null): EntityKindKey | null => {
  1727. const normalized = String(value || '')
  1728. .trim()
  1729. .toLowerCase();
  1730. if (!normalized) return null;
  1731. if (normalized === 'obj') return 'person';
  1732. if (normalized === 'org' || normalized === 'ins') return 'group';
  1733. return getEntityKindByClass(normalized);
  1734. };
  1735. const getEdgeEndpointKindsByClazz = (clazz?: string | null) => {
  1736. const tokens = String(clazz || '')
  1737. .trim()
  1738. .toLowerCase()
  1739. .split(/[^a-z0-9]+/)
  1740. .filter(Boolean);
  1741. return {
  1742. sourceKind: getEntityKindByClazzPositionToken(tokens[0]),
  1743. targetKind: getEntityKindByClazzPositionToken(tokens[tokens.length - 1]),
  1744. };
  1745. };
  1746. const getAnalysisNodeFullLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
  1747. const name = String(node.mc || '').trim();
  1748. const nodeCn = String(node.nodecn || '').trim();
  1749. const code = String(node.bm || node.sfz || '').trim();
  1750. if (kind === 'person' || kind === 'group') return name || nodeCn || code || '未命名实体';
  1751. if (kind === 'bank' || kind === 'phone' || kind === 'car' || kind === 'house') {
  1752. return code || name || nodeCn || '未命名实体';
  1753. }
  1754. return name || nodeCn || code || '未命名实体';
  1755. };
  1756. const getAnalysisNodeSecondaryLabel = (node: GraphAnalysisNode, kind: EntityKindKey) => {
  1757. const nodeCn = String(node.nodecn || '').trim();
  1758. const code = String(node.bm || node.sfz || '').trim();
  1759. const fullLabel = getAnalysisNodeFullLabel(node, kind);
  1760. if (!nodeCn || nodeCn === code || nodeCn === fullLabel) return '';
  1761. return nodeCn;
  1762. };
  1763. const buildAnalysisEdgeLabel = (edge: GraphAnalysisEdge) => {
  1764. const baseLabel = String(edge.mc || edge.sm || '').trim();
  1765. const relationText = [baseLabel, edge.clazz, edge.sm, edge.mc]
  1766. .map((item) => String(item || '').trim())
  1767. .filter(Boolean)
  1768. .join(' ');
  1769. const shouldHideAmount =
  1770. relationText.includes('持卡人') ||
  1771. relationText.includes('机主') ||
  1772. relationText.includes('开户人') ||
  1773. relationText.includes('户主');
  1774. const countLabel = getAnalysisEdgeCountText(edge);
  1775. const amountLabel =
  1776. !shouldHideAmount && !isCommunicationAnalysisEdgeClazz(edge.clazz) && typeof edge.money === 'number'
  1777. ? `¥${formatEdgeAmountLabel(edge.money)}`
  1778. : '';
  1779. return [baseLabel, amountLabel || countLabel].filter(Boolean).join(' ') || '关联';
  1780. };
  1781. const buildNodeDetailEntriesFromPayload = (payload: unknown) => {
  1782. const entries: AnalysisDetailEntry[] = [];
  1783. if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return entries;
  1784. Object.entries(payload as Record<string, unknown>).forEach(([key, value]) => {
  1785. appendDetailEntry(entries, key, value);
  1786. });
  1787. return entries;
  1788. };
  1789. const resolveNodeDetailPayload = (response: unknown) => {
  1790. const root = response && typeof response === 'object' ? (response as Record<string, unknown>) : {};
  1791. const data = root?.data && typeof root.data === 'object' ? (root.data as Record<string, unknown>) : root;
  1792. const head = Array.isArray(data?.head) ? (data.head as Record<string, unknown>[]) : [];
  1793. const pages = data?.pages && typeof data.pages === 'object' ? (data.pages as Record<string, unknown>) : undefined;
  1794. const records = Array.isArray(pages?.records) ? (pages?.records as Record<string, unknown>[]) : [];
  1795. return {
  1796. data,
  1797. head,
  1798. records,
  1799. pages,
  1800. };
  1801. };
  1802. const isNodeDetailSequenceColumn = (item: Record<string, unknown>) => {
  1803. const title = (normalizeGraphDetailText(item.title) || '').toLowerCase();
  1804. const dataIndex = (normalizeGraphDetailText(item.dataIndex) || '').toLowerCase();
  1805. const key = (normalizeGraphDetailText(item.key) || '').toLowerCase();
  1806. return (
  1807. ['序号', 'index', 'xh'].includes(title) || ['index', 'xh'].includes(dataIndex) || ['index', 'xh'].includes(key)
  1808. );
  1809. };
  1810. const normalizeNodeDetailColumnWidth = (value: unknown, title: string) => {
  1811. const width = Number.parseFloat(String(value ?? ''));
  1812. if (Number.isFinite(width) && width > 0) return width;
  1813. if (title === '序号') return 72;
  1814. if (title.includes('时间') || title.includes('日期')) return 180;
  1815. if (title.includes('地址') || title.includes('内容') || title.includes('摘要') || title.includes('名称')) {
  1816. return 220;
  1817. }
  1818. return 160;
  1819. };
  1820. const buildNodeDetailTableColumns = (head: Record<string, unknown>[]): AnalysisDetailTableColumn[] =>
  1821. head
  1822. .filter((item) => !isNodeDetailSequenceColumn(item))
  1823. .map((item, index) => {
  1824. const title = normalizeGraphDetailText(item.title) || normalizeGraphDetailText(item.key) || `字段${index + 1}`;
  1825. const dataIndex =
  1826. normalizeGraphDetailText(item.dataIndex) || normalizeGraphDetailText(item.key) || `field_${index}`;
  1827. const key = normalizeGraphDetailText(item.key) || dataIndex;
  1828. const width = normalizeNodeDetailColumnWidth(item.width, title);
  1829. return {
  1830. title,
  1831. dataIndex,
  1832. key,
  1833. width,
  1834. };
  1835. })
  1836. .filter((item) => item.dataIndex);
  1837. const buildNodeDetailTableRows = (records: Record<string, unknown>[]) =>
  1838. records.map((item, index) => ({
  1839. rowKey: String(item.id ?? item.rowId ?? item.fileId ?? index),
  1840. ...item,
  1841. }));
  1842. const resetAnalysisDetailTableFilters = () => {
  1843. analysisDetailFilters.startDate = '';
  1844. analysisDetailFilters.endDate = '';
  1845. analysisDetailFilters.transAmount = '';
  1846. analysisDetailFilters.transAmountSymbol = 'GE';
  1847. analysisDetailFilters.orderKey = '';
  1848. analysisDetailFilters.sort = '';
  1849. };
  1850. const buildAnalysisDetailNodeQuery = () => {
  1851. const baseQuery = analysisDetailQueryBase.value;
  1852. if (!baseQuery) return null;
  1853. return {
  1854. ...baseQuery,
  1855. page: analysisDetailPagination.value.page,
  1856. limit: analysisDetailPagination.value.limit,
  1857. startDate: analysisDetailFilters.startDate || undefined,
  1858. endDate: analysisDetailFilters.endDate || undefined,
  1859. transAmountSymbol: analysisDetailFilters.transAmount ? analysisDetailFilters.transAmountSymbol : undefined,
  1860. transAmount: analysisDetailFilters.transAmount || undefined,
  1861. orderKey: analysisDetailFilters.orderKey || undefined,
  1862. sort: analysisDetailFilters.sort || undefined,
  1863. } as GraphNodeDetailQuery;
  1864. };
  1865. const fetchAnalysisDetailTable = async () => {
  1866. const query = buildAnalysisDetailNodeQuery();
  1867. if (!query) return;
  1868. analysisDetailLoading.value = true;
  1869. try {
  1870. const response = await fetchGraphNodeDetail(query);
  1871. const { data, head, records, pages } = resolveNodeDetailPayload(response);
  1872. if (head.length) {
  1873. analysisDetailTableColumns.value = buildNodeDetailTableColumns(head);
  1874. analysisDetailTableRows.value = buildNodeDetailTableRows(records);
  1875. const current = Number(pages?.current ?? analysisDetailPagination.value.page);
  1876. const size = Number((pages?.size ?? records.length) || analysisDetailPagination.value.limit);
  1877. const total = Number(pages?.total ?? records.length);
  1878. analysisDetailPagination.value.page =
  1879. Number.isFinite(current) && current > 0 ? current : analysisDetailPagination.value.page;
  1880. analysisDetailPagination.value.limit =
  1881. Number.isFinite(size) && size > 0 ? size : analysisDetailPagination.value.limit;
  1882. analysisDetailPagination.value.total = Number.isFinite(total) && total >= 0 ? total : records.length;
  1883. analysisDetailEntries.value = [];
  1884. return;
  1885. }
  1886. analysisDetailEntries.value = buildNodeDetailEntriesFromPayload(data);
  1887. analysisDetailTableColumns.value = [];
  1888. analysisDetailTableRows.value = [];
  1889. analysisDetailPagination.value.total = 0;
  1890. } catch (error) {
  1891. console.error('load g6 node detail failed', error);
  1892. analysisDetailTableColumns.value = [];
  1893. analysisDetailTableRows.value = [];
  1894. analysisDetailPagination.value.total = 0;
  1895. message.error('节点详情加载失败');
  1896. } finally {
  1897. analysisDetailLoading.value = false;
  1898. }
  1899. };
  1900. const handleAnalysisDetailSearch = async () => {
  1901. analysisDetailPagination.value.page = 1;
  1902. await fetchAnalysisDetailTable();
  1903. };
  1904. const handleAnalysisDetailReset = async () => {
  1905. resetAnalysisDetailTableFilters();
  1906. analysisDetailPagination.value.page = 1;
  1907. await fetchAnalysisDetailTable();
  1908. };
  1909. const handleAnalysisDetailPrevPage = () => {
  1910. if (analysisDetailPagination.value.page <= 1) return;
  1911. analysisDetailPagination.value.page -= 1;
  1912. void fetchAnalysisDetailTable();
  1913. };
  1914. const handleAnalysisDetailNextPage = () => {
  1915. if (analysisDetailPagination.value.page >= analysisDetailTotalPages.value) return;
  1916. analysisDetailPagination.value.page += 1;
  1917. void fetchAnalysisDetailTable();
  1918. };
  1919. const handleAnalysisDetailPageChange = (page: number, pageSize: number) => {
  1920. if (analysisDetailLoading.value) return;
  1921. if (pageSize !== analysisDetailPagination.value.limit) {
  1922. handleAnalysisDetailPageSizeChange(pageSize);
  1923. return;
  1924. }
  1925. if (page === analysisDetailPagination.value.page - 1) {
  1926. handleAnalysisDetailPrevPage();
  1927. return;
  1928. }
  1929. if (page === analysisDetailPagination.value.page + 1) {
  1930. handleAnalysisDetailNextPage();
  1931. return;
  1932. }
  1933. if (page === analysisDetailPagination.value.page) return;
  1934. analysisDetailPagination.value.page = page;
  1935. void fetchAnalysisDetailTable();
  1936. };
  1937. const handleAnalysisDetailPageSizeChange = (size: number) => {
  1938. analysisDetailPagination.value.limit = Number(size);
  1939. analysisDetailPagination.value.page = 1;
  1940. void fetchAnalysisDetailTable();
  1941. };
  1942. const handleAnalysisDetailSortChange = (payload: { orderKey: string; sort: 'ASC' | 'DESC' }) => {
  1943. analysisDetailFilters.orderKey = payload.orderKey;
  1944. analysisDetailFilters.sort = payload.sort;
  1945. analysisDetailPagination.value.page = 1;
  1946. void fetchAnalysisDetailTable();
  1947. };
  1948. const handleAnalysisDetailVisibleChange = (open: boolean) => {
  1949. analysisDetailVisible.value = open;
  1950. };
  1951. const findNodeData = (id: string) => graphRef.value?.getNodeData().find((node) => String(node.id) === id) || null;
  1952. const findEdgeData = (id: string) =>
  1953. graphRef.value?.getEdgeData().find((edge) => edge.id && String(edge.id) === id) || null;
  1954. const findComboData = (id: string) => graphRef.value?.getComboData().find((combo) => String(combo.id) === id) || null;
  1955. const createInitialData = (): GraphData => {
  1956. return {
  1957. combos: [],
  1958. nodes: [],
  1959. edges: [],
  1960. };
  1961. };
  1962. const createAnalysisNodeFromToken = (token: string, kind?: EntityKindKey | null): NodeData | null => {
  1963. const key = normalizeLookupKey(token);
  1964. if (!key) return null;
  1965. const entityKind = kind || inferEntityKindFromToken(token);
  1966. return {
  1967. id: key,
  1968. data: {
  1969. kind: entityKind,
  1970. label: token.trim(),
  1971. fullLabel: token.trim(),
  1972. isAnalysis: true,
  1973. },
  1974. };
  1975. };
  1976. const getRelationLayoutSize = () => {
  1977. const container = graphContainerRef.value || graphWrapperRef.value;
  1978. return {
  1979. width: container?.clientWidth || 1200,
  1980. height: container?.clientHeight || 720,
  1981. };
  1982. };
  1983. const getFiniteNumber = (value: unknown, fallback: number) => {
  1984. const numericValue = Number(value);
  1985. return Number.isFinite(numericValue) ? numericValue : fallback;
  1986. };
  1987. const getGraphElementPositionPoint = (graph: Graph, elementId: string, fallback?: { x?: unknown; y?: unknown }) => {
  1988. let position: unknown;
  1989. try {
  1990. position = graph.getElementPosition(elementId);
  1991. } catch {
  1992. position = undefined;
  1993. }
  1994. const x = Array.isArray(position) || position instanceof Float32Array ? position[0] : (position as any)?.x;
  1995. const y = Array.isArray(position) || position instanceof Float32Array ? position[1] : (position as any)?.y;
  1996. const fallbackX = getFiniteNumber(fallback?.x, 0);
  1997. const fallbackY = getFiniteNumber(fallback?.y, 0);
  1998. return {
  1999. x: getFiniteNumber(x, fallbackX),
  2000. y: getFiniteNumber(y, fallbackY),
  2001. };
  2002. };
  2003. const getNodeAnalysisTokens = (node: NodeData) => {
  2004. const rawData = (node.data?.rawData || {}) as Record<string, any>;
  2005. return uniqueValues(
  2006. [
  2007. node.id,
  2008. node.data?.label,
  2009. node.data?.fullLabel,
  2010. rawData.bm,
  2011. rawData.sfz,
  2012. rawData.id,
  2013. rawData.dxId,
  2014. rawData.dxId0,
  2015. rawData.fzId,
  2016. rawData.mc,
  2017. rawData.name,
  2018. ]
  2019. .map((item) => normalizeLookupKey(item as string | number | null | undefined))
  2020. .filter(Boolean),
  2021. );
  2022. };
  2023. const sortRelationLayoutCandidates = (nodes: NodeData[], degreeRecord: Record<string, number>) =>
  2024. [...nodes].sort((left, right) => {
  2025. const leftKind = getEntityKind(left.data?.kind).key;
  2026. const rightKind = getEntityKind(right.data?.kind).key;
  2027. const personDiff = Number(rightKind === 'person') - Number(leftKind === 'person');
  2028. if (personDiff !== 0) return personDiff;
  2029. const degreeDiff = (degreeRecord[String(right.id)] || 0) - (degreeRecord[String(left.id)] || 0);
  2030. if (degreeDiff !== 0) return degreeDiff;
  2031. return getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
  2032. });
  2033. const resolveRelationLayoutSeedIds = (
  2034. nodes: NodeData[],
  2035. degreeRecord: Record<string, number>,
  2036. preferredSeedIds: string[] = [],
  2037. ) => {
  2038. const nodeIdSet = new Set(nodes.map((node) => String(node.id)));
  2039. const validPreferredSeedIds = preferredSeedIds.map(String).filter((nodeId) => nodeIdSet.has(nodeId));
  2040. if (validPreferredSeedIds.length) return uniqueValues(validPreferredSeedIds);
  2041. const routeSeedTokens = new Set(
  2042. routeGraphRespondents.value
  2043. .flatMap((item) => [item.id, item.objId, item.identityCode, item.name])
  2044. .map((item) => normalizeLookupKey(item))
  2045. .filter(Boolean),
  2046. );
  2047. if (routeSeedTokens.size) {
  2048. const routeSeedIds = nodes
  2049. .filter((node) => getNodeAnalysisTokens(node).some((token) => routeSeedTokens.has(token)))
  2050. .map((node) => String(node.id));
  2051. if (routeSeedIds.length) return routeSeedIds;
  2052. }
  2053. const sortedCandidates = sortRelationLayoutCandidates(nodes, degreeRecord);
  2054. return sortedCandidates[0]?.id ? [String(sortedCandidates[0].id)] : [];
  2055. };
  2056. const buildRelationLevelMap = (nodes: NodeData[], edges: AnalysisLayoutWorkerPayload['edges'], seedIds: string[]) => {
  2057. const adjacency = new Map<string, string[]>();
  2058. nodes.forEach((node) => adjacency.set(String(node.id), []));
  2059. edges.forEach((edge) => {
  2060. adjacency.get(edge.source)?.push(edge.target);
  2061. adjacency.get(edge.target)?.push(edge.source);
  2062. });
  2063. const levelMap = new Map<string, number>();
  2064. const queue = [...seedIds];
  2065. seedIds.forEach((seedId) => levelMap.set(seedId, 0));
  2066. while (queue.length) {
  2067. const currentId = queue.shift() || '';
  2068. const currentLevel = levelMap.get(currentId) ?? 0;
  2069. (adjacency.get(currentId) || []).forEach((nextId) => {
  2070. if (levelMap.has(nextId)) return;
  2071. levelMap.set(nextId, currentLevel + 1);
  2072. queue.push(nextId);
  2073. });
  2074. }
  2075. nodes.forEach((node) => {
  2076. const nodeId = String(node.id);
  2077. if (!levelMap.has(nodeId)) {
  2078. levelMap.set(nodeId, 99);
  2079. }
  2080. });
  2081. return levelMap;
  2082. };
  2083. const cloneAnalysisNodeData = (node: NodeData): NodeData => JSON.parse(JSON.stringify(node)) as NodeData;
  2084. const cloneAnalysisEdgeData = (edge: EdgeData): EdgeData => JSON.parse(JSON.stringify(edge)) as EdgeData;
  2085. const getGraphEdgeEndpointId = (edge: EdgeData, endpoint: 'source' | 'target') =>
  2086. String(endpoint === 'source' ? edge.source || '' : edge.target || '').trim();
  2087. const getUndirectedGraphEdgePairKey = (sourceId?: unknown, targetId?: unknown) => {
  2088. const source = String(sourceId || '').trim();
  2089. const target = String(targetId || '').trim();
  2090. if (!source || !target) return '';
  2091. return [source, target].sort().join('::');
  2092. };
  2093. const getGraphEdgeStyle = (edge: EdgeData) => (edge.style || {}) as Record<string, any>;
  2094. const compactGraphEdgeStyle = (style: Record<string, any>) => (Object.keys(style).length ? style : undefined);
  2095. const isBidirectionalArrowEdge = (edge: EdgeData) => {
  2096. const style = getGraphEdgeStyle(edge);
  2097. if (edge.data?.analysisBidirectionalMirrorEdge) return false;
  2098. return style.startArrow === true && style.endArrow !== false;
  2099. };
  2100. const createUniqueGraphEdgeId = (baseId: string, usedEdgeIdSet: Set<string>) => {
  2101. let edgeId = baseId;
  2102. let index = 1;
  2103. while (usedEdgeIdSet.has(edgeId)) {
  2104. edgeId = `${baseId}-${index}`;
  2105. index += 1;
  2106. }
  2107. usedEdgeIdSet.add(edgeId);
  2108. return edgeId;
  2109. };
  2110. const cloneGraphEdgeForCurve = (edge: EdgeData) =>
  2111. ({
  2112. ...edge,
  2113. data: edge.data ? { ...edge.data } : edge.data,
  2114. style: edge.style ? { ...(edge.style as Record<string, any>) } : edge.style,
  2115. }) as EdgeData;
  2116. const splitBidirectionalArrowEdges = (edges: EdgeData[]) => {
  2117. const usedEdgeIdSet = new Set(edges.map((edge) => String(edge.id || '')).filter(Boolean));
  2118. return edges.flatMap((edge, index) => {
  2119. const source = getGraphEdgeEndpointId(edge, 'source');
  2120. const target = getGraphEdgeEndpointId(edge, 'target');
  2121. if (!source || !target || source === target || !isBidirectionalArrowEdge(edge)) {
  2122. return [cloneGraphEdgeForCurve(edge)];
  2123. }
  2124. const sourceEdge = cloneGraphEdgeForCurve(edge);
  2125. const sourceStyle: Record<string, any> = { ...getGraphEdgeStyle(sourceEdge), startArrow: false, endArrow: true };
  2126. delete sourceStyle.childrenData;
  2127. sourceEdge.source = source;
  2128. sourceEdge.target = target;
  2129. sourceEdge.type = 'quadratic';
  2130. sourceEdge.style = sourceStyle;
  2131. sourceEdge.data = {
  2132. ...(sourceEdge.data || {}),
  2133. analysisBidirectionalSourceEdge: true,
  2134. };
  2135. const baseId = String(edge.id || `${source}-${target}-${index}`).trim() || `${source}-${target}-${index}`;
  2136. const mirrorEdge = cloneGraphEdgeForCurve(edge);
  2137. const mirrorStyle: Record<string, any> = { ...getGraphEdgeStyle(mirrorEdge), startArrow: false, endArrow: true };
  2138. delete mirrorStyle.childrenData;
  2139. mirrorEdge.id = createUniqueGraphEdgeId(`${baseId}-reverse`, usedEdgeIdSet);
  2140. mirrorEdge.source = target;
  2141. mirrorEdge.target = source;
  2142. mirrorEdge.type = 'quadratic';
  2143. mirrorEdge.style = mirrorStyle;
  2144. mirrorEdge.data = {
  2145. ...(mirrorEdge.data || {}),
  2146. analysisBidirectionalMirrorEdge: true,
  2147. analysisSourceEdgeId: edge.id || baseId,
  2148. };
  2149. return [sourceEdge, mirrorEdge];
  2150. });
  2151. };
  2152. const getParallelCurveOffset = (index: number, total: number, isReverseEdge: boolean) => {
  2153. const sign = (index % 2 === 0 ? 1 : -1) * (isReverseEdge ? -1 : 1);
  2154. if (total % 2 === 1) {
  2155. return sign * Math.ceil(index / 2) * PARALLEL_EDGE_CURVE_GAP * 2;
  2156. }
  2157. return sign * (Math.floor(index / 2) * PARALLEL_EDGE_CURVE_GAP * 2 + PARALLEL_EDGE_CURVE_GAP);
  2158. };
  2159. const normalizeGraphEdgeCurves = (edges: EdgeData[]) => {
  2160. const normalizedEdges = splitBidirectionalArrowEdges(edges);
  2161. const groupMap = new Map<string, Array<{ edge: EdgeData; source: string; target: string }>>();
  2162. normalizedEdges.forEach((edge) => {
  2163. const source = getGraphEdgeEndpointId(edge, 'source');
  2164. const target = getGraphEdgeEndpointId(edge, 'target');
  2165. const pairKey = getUndirectedGraphEdgePairKey(source, target);
  2166. if (!pairKey || source === target) return;
  2167. const group = groupMap.get(pairKey) || [];
  2168. group.push({ edge, source, target });
  2169. groupMap.set(pairKey, group);
  2170. });
  2171. groupMap.forEach((group) => {
  2172. if (group.length <= 1) {
  2173. const edge = group[0]?.edge;
  2174. if (!edge) return;
  2175. const style = { ...getGraphEdgeStyle(edge) };
  2176. if (edge.data?.analysisBidirectionalSourceEdge || edge.data?.analysisBidirectionalMirrorEdge) {
  2177. style.curveOffset = edge.data?.analysisBidirectionalMirrorEdge
  2178. ? -PARALLEL_EDGE_CURVE_GAP
  2179. : PARALLEL_EDGE_CURVE_GAP;
  2180. edge.type = 'quadratic';
  2181. } else {
  2182. delete style.curveOffset;
  2183. edge.type = 'line';
  2184. }
  2185. edge.style = compactGraphEdgeStyle(style);
  2186. return;
  2187. }
  2188. const baseSource = group[0].source;
  2189. const baseTarget = group[0].target;
  2190. group.forEach((item, index) => {
  2191. const isReverseEdge = item.source === baseTarget && item.target === baseSource;
  2192. const style = {
  2193. ...getGraphEdgeStyle(item.edge),
  2194. curveOffset: getParallelCurveOffset(index, group.length, isReverseEdge),
  2195. };
  2196. item.edge.type = 'quadratic';
  2197. item.edge.style = style;
  2198. });
  2199. });
  2200. return normalizedEdges;
  2201. };
  2202. const normalizeGraphDataEdgeCurves = <T extends GraphData>(data: T): T => ({
  2203. ...data,
  2204. edges: normalizeGraphEdgeCurves(data.edges || []),
  2205. });
  2206. const refreshCurrentGraphEdgeCurves = async () => {
  2207. const graph = graphRef.value;
  2208. if (!graph) return;
  2209. const nextData = normalizeGraphDataEdgeCurves(graph.getData());
  2210. graph.setData(nextData);
  2211. await graph.draw();
  2212. };
  2213. const getAnalysisNodeData = (node?: NodeData | null) => (node?.data || {}) as Record<string, any>;
  2214. const getAnalysisEdgeData = (edge?: EdgeData | null) => (edge?.data || {}) as Record<string, any>;
  2215. const getAnalysisCollapsedNodes = (node?: NodeData | null) =>
  2216. (getAnalysisNodeData(node).analysisCollapsedNodes || []) as NodeData[];
  2217. const getAnalysisCollapsedEdges = (node?: NodeData | null) =>
  2218. (getAnalysisNodeData(node).analysisCollapsedEdges || []) as EdgeData[];
  2219. const getAnalysisExpandedNodeIds = (node?: NodeData | null) =>
  2220. ((getAnalysisNodeData(node).analysisExpandedNodeIds || []) as unknown[]).map(String).filter(Boolean);
  2221. const getAnalysisExpandedEdgeIds = (node?: NodeData | null) =>
  2222. ((getAnalysisNodeData(node).analysisExpandedEdgeIds || []) as unknown[]).map(String).filter(Boolean);
  2223. const getAnalysisCollapsedNeighborCount = (node?: NodeData | null) =>
  2224. Number(getAnalysisNodeData(node).analysisCollapsedNeighborCount || 0);
  2225. const getAnalysisExpandedNeighborCount = (node?: NodeData | null) =>
  2226. Number(getAnalysisNodeData(node).analysisExpandedNeighborCount || 0);
  2227. const getAnalysisRelationBadgeCount = (node?: NodeData | null) =>
  2228. getAnalysisExpandedNeighborCount(node) || getAnalysisCollapsedNeighborCount(node);
  2229. const getAnalysisRelationBadgeText = (node?: NodeData | null) => {
  2230. const count = getAnalysisRelationBadgeCount(node);
  2231. if (!count) return '';
  2232. return count > 99 ? '99+' : String(count);
  2233. };
  2234. const buildAnalysisGraphNeighborStats = (nodes: NodeData[], edges: EdgeData[]) => {
  2235. const neighborMap = new Map<string, Set<string>>();
  2236. const degreeMap = new Map<string, number>();
  2237. const edgeMapByNodeId = new Map<string, EdgeData[]>();
  2238. nodes.forEach((node) => {
  2239. const nodeId = String(node.id || '');
  2240. if (!nodeId) return;
  2241. neighborMap.set(nodeId, new Set<string>());
  2242. degreeMap.set(nodeId, 0);
  2243. edgeMapByNodeId.set(nodeId, []);
  2244. });
  2245. edges.forEach((edge) => {
  2246. const source = String(edge.source || '');
  2247. const target = String(edge.target || '');
  2248. if (!source || !target || source === target || !neighborMap.has(source) || !neighborMap.has(target)) {
  2249. return;
  2250. }
  2251. neighborMap.get(source)?.add(target);
  2252. neighborMap.get(target)?.add(source);
  2253. degreeMap.set(source, (degreeMap.get(source) || 0) + 1);
  2254. degreeMap.set(target, (degreeMap.get(target) || 0) + 1);
  2255. edgeMapByNodeId.get(source)?.push(edge);
  2256. edgeMapByNodeId.get(target)?.push(edge);
  2257. });
  2258. return { neighborMap, degreeMap, edgeMapByNodeId };
  2259. };
  2260. const getAnalysisGraphDegreeRecord = (nodes: NodeData[], degreeMap: Map<string, number>) =>
  2261. nodes.reduce<Record<string, number>>((accumulator, node) => {
  2262. const nodeId = String(node.id || '');
  2263. if (nodeId) accumulator[nodeId] = degreeMap.get(nodeId) || 0;
  2264. return accumulator;
  2265. }, {});
  2266. const getAnalysisCollapseCenterNodes = (nodes: NodeData[], seedIdSet: Set<string>, degreeMap: Map<string, number>) =>
  2267. [...nodes].sort((left, right) => {
  2268. const leftId = String(left.id || '');
  2269. const rightId = String(right.id || '');
  2270. const seedDiff = Number(seedIdSet.has(rightId)) - Number(seedIdSet.has(leftId));
  2271. if (seedDiff !== 0) return seedDiff;
  2272. const personDiff =
  2273. Number(getEntityKind(right.data?.kind).key === 'person') -
  2274. Number(getEntityKind(left.data?.kind).key === 'person');
  2275. if (personDiff !== 0) return personDiff;
  2276. const degreeDiff = (degreeMap.get(rightId) || 0) - (degreeMap.get(leftId) || 0);
  2277. if (degreeDiff !== 0) return degreeDiff;
  2278. return getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
  2279. });
  2280. const getAnalysisCollapsibleNeighborIds = (options: {
  2281. centerId: string;
  2282. neighborMap: Map<string, Set<string>>;
  2283. hiddenNodeIds: Set<string>;
  2284. seedIdSet: Set<string>;
  2285. }) =>
  2286. Array.from(options.neighborMap.get(options.centerId) || []).filter((neighborId) => {
  2287. if (!neighborId || options.hiddenNodeIds.has(neighborId) || options.seedIdSet.has(neighborId)) return false;
  2288. const neighborDegree = options.neighborMap.get(neighborId)?.size || 0;
  2289. // Only collapse leaf neighbors so bridge nodes connected to other entities remain visible.
  2290. return neighborDegree === 1;
  2291. });
  2292. const applyAnalysisCollapsedNeighbors = (options: {
  2293. centerNode: NodeData;
  2294. collapsedNodeIds: string[];
  2295. nodeMap: Map<string, NodeData>;
  2296. edgeMapByNodeId: Map<string, EdgeData[]>;
  2297. }) => {
  2298. const collapsedNodeIdSet = new Set(options.collapsedNodeIds);
  2299. const collapsedNodes = options.collapsedNodeIds
  2300. .map((nodeId) => options.nodeMap.get(nodeId))
  2301. .filter((node): node is NodeData => Boolean(node))
  2302. .map((node) => cloneAnalysisNodeData(node));
  2303. if (!collapsedNodes.length) return false;
  2304. const edgeKeySet = new Set<string>();
  2305. const collapsedEdges: EdgeData[] = [];
  2306. options.collapsedNodeIds.forEach((nodeId) => {
  2307. (options.edgeMapByNodeId.get(nodeId) || []).forEach((edge, index) => {
  2308. const source = String(edge.source || '');
  2309. const target = String(edge.target || '');
  2310. if (!collapsedNodeIdSet.has(source) && !collapsedNodeIdSet.has(target)) return;
  2311. const edgeKey = String(edge.id || `${source}->${target}-${getEdgeLabel(edge)}-${index}`);
  2312. if (edgeKeySet.has(edgeKey)) return;
  2313. edgeKeySet.add(edgeKey);
  2314. collapsedEdges.push(cloneAnalysisEdgeData(edge));
  2315. });
  2316. });
  2317. options.centerNode.data = {
  2318. ...(options.centerNode.data || {}),
  2319. analysisCollapsedNeighborCount: collapsedNodes.length,
  2320. analysisCollapsedNodes: collapsedNodes,
  2321. analysisCollapsedEdges: collapsedEdges,
  2322. };
  2323. return true;
  2324. };
  2325. const collectHiddenAnalysisNodeIds = (nodes: NodeData[], edges: EdgeData[], seedIds: string[]) => {
  2326. const nodeMap = nodes.reduce<Map<string, NodeData>>((map, node) => {
  2327. const nodeId = String(node.id || '');
  2328. if (nodeId) map.set(nodeId, node);
  2329. return map;
  2330. }, new Map<string, NodeData>());
  2331. const seedIdSet = new Set(seedIds.map(String).filter(Boolean));
  2332. const hiddenNodeIds = new Set<string>();
  2333. const { neighborMap, degreeMap, edgeMapByNodeId } = buildAnalysisGraphNeighborStats(nodes, edges);
  2334. getAnalysisCollapseCenterNodes(nodes, seedIdSet, degreeMap).forEach((centerNode) => {
  2335. const centerId = String(centerNode.id || '');
  2336. if (!centerId || hiddenNodeIds.has(centerId)) return;
  2337. const candidateNeighborIds = getAnalysisCollapsibleNeighborIds({
  2338. centerId,
  2339. neighborMap,
  2340. hiddenNodeIds,
  2341. seedIdSet,
  2342. });
  2343. if (candidateNeighborIds.length <= ANALYSIS_COLLAPSE_NEIGHBOR_THRESHOLD) return;
  2344. if (
  2345. applyAnalysisCollapsedNeighbors({
  2346. centerNode,
  2347. collapsedNodeIds: candidateNeighborIds,
  2348. nodeMap,
  2349. edgeMapByNodeId,
  2350. })
  2351. ) {
  2352. candidateNeighborIds.forEach((nodeId) => hiddenNodeIds.add(nodeId));
  2353. }
  2354. });
  2355. return hiddenNodeIds;
  2356. };
  2357. const getVisibleAnalysisGraph = (
  2358. nodes: NodeData[],
  2359. edges: EdgeData[],
  2360. hiddenNodeIds: Set<string>,
  2361. ): AnalysisDefaultCollapseResult => {
  2362. const visibleNodes = nodes.filter((node) => !hiddenNodeIds.has(String(node.id || '')));
  2363. const visibleNodeIdSet = new Set(visibleNodes.map((node) => String(node.id || '')).filter(Boolean));
  2364. const visibleEdges = edges.filter(
  2365. (edge) => visibleNodeIdSet.has(String(edge.source || '')) && visibleNodeIdSet.has(String(edge.target || '')),
  2366. );
  2367. return {
  2368. nodes: visibleNodes,
  2369. edges: visibleEdges,
  2370. hiddenNodeIds,
  2371. };
  2372. };
  2373. const applyDefaultAnalysisCollapse = (nodes: NodeData[], edges: EdgeData[]): AnalysisDefaultCollapseResult => {
  2374. const { degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
  2375. const degreeRecord = getAnalysisGraphDegreeRecord(nodes, degreeMap);
  2376. const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord);
  2377. const hiddenNodeIds = collectHiddenAnalysisNodeIds(nodes, edges, seedIds);
  2378. return getVisibleAnalysisGraph(nodes, edges, hiddenNodeIds);
  2379. };
  2380. const buildRelationLayoutPayload = (
  2381. data: GraphData,
  2382. options: RelationLayoutOptions = {},
  2383. ): AnalysisLayoutWorkerPayload => {
  2384. const { width, height } = getRelationLayoutSize();
  2385. const centerX = width / 2;
  2386. const centerY = height / 2;
  2387. const nodes = (data.nodes || []).filter((node) => node.id !== undefined && node.id !== null);
  2388. const nodeIdSet = new Set(nodes.map((node) => String(node.id)));
  2389. const edges = (data.edges || [])
  2390. .map((edge) => ({
  2391. source: String(edge.source || ''),
  2392. target: String(edge.target || ''),
  2393. }))
  2394. .filter((edge) => edge.source && edge.target && edge.source !== edge.target)
  2395. .filter((edge) => nodeIdSet.has(edge.source) && nodeIdSet.has(edge.target));
  2396. const degreeRecord = nodes.reduce<Record<string, number>>((accumulator, node) => {
  2397. accumulator[String(node.id)] = 0;
  2398. return accumulator;
  2399. }, {});
  2400. edges.forEach((edge) => {
  2401. degreeRecord[edge.source] = (degreeRecord[edge.source] || 0) + 1;
  2402. degreeRecord[edge.target] = (degreeRecord[edge.target] || 0) + 1;
  2403. });
  2404. const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord, options.seedIds);
  2405. const seedIdSet = new Set(seedIds);
  2406. const levelMap = buildRelationLevelMap(nodes, edges, seedIds);
  2407. const nodeSize = Math.max(24, Math.round(options.nodeSize || ANALYSIS_LAYOUT_NODE_SIZE));
  2408. return {
  2409. width,
  2410. height,
  2411. center: [centerX, centerY],
  2412. nodeWidth: nodeSize,
  2413. nodeHeight: nodeSize,
  2414. layoutPreset: options.preset || 'default',
  2415. degreeRecord,
  2416. nodes: nodes.map((node) => {
  2417. const nodeId = String(node.id);
  2418. return {
  2419. id: nodeId,
  2420. kind: getEntityKind(node.data?.kind).key,
  2421. degree: degreeRecord[nodeId] || 0,
  2422. isSeed: seedIdSet.has(nodeId),
  2423. level: levelMap.get(nodeId) ?? 99,
  2424. x: getFiniteNumber(node.style?.x, centerX),
  2425. y: getFiniteNumber(node.style?.y, centerY),
  2426. anchorX: centerX,
  2427. anchorY: centerY,
  2428. };
  2429. }),
  2430. edges,
  2431. };
  2432. };
  2433. const getGraphDataBounds = (data: GraphData): GraphDataBounds => {
  2434. const nodes = data.nodes || [];
  2435. if (!nodes.length) {
  2436. return { minX: 0, minY: 0, maxX: 0, maxY: 0 };
  2437. }
  2438. return nodes.reduce<GraphDataBounds>(
  2439. (bounds, node) => {
  2440. const x = getFiniteNumber(node.style?.x, 0);
  2441. const y = getFiniteNumber(node.style?.y, 0);
  2442. bounds.minX = Math.min(bounds.minX, x);
  2443. bounds.minY = Math.min(bounds.minY, y);
  2444. bounds.maxX = Math.max(bounds.maxX, x);
  2445. bounds.maxY = Math.max(bounds.maxY, y);
  2446. return bounds;
  2447. },
  2448. {
  2449. minX: Number.POSITIVE_INFINITY,
  2450. minY: Number.POSITIVE_INFINITY,
  2451. maxX: Number.NEGATIVE_INFINITY,
  2452. maxY: Number.NEGATIVE_INFINITY,
  2453. },
  2454. );
  2455. };
  2456. const terminateAnalysisLayoutWorker = () => {
  2457. analysisLayoutWorker?.terminate();
  2458. analysisLayoutWorker = null;
  2459. };
  2460. const createAnalysisLayoutWorker = () => {
  2461. terminateAnalysisLayoutWorker();
  2462. analysisLayoutWorker = new Worker(new URL('../graph/workers/analysisLayout.worker.ts', import.meta.url), {
  2463. type: 'module',
  2464. });
  2465. return analysisLayoutWorker;
  2466. };
  2467. const runAnalysisLayoutWorker = (
  2468. payload: AnalysisLayoutWorkerPayload,
  2469. engine: 'deterministic' | 'legacy-force' = 'legacy-force',
  2470. ) =>
  2471. new Promise<Map<string, { x: number; y: number }>>((resolve, reject) => {
  2472. let timeoutId = 0;
  2473. try {
  2474. const worker = createAnalysisLayoutWorker();
  2475. const cleanup = () => {
  2476. window.clearTimeout(timeoutId);
  2477. worker.onmessage = null;
  2478. worker.onerror = null;
  2479. };
  2480. timeoutId = window.setTimeout(() => {
  2481. cleanup();
  2482. terminateAnalysisLayoutWorker();
  2483. reject(new Error('analysis layout worker timeout'));
  2484. }, ANALYSIS_LAYOUT_WORKER_TIMEOUT);
  2485. worker.onmessage = (event: MessageEvent<AnalysisLayoutWorkerResponseMessage>) => {
  2486. cleanup();
  2487. if (event.data?.type === 'layout-complete') {
  2488. const nextPositions = new Map<string, { x: number; y: number }>();
  2489. (event.data.positions as AnalysisLayoutWorkerPosition[]).forEach((item) => {
  2490. nextPositions.set(item.id, { x: item.x, y: item.y });
  2491. });
  2492. terminateAnalysisLayoutWorker();
  2493. resolve(nextPositions);
  2494. return;
  2495. }
  2496. const messageText = event.data?.message || 'analysis layout worker failed';
  2497. terminateAnalysisLayoutWorker();
  2498. reject(new Error(messageText));
  2499. };
  2500. worker.onerror = (event: ErrorEvent) => {
  2501. cleanup();
  2502. terminateAnalysisLayoutWorker();
  2503. reject(event.error || new Error(event.message || 'analysis layout worker failed'));
  2504. };
  2505. worker.postMessage({
  2506. type: 'layout',
  2507. payload,
  2508. engine,
  2509. });
  2510. } catch (error) {
  2511. terminateAnalysisLayoutWorker();
  2512. reject(error instanceof Error ? error : new Error('analysis layout worker failed'));
  2513. }
  2514. });
  2515. const buildFallbackAnalysisNodePositions = (data: GraphData, options: RelationLayoutOptions = {}) => {
  2516. const layoutResult = computeAnalysisLayout(buildRelationLayoutPayload(data, options));
  2517. return {
  2518. positions: new Map(layoutResult.positions.map((item) => [item.id, { x: item.x, y: item.y }])),
  2519. fixedIds: new Set(layoutResult.fixedIds),
  2520. } satisfies AnalysisLayoutDraft;
  2521. };
  2522. const getAnalysisRelaxBounds = (items: AnalysisRelaxItem[], payload: AnalysisLayoutWorkerPayload) => {
  2523. const spreadRatio = Math.max(2.6, Math.sqrt(Math.max(items.length, 1) / 18) * 2.2);
  2524. return {
  2525. minX: -payload.width * spreadRatio,
  2526. maxX: payload.width * (spreadRatio + 1),
  2527. minY: -payload.height * spreadRatio,
  2528. maxY: payload.height * (spreadRatio + 1),
  2529. } satisfies AnalysisRelaxBounds;
  2530. };
  2531. const clampAnalysisRelaxItem = (item: AnalysisRelaxItem, bounds: AnalysisRelaxBounds) => {
  2532. item.x = Math.min(bounds.maxX, Math.max(bounds.minX, item.x));
  2533. item.y = Math.min(bounds.maxY, Math.max(bounds.minY, item.y));
  2534. };
  2535. const getAnalysisRelaxOverlap = (
  2536. left: AnalysisRelaxItem,
  2537. right: AnalysisRelaxItem,
  2538. boxWidth: number,
  2539. boxHeight: number,
  2540. ): AnalysisRelaxOverlap | null => {
  2541. const deltaX = right.x - left.x;
  2542. const deltaY = right.y - left.y;
  2543. const overlapX = boxWidth - Math.abs(deltaX);
  2544. const overlapY = boxHeight - Math.abs(deltaY);
  2545. if (overlapX <= 0 || overlapY <= 0) return null;
  2546. return {
  2547. left,
  2548. right,
  2549. overlapX,
  2550. overlapY,
  2551. deltaX,
  2552. deltaY,
  2553. };
  2554. };
  2555. const getAnalysisRelaxDirection = (overlap: AnalysisRelaxOverlap, leftIndex: number, rightIndex: number) => {
  2556. const separateOnX = overlap.overlapX < overlap.overlapY;
  2557. if (separateOnX) {
  2558. return overlap.deltaX === 0 ? (leftIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaX);
  2559. }
  2560. return overlap.deltaY === 0 ? (rightIndex % 2 === 0 ? -1 : 1) : Math.sign(overlap.deltaY);
  2561. };
  2562. const moveAnalysisRelaxItem = (
  2563. item: AnalysisRelaxItem,
  2564. options: { separateOnX: boolean; direction: number; offset: number; multiplier: number },
  2565. ) => {
  2566. if (options.separateOnX) {
  2567. item.x += options.direction * options.offset * options.multiplier;
  2568. return;
  2569. }
  2570. item.y += options.direction * options.offset * options.multiplier;
  2571. };
  2572. const separateAnalysisRelaxItems = (
  2573. overlap: AnalysisRelaxOverlap,
  2574. leftIndex: number,
  2575. rightIndex: number,
  2576. fixedIds: Set<string>,
  2577. bounds: AnalysisRelaxBounds,
  2578. ) => {
  2579. const { left, right } = overlap;
  2580. const leftFixed = fixedIds.has(left.id);
  2581. const rightFixed = fixedIds.has(right.id);
  2582. if (leftFixed && rightFixed) return false;
  2583. const separateOnX = overlap.overlapX < overlap.overlapY;
  2584. const direction = getAnalysisRelaxDirection(overlap, leftIndex, rightIndex);
  2585. const offset = (separateOnX ? overlap.overlapX : overlap.overlapY) / 2 + 20;
  2586. const moveOptions = { separateOnX, direction, offset };
  2587. if (leftFixed) {
  2588. moveAnalysisRelaxItem(right, { ...moveOptions, multiplier: 2 });
  2589. } else if (rightFixed) {
  2590. moveAnalysisRelaxItem(left, { ...moveOptions, multiplier: -2 });
  2591. } else {
  2592. moveAnalysisRelaxItem(left, { ...moveOptions, multiplier: -1 });
  2593. moveAnalysisRelaxItem(right, { ...moveOptions, multiplier: 1 });
  2594. }
  2595. clampAnalysisRelaxItem(left, bounds);
  2596. clampAnalysisRelaxItem(right, bounds);
  2597. return true;
  2598. };
  2599. const relaxAnalysisPositionIteration = (
  2600. items: AnalysisRelaxItem[],
  2601. fixedIds: Set<string>,
  2602. bounds: AnalysisRelaxBounds,
  2603. boxWidth: number,
  2604. boxHeight: number,
  2605. ) => {
  2606. let hasOverlap = false;
  2607. for (let leftIndex = 0; leftIndex < items.length; leftIndex += 1) {
  2608. for (let rightIndex = leftIndex + 1; rightIndex < items.length; rightIndex += 1) {
  2609. const overlap = getAnalysisRelaxOverlap(items[leftIndex], items[rightIndex], boxWidth, boxHeight);
  2610. if (!overlap) continue;
  2611. hasOverlap = separateAnalysisRelaxItems(overlap, leftIndex, rightIndex, fixedIds, bounds) || hasOverlap;
  2612. }
  2613. }
  2614. return hasOverlap;
  2615. };
  2616. const getAnalysisRelaxMaxIterations = (itemCount: number) => (itemCount > 220 ? 36 : itemCount > 120 ? 72 : 120);
  2617. const relaxAnalysisNodePositions = (
  2618. positions: Map<string, { x: number; y: number }>,
  2619. payload: AnalysisLayoutWorkerPayload,
  2620. fixedIds: Set<string> = new Set<string>(),
  2621. ) => {
  2622. const items = Array.from(positions.entries()).map(([id, position]) => ({ id, ...position }));
  2623. if (items.length > ANALYSIS_LAYOUT_RELAX_MAX_NODES) {
  2624. return positions;
  2625. }
  2626. const nodeSize = Math.max(payload.nodeWidth, payload.nodeHeight);
  2627. const boxWidth = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_X;
  2628. const boxHeight = nodeSize + ANALYSIS_LAYOUT_NODE_GAP_Y;
  2629. const bounds = getAnalysisRelaxBounds(items, payload);
  2630. const maxIterations = getAnalysisRelaxMaxIterations(items.length);
  2631. for (let iteration = 0; iteration < maxIterations; iteration += 1) {
  2632. const hasOverlap = relaxAnalysisPositionIteration(items, fixedIds, bounds, boxWidth, boxHeight);
  2633. if (!hasOverlap) break;
  2634. }
  2635. return new Map(items.map((item) => [item.id, { x: item.x, y: item.y }]));
  2636. };
  2637. const mapLegacyLayoutPositions = (positions: AnalysisLayoutWorkerPosition[]) =>
  2638. new Map(positions.map((item) => [item.id, { x: item.x, y: item.y }]));
  2639. const resolveWorkerFallbackAnalysisLayoutPositions = async (
  2640. data: GraphData,
  2641. payload: AnalysisLayoutWorkerPayload,
  2642. options: RelationLayoutOptions,
  2643. ): Promise<AnalysisLayoutResolveResult> => {
  2644. const draft = buildFallbackAnalysisNodePositions(data, options);
  2645. let workerPositions = new Map<string, { x: number; y: number }>();
  2646. try {
  2647. workerPositions = await runAnalysisLayoutWorker(payload, 'legacy-force');
  2648. } catch (error) {
  2649. console.warn('relation force layout fallback to deterministic layout', error);
  2650. }
  2651. return {
  2652. draft,
  2653. positions: workerPositions.size >= (data.nodes?.length || 0) ? workerPositions : draft.positions,
  2654. };
  2655. };
  2656. const resolveLegacyAnalysisLayoutPositions = async (
  2657. data: GraphData,
  2658. payload: AnalysisLayoutWorkerPayload,
  2659. options: RelationLayoutOptions,
  2660. ): Promise<AnalysisLayoutResolveResult> => {
  2661. const draft = buildFallbackAnalysisNodePositions(data, options);
  2662. const legacyPositions = await runLegacyForceLayout(payload);
  2663. return {
  2664. draft,
  2665. positions:
  2666. legacyPositions.length >= (data.nodes?.length || 0)
  2667. ? mapLegacyLayoutPositions(legacyPositions)
  2668. : draft.positions,
  2669. };
  2670. };
  2671. const resolveAnalysisLayoutPositions = async (
  2672. data: GraphData,
  2673. payload: AnalysisLayoutWorkerPayload,
  2674. options: RelationLayoutOptions,
  2675. ): Promise<AnalysisLayoutResolveResult> => {
  2676. const nodeCount = data.nodes?.length || 0;
  2677. if (nodeCount > ANALYSIS_LAYOUT_FORCE_WORKER_MAX_NODES) {
  2678. const draft = buildFallbackAnalysisNodePositions(data, options);
  2679. return {
  2680. draft,
  2681. positions: draft.positions,
  2682. };
  2683. }
  2684. if (nodeCount >= LEGACY_FORCE_WORKER_THRESHOLD) {
  2685. return resolveWorkerFallbackAnalysisLayoutPositions(data, payload, options);
  2686. }
  2687. return resolveLegacyAnalysisLayoutPositions(data, payload, options);
  2688. };
  2689. const createMingjingG6LayoutOptions = (data: GraphData): LayoutOptions => {
  2690. const nodeCount = data.nodes?.length || 0;
  2691. const degreeMap = new Map<string, number>();
  2692. const hasExpandedNode = (data.nodes || []).some((node) => {
  2693. const nodeData = getAnalysisNodeData(node);
  2694. return Boolean(nodeData.analysisExpandedNodeIds?.length || nodeData.analysisExpandedNeighborCount);
  2695. });
  2696. const hasExpandedFlag = (data.nodes || []).some((node) => {
  2697. const rawData = (node.data?.rawData || {}) as Record<string, any>;
  2698. return Boolean(rawData.expend || node.data?.expend);
  2699. });
  2700. (data.nodes || []).forEach((node) => {
  2701. const nodeId = String(node.id || '');
  2702. if (nodeId) degreeMap.set(nodeId, 0);
  2703. });
  2704. (data.edges || []).forEach((edge) => {
  2705. const source = String(edge.source || '');
  2706. const target = String(edge.target || '');
  2707. if (!source || !target || source === target) return;
  2708. degreeMap.set(source, (degreeMap.get(source) || 0) + 1);
  2709. degreeMap.set(target, (degreeMap.get(target) || 0) + 1);
  2710. });
  2711. return {
  2712. type: 'force',
  2713. center: ANALYSIS_LAYOUT_MINGJING_CENTER,
  2714. nodeSize: ANALYSIS_LAYOUT_COLLISION_SIZE,
  2715. nodeSpacing: 0,
  2716. preventOverlap: true,
  2717. collideStrength: ANALYSIS_LAYOUT_FORCE_COLLIDE_STRENGTH,
  2718. animate: false,
  2719. gravity: ANALYSIS_LAYOUT_FORCE_GRAVITY,
  2720. factor: ANALYSIS_LAYOUT_FORCE_FACTOR,
  2721. maxIteration: hasExpandedNode ? 150 : 1000,
  2722. linkDistance: (edge: { source?: string; target?: string }, source: { id?: string }, target: { id?: string }) => {
  2723. const sourceDegree = degreeMap.get(String(source.id || '')) || 0;
  2724. const targetDegree = degreeMap.get(String(target.id || '')) || 0;
  2725. const minDegree = Math.min(sourceDegree, targetDegree);
  2726. if (minDegree <= 1) {
  2727. return ANALYSIS_LAYOUT_FORCE_CENTER_LINK_DISTANCE;
  2728. }
  2729. return nodeCount > 5 ? ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_SHORT : ANALYSIS_LAYOUT_FORCE_LINK_DISTANCE_LONG;
  2730. },
  2731. nodeStrength: (node: { id?: string }) => {
  2732. if (hasExpandedNode && !hasExpandedFlag) {
  2733. return ANALYSIS_LAYOUT_FORCE_EXPANDED_STRENGTH;
  2734. }
  2735. const degree = degreeMap.get(String(node.id || '')) || 0;
  2736. if (degree === 1) return ANALYSIS_LAYOUT_FORCE_LEAF_STRENGTH;
  2737. if (degree > 0) return degree * ANALYSIS_LAYOUT_FORCE_DEGREE_STRENGTH;
  2738. return 120;
  2739. },
  2740. } as LayoutOptions;
  2741. };
  2742. const syncGraphNodePositionsToData = (data: GraphData, graph: Graph) => {
  2743. const nodePositionMap = new Map<string, { x: number; y: number }>();
  2744. (graph.getNodeData() || []).forEach((node) => {
  2745. const nodeId = String(node.id || '');
  2746. const { x, y } = getGraphElementPositionPoint(graph, nodeId, node.style as any);
  2747. if (!nodeId || !Number.isFinite(x) || !Number.isFinite(y)) return;
  2748. nodePositionMap.set(nodeId, { x, y });
  2749. });
  2750. (data.nodes || []).forEach((node) => {
  2751. const position = nodePositionMap.get(String(node.id || ''));
  2752. if (!position) return;
  2753. node.style = {
  2754. ...(node.style || {}),
  2755. x: position.x,
  2756. y: position.y,
  2757. };
  2758. });
  2759. };
  2760. const syncRenderedNodePositionsToGraphData = (graph = graphRef.value) => {
  2761. if (!graph) return false;
  2762. if (isLargeGraphData(graph.getData())) return false;
  2763. const updates = (graph.getNodeData() || [])
  2764. .map((node) => {
  2765. const nodeId = String(node.id || '');
  2766. if (!nodeId) return null;
  2767. const { x, y } = getGraphElementPositionPoint(graph, nodeId, node.style as any);
  2768. if (!Number.isFinite(x) || !Number.isFinite(y)) return null;
  2769. const currentX = Number((node.style as any)?.x);
  2770. const currentY = Number((node.style as any)?.y);
  2771. if (Math.abs(currentX - x) < 0.5 && Math.abs(currentY - y) < 0.5) return null;
  2772. return {
  2773. id: node.id,
  2774. style: {
  2775. ...(node.style || {}),
  2776. x,
  2777. y,
  2778. },
  2779. };
  2780. })
  2781. .filter((item): item is { id: NodeData['id']; style: NonNullable<NodeData['style']> } => Boolean(item));
  2782. if (!updates.length) return false;
  2783. graph.updateNodeData(updates);
  2784. return true;
  2785. };
  2786. const getAnalysisLeafRingCenterIds = (
  2787. nodes: NodeData[],
  2788. neighborMap: Map<string, Set<string>>,
  2789. degreeMap: Map<string, number>,
  2790. ) =>
  2791. [...nodes]
  2792. .map((node) => {
  2793. const nodeId = String(node.id || '');
  2794. const leafNeighborIds = Array.from(neighborMap.get(nodeId) || []).filter(
  2795. (neighborId) => (degreeMap.get(neighborId) || 0) <= 1,
  2796. );
  2797. return {
  2798. nodeId,
  2799. node,
  2800. degree: degreeMap.get(nodeId) || 0,
  2801. leafNeighborIds,
  2802. kind: getEntityKind(node.data?.kind).key,
  2803. label: getNodeLabel(node),
  2804. };
  2805. })
  2806. .filter((item) => item.nodeId && item.leafNeighborIds.length >= 3)
  2807. .sort((left, right) => {
  2808. const personDiff = Number(right.kind === 'person') - Number(left.kind === 'person');
  2809. if (personDiff !== 0) return personDiff;
  2810. const degreeDiff = right.degree - left.degree;
  2811. if (degreeDiff !== 0) return degreeDiff;
  2812. const leafDiff = right.leafNeighborIds.length - left.leafNeighborIds.length;
  2813. if (leafDiff !== 0) return leafDiff;
  2814. return left.label.localeCompare(right.label, 'zh-CN');
  2815. })
  2816. .map((item) => item.nodeId);
  2817. const normalizeAnalysisAngle = (angle: number) => {
  2818. if (!Number.isFinite(angle)) return 0;
  2819. let normalizedAngle = angle;
  2820. while (normalizedAngle <= -Math.PI) normalizedAngle += Math.PI * 2;
  2821. while (normalizedAngle > Math.PI) normalizedAngle -= Math.PI * 2;
  2822. return normalizedAngle;
  2823. };
  2824. const getAnalysisGraphDataCenterPoint = (data: GraphData) => {
  2825. const bounds = getGraphDataBounds(data);
  2826. if (![bounds.minX, bounds.minY, bounds.maxX, bounds.maxY].every((value) => Number.isFinite(value))) {
  2827. return getGraphCenterCanvasPoint();
  2828. }
  2829. return {
  2830. x: (bounds.minX + bounds.maxX) / 2,
  2831. y: (bounds.minY + bounds.maxY) / 2,
  2832. };
  2833. };
  2834. const getAnalysisLeafRingNearbyCenters = (
  2835. centerId: string,
  2836. centerPosition: { x: number; y: number },
  2837. centerMetaMap: Map<string, AnalysisLeafRingCenterMeta>,
  2838. ) =>
  2839. Array.from(centerMetaMap.values())
  2840. .filter((item) => item.id !== centerId)
  2841. .map((item) => {
  2842. const deltaX = centerPosition.x - item.position.x;
  2843. const deltaY = centerPosition.y - item.position.y;
  2844. return {
  2845. ...item,
  2846. distance: Math.hypot(deltaX, deltaY),
  2847. deltaX,
  2848. deltaY,
  2849. };
  2850. })
  2851. .filter((item) => item.distance < ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE)
  2852. .sort((left, right) => left.distance - right.distance || right.leafCount - left.leafCount);
  2853. const getAnalysisLeafRingCenterDesiredGap = (left: AnalysisLeafRingCenterMeta, right: AnalysisLeafRingCenterMeta) =>
  2854. Math.min(
  2855. ANALYSIS_LAYOUT_CENTER_CLUSTER_MAX_GAP,
  2856. ANALYSIS_LAYOUT_CENTER_CLUSTER_MIN_GAP +
  2857. Math.max(left.leafCount, right.leafCount) * 5 +
  2858. Math.min(70, ((left.leafCount + right.leafCount) / 2) * 3),
  2859. );
  2860. const separateAnalysisLeafRingCenters = (
  2861. centerMetaMap: Map<string, AnalysisLeafRingCenterMeta>,
  2862. graphCenterPoint: { x: number; y: number },
  2863. ) => {
  2864. const centers = Array.from(centerMetaMap.values());
  2865. if (centers.length < 2) return;
  2866. for (let iteration = 0; iteration < ANALYSIS_LAYOUT_CENTER_CLUSTER_ITERATIONS; iteration += 1) {
  2867. let moved = false;
  2868. for (let leftIndex = 0; leftIndex < centers.length; leftIndex += 1) {
  2869. for (let rightIndex = leftIndex + 1; rightIndex < centers.length; rightIndex += 1) {
  2870. const left = centers[leftIndex];
  2871. const right = centers[rightIndex];
  2872. const deltaX = right.position.x - left.position.x;
  2873. const deltaY = right.position.y - left.position.y;
  2874. const distance = Math.hypot(deltaX, deltaY);
  2875. const desiredGap = getAnalysisLeafRingCenterDesiredGap(left, right);
  2876. if (distance >= desiredGap) continue;
  2877. let directionX = 0;
  2878. let directionY = -1;
  2879. if (distance > 1) {
  2880. directionX = deltaX / distance;
  2881. directionY = deltaY / distance;
  2882. } else {
  2883. const fallbackX = left.position.x - graphCenterPoint.x || (leftIndex % 2 === 0 ? -1 : 1);
  2884. const fallbackY = left.position.y - graphCenterPoint.y || (rightIndex % 2 === 0 ? -1 : 1);
  2885. const fallbackDistance = Math.hypot(fallbackX, fallbackY) || 1;
  2886. directionX = fallbackX / fallbackDistance;
  2887. directionY = fallbackY / fallbackDistance;
  2888. }
  2889. const overlap = desiredGap - distance;
  2890. const shift = Math.min(ANALYSIS_LAYOUT_CENTER_CLUSTER_PUSH_STEP, overlap * 0.38 + 6);
  2891. const leftWeight = 1 + left.leafCount * 0.6 + left.degree * 0.12;
  2892. const rightWeight = 1 + right.leafCount * 0.6 + right.degree * 0.12;
  2893. const totalWeight = leftWeight + rightWeight;
  2894. const leftShift = shift * (rightWeight / totalWeight);
  2895. const rightShift = shift * (leftWeight / totalWeight);
  2896. left.position.x -= directionX * leftShift;
  2897. left.position.y -= directionY * leftShift;
  2898. right.position.x += directionX * rightShift;
  2899. right.position.y += directionY * rightShift;
  2900. moved = true;
  2901. }
  2902. }
  2903. if (!moved) break;
  2904. }
  2905. centers.forEach((center) => {
  2906. center.node.style = {
  2907. ...(center.node.style || {}),
  2908. x: center.position.x,
  2909. y: center.position.y,
  2910. };
  2911. });
  2912. };
  2913. const getAnalysisLeafRingBaseAngle = (
  2914. centerPosition: { x: number; y: number },
  2915. centerId: string,
  2916. neighborMap: Map<string, Set<string>>,
  2917. degreeMap: Map<string, number>,
  2918. nodeMap: Map<string, NodeData>,
  2919. graphCenterPoint: { x: number; y: number },
  2920. nearbyCenters: Array<
  2921. AnalysisLeafRingCenterMeta & {
  2922. distance: number;
  2923. deltaX: number;
  2924. deltaY: number;
  2925. }
  2926. >,
  2927. ) => {
  2928. let baseVectorX = 0;
  2929. let baseVectorY = -1;
  2930. const backboneNeighbors = Array.from(neighborMap.get(centerId) || [])
  2931. .filter((neighborId) => (degreeMap.get(neighborId) || 0) > 1)
  2932. .map((neighborId) => nodeMap.get(neighborId))
  2933. .filter((node): node is NodeData => Boolean(node));
  2934. if (backboneNeighbors.length) {
  2935. const averagePosition = backboneNeighbors.reduce(
  2936. (accumulator, node) => {
  2937. const position = getAnalysisNodePosition(node);
  2938. accumulator.x += position.x;
  2939. accumulator.y += position.y;
  2940. return accumulator;
  2941. },
  2942. { x: 0, y: 0 },
  2943. );
  2944. averagePosition.x /= backboneNeighbors.length;
  2945. averagePosition.y /= backboneNeighbors.length;
  2946. const deltaX = centerPosition.x - averagePosition.x;
  2947. const deltaY = centerPosition.y - averagePosition.y;
  2948. if (Math.hypot(deltaX, deltaY) > 1) {
  2949. baseVectorX = deltaX;
  2950. baseVectorY = deltaY;
  2951. }
  2952. } else {
  2953. const graphDeltaX = centerPosition.x - graphCenterPoint.x;
  2954. const graphDeltaY = centerPosition.y - graphCenterPoint.y;
  2955. if (Math.hypot(graphDeltaX, graphDeltaY) > 1) {
  2956. baseVectorX = graphDeltaX;
  2957. baseVectorY = graphDeltaY;
  2958. }
  2959. }
  2960. let vectorX = baseVectorX;
  2961. let vectorY = baseVectorY;
  2962. nearbyCenters.forEach((item) => {
  2963. const distanceRatio = Math.max(0, ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - item.distance);
  2964. if (!distanceRatio) return;
  2965. const normalizedDeltaX = item.deltaX / Math.max(item.distance, 1);
  2966. const normalizedDeltaY = item.deltaY / Math.max(item.distance, 1);
  2967. const weight =
  2968. (distanceRatio / ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE) * (1.8 + Math.min(1.4, item.leafCount / 8));
  2969. vectorX += normalizedDeltaX * weight;
  2970. vectorY += normalizedDeltaY * weight;
  2971. });
  2972. if (Math.hypot(vectorX, vectorY) > 1) {
  2973. return Math.atan2(vectorY, vectorX);
  2974. }
  2975. if (Math.hypot(baseVectorX, baseVectorY) > 1) {
  2976. return Math.atan2(baseVectorY, baseVectorX);
  2977. }
  2978. return -Math.PI / 2;
  2979. };
  2980. const expandAnalysisLeafRingNodes = (data: GraphData) => {
  2981. const nodes = data.nodes || [];
  2982. const edges = data.edges || [];
  2983. if (!nodes.length || !edges.length) return;
  2984. const nodeMap = new Map(nodes.map((node) => [String(node.id || ''), node]));
  2985. const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
  2986. const occupiedLeafIds = new Set<string>();
  2987. const graphCenterPoint = getAnalysisGraphDataCenterPoint(data);
  2988. const centerMetaMap = new Map<string, AnalysisLeafRingCenterMeta>();
  2989. getAnalysisLeafRingCenterIds(nodes, neighborMap, degreeMap).forEach((centerId) => {
  2990. const centerNode = nodeMap.get(centerId);
  2991. if (!centerNode) return;
  2992. centerMetaMap.set(centerId, {
  2993. id: centerId,
  2994. node: centerNode,
  2995. position: getAnalysisNodePosition(centerNode),
  2996. leafCount: Array.from(neighborMap.get(centerId) || []).filter(
  2997. (neighborId) => (degreeMap.get(neighborId) || 0) <= 1,
  2998. ).length,
  2999. degree: degreeMap.get(centerId) || 0,
  3000. });
  3001. });
  3002. separateAnalysisLeafRingCenters(centerMetaMap, graphCenterPoint);
  3003. Array.from(centerMetaMap.values()).forEach((centerMeta) => {
  3004. const centerId = centerMeta.id;
  3005. const centerPosition = centerMeta.position;
  3006. const nearbyCenters = getAnalysisLeafRingNearbyCenters(centerId, centerPosition, centerMetaMap);
  3007. const crowdingFactor = nearbyCenters.reduce(
  3008. (total, item) =>
  3009. total +
  3010. (ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - item.distance) / ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE,
  3011. 0,
  3012. );
  3013. const closestCenterDistance = nearbyCenters[0]?.distance ?? Number.POSITIVE_INFINITY;
  3014. const dynamicMinSweep = Math.max(
  3015. ANALYSIS_LAYOUT_RING_DENSE_MIN_SWEEP,
  3016. ANALYSIS_LAYOUT_RING_MIN_SWEEP - Math.min(Math.PI / 8, crowdingFactor * 0.14),
  3017. );
  3018. const maxAvailableSweep = Math.max(
  3019. dynamicMinSweep,
  3020. ANALYSIS_LAYOUT_RING_SECTOR_SWEEP - Math.min(Math.PI / 2, crowdingFactor * 0.4),
  3021. );
  3022. const dynamicRadiusBoost = Math.min(
  3023. ANALYSIS_LAYOUT_RING_DENSE_RADIUS_BOOST,
  3024. Math.max(0, ANALYSIS_LAYOUT_RING_NEARBY_CENTER_DISTANCE - closestCenterDistance) * 0.34 + crowdingFactor * 24,
  3025. );
  3026. const baseAngle = getAnalysisLeafRingBaseAngle(
  3027. centerPosition,
  3028. centerId,
  3029. neighborMap,
  3030. degreeMap,
  3031. nodeMap,
  3032. graphCenterPoint,
  3033. nearbyCenters,
  3034. );
  3035. const leafNodes = Array.from(neighborMap.get(centerId) || [])
  3036. .filter((neighborId) => (degreeMap.get(neighborId) || 0) <= 1 && !occupiedLeafIds.has(neighborId))
  3037. .map((neighborId) => nodeMap.get(neighborId))
  3038. .filter((node): node is NodeData => Boolean(node))
  3039. .sort((left, right) => {
  3040. const leftPosition = getAnalysisNodePosition(left);
  3041. const rightPosition = getAnalysisNodePosition(right);
  3042. const leftAngle = normalizeAnalysisAngle(
  3043. Math.atan2(leftPosition.y - centerPosition.y, leftPosition.x - centerPosition.x) - baseAngle,
  3044. );
  3045. const rightAngle = normalizeAnalysisAngle(
  3046. Math.atan2(rightPosition.y - centerPosition.y, rightPosition.x - centerPosition.x) - baseAngle,
  3047. );
  3048. return leftAngle - rightAngle || getNodeLabel(left).localeCompare(getNodeLabel(right), 'zh-CN');
  3049. });
  3050. if (leafNodes.length < 3) return;
  3051. let cursor = 0;
  3052. let ringIndex = 0;
  3053. while (cursor < leafNodes.length) {
  3054. const radius =
  3055. ANALYSIS_LAYOUT_RING_MIN_RADIUS + dynamicRadiusBoost + ringIndex * ANALYSIS_EXPAND_RING_RADIUS_STEP;
  3056. const maxRingCount = Math.max(5, Math.floor((maxAvailableSweep * radius) / ANALYSIS_EXPAND_ARC_GAP));
  3057. const ringNodes = leafNodes.slice(cursor, cursor + maxRingCount);
  3058. const desiredSweep = ringNodes.length > 1 ? ((ringNodes.length - 1) * ANALYSIS_EXPAND_ARC_GAP) / radius : 0;
  3059. const ringSweep = Math.min(maxAvailableSweep, Math.max(dynamicMinSweep, desiredSweep));
  3060. ringNodes.forEach((node, index) => {
  3061. const nodeId = String(node.id || '');
  3062. occupiedLeafIds.add(nodeId);
  3063. const angle =
  3064. ringNodes.length === 1
  3065. ? baseAngle
  3066. : baseAngle - ringSweep / 2 + (ringSweep * (index + 1)) / (ringNodes.length + 1);
  3067. node.style = {
  3068. ...(node.style || {}),
  3069. x: centerPosition.x + Math.cos(angle) * radius,
  3070. y: centerPosition.y + Math.sin(angle) * radius,
  3071. };
  3072. });
  3073. cursor += ringNodes.length;
  3074. ringIndex += 1;
  3075. }
  3076. });
  3077. };
  3078. const applyAnalysisLayoutPositions = (data: GraphData, positionMap: Map<string, { x: number; y: number }>) => {
  3079. (data.nodes || []).forEach((node) => {
  3080. const position = positionMap.get(String(node.id));
  3081. if (!position) return;
  3082. node.style = {
  3083. ...(node.style || {}),
  3084. x: position.x,
  3085. y: position.y,
  3086. };
  3087. });
  3088. };
  3089. const applyPersonNeighborClusterLayout = (data: GraphData, positionMap: Map<string, { x: number; y: number }>) => {
  3090. const nodes = data.nodes || [];
  3091. const edges = data.edges || [];
  3092. if (!nodes.length || !edges.length) return positionMap;
  3093. const nodeMap = new Map(nodes.map((node) => [String(node.id), node]));
  3094. const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
  3095. const centers = [...nodes]
  3096. .map((node) => {
  3097. const id = String(node.id || '');
  3098. return {
  3099. id,
  3100. degree: degreeMap.get(id) || 0,
  3101. };
  3102. })
  3103. .filter((item) => item.id && item.degree >= PERSON_NEIGHBOR_CLUSTER_MIN_HUB_DEGREE && positionMap.has(item.id))
  3104. .sort((left, right) => right.degree - left.degree);
  3105. const centerIdSet = new Set(centers.map((item) => item.id));
  3106. const clusterMap = new Map<string, NodeData[]>();
  3107. nodes
  3108. .filter((node) => getEntityKind(node.data?.kind).key === 'person' && positionMap.has(String(node.id)))
  3109. .forEach((node) => {
  3110. const nodeId = String(node.id);
  3111. const nodePosition = positionMap.get(nodeId);
  3112. if (!nodePosition) return;
  3113. const closestCenter = Array.from(neighborMap.get(nodeId) || [])
  3114. .filter((centerId) => centerIdSet.has(centerId) && positionMap.has(centerId))
  3115. .map((centerId) => {
  3116. const centerPosition = positionMap.get(centerId)!;
  3117. const distance = Math.hypot(centerPosition.x - nodePosition.x, centerPosition.y - nodePosition.y);
  3118. return {
  3119. id: centerId,
  3120. distance,
  3121. degree: degreeMap.get(centerId) || 0,
  3122. };
  3123. })
  3124. .sort((left, right) => left.distance - right.distance || right.degree - left.degree)[0];
  3125. if (!closestCenter) return;
  3126. const cluster = clusterMap.get(closestCenter.id) || [];
  3127. cluster.push(node);
  3128. clusterMap.set(closestCenter.id, cluster);
  3129. });
  3130. centers.forEach((center) => {
  3131. const centerPosition = positionMap.get(center.id);
  3132. const personNeighbors = clusterMap.get(center.id) || [];
  3133. if (!centerPosition) return;
  3134. if (personNeighbors.length < PERSON_NEIGHBOR_CLUSTER_MIN_COUNT) return;
  3135. personNeighbors.sort((left, right) => {
  3136. const leftDegree = degreeMap.get(String(left.id)) || 0;
  3137. const rightDegree = degreeMap.get(String(right.id)) || 0;
  3138. return rightDegree - leftDegree || String(left.id).localeCompare(String(right.id));
  3139. });
  3140. personNeighbors.forEach((node, index) => {
  3141. const nodeId = String(node.id);
  3142. const ringIndex = Math.floor(index / 12);
  3143. const indexInRing = index % 12;
  3144. const ringSize = Math.min(12, personNeighbors.length - ringIndex * 12);
  3145. const radius =
  3146. PERSON_NEIGHBOR_CLUSTER_RADIUS +
  3147. ringIndex * Math.max(PERSON_NEIGHBOR_CLUSTER_RING_STEP, PERSON_NEIGHBOR_CLUSTER_SPACING);
  3148. const angle = -Math.PI / 2 + (Math.PI * 2 * indexInRing) / Math.max(ringSize, 1);
  3149. positionMap.set(nodeId, {
  3150. x: centerPosition.x + Math.cos(angle) * radius,
  3151. y: centerPosition.y + Math.sin(angle) * radius,
  3152. });
  3153. });
  3154. });
  3155. return positionMap;
  3156. };
  3157. const applyAnalysisRelationLayout = async (data: GraphData, options: RelationLayoutOptions = {}) => {
  3158. const nodes = data.nodes || [];
  3159. if (!nodes.length) return getGraphDataBounds(data);
  3160. const payload = buildRelationLayoutPayload(data, { ...options, preset: 'mingjing-g6' });
  3161. let draft: AnalysisLayoutDraft | null = null;
  3162. let positionMap = new Map<string, { x: number; y: number }>();
  3163. try {
  3164. const layoutResult = await resolveAnalysisLayoutPositions(data, payload, { ...options, preset: 'mingjing-g6' });
  3165. draft = layoutResult.draft;
  3166. positionMap = layoutResult.positions;
  3167. } catch (error) {
  3168. console.warn('relation force layout fallback to deterministic layout', error);
  3169. }
  3170. if (!positionMap.size) {
  3171. draft = draft || buildFallbackAnalysisNodePositions(data, { ...options, preset: 'mingjing-g6' });
  3172. positionMap = draft.positions;
  3173. }
  3174. positionMap = relaxAnalysisNodePositions(positionMap, payload, draft?.fixedIds || new Set<string>());
  3175. applyAnalysisLayoutPositions(data, positionMap);
  3176. return getGraphDataBounds(data);
  3177. };
  3178. const buildAnalysisGraphData = (response: GraphAnalysisResponse): GraphData => {
  3179. const graphData = response?.data || { dx: [], gx: [] };
  3180. const rawNodes = Array.isArray(graphData.dx) ? graphData.dx : [];
  3181. const rawEdges = Array.isArray(graphData.gx) ? graphData.gx : [];
  3182. const nodeMap = new Map<string, NodeData>();
  3183. const exactTokenToKey = new Map<string, string>();
  3184. const registerNode = (node: NodeData, exactTokens: Array<string | number | null | undefined>) => {
  3185. const nodeId = String(node.id);
  3186. if (!nodeId) return;
  3187. if (!nodeMap.has(nodeId)) {
  3188. nodeMap.set(nodeId, node);
  3189. }
  3190. exactTokens
  3191. .map((item) => normalizeLookupKey(item))
  3192. .filter(Boolean)
  3193. .forEach((token) => {
  3194. if (!exactTokenToKey.has(token)) {
  3195. exactTokenToKey.set(token, nodeId);
  3196. }
  3197. });
  3198. };
  3199. rawNodes.forEach((rawNode) => {
  3200. const primaryToken = rawNode.bm || rawNode.sfz || rawNode.mc || rawNode.id;
  3201. const key = normalizeLookupKey(primaryToken);
  3202. if (!key) return;
  3203. const kind =
  3204. getEntityKindByClazzPositionToken(rawNode.class || rawNode.clazz) ||
  3205. inferEntityKindFromToken(rawNode.mc || rawNode.bm || rawNode.sfz || rawNode.id);
  3206. const fullLabel = getAnalysisNodeFullLabel(rawNode, kind);
  3207. registerNode(
  3208. {
  3209. id: key,
  3210. data: {
  3211. kind,
  3212. label: fullLabel,
  3213. secondaryLabel: getAnalysisNodeSecondaryLabel(rawNode, kind),
  3214. fullLabel,
  3215. rawData: rawNode,
  3216. isAnalysis: true,
  3217. },
  3218. },
  3219. [rawNode.bm, rawNode.sfz, rawNode.id, rawNode.dxId, rawNode.dxId0, rawNode.fzId, rawNode.mc],
  3220. );
  3221. });
  3222. const resolveNodeKey = (token?: string | null, preferredKind?: EntityKindKey | null) => {
  3223. const lookupKey = normalizeLookupKey(token);
  3224. if (!lookupKey) return '';
  3225. const existingKey = exactTokenToKey.get(lookupKey);
  3226. if (existingKey) return existingKey;
  3227. const virtualNode = createAnalysisNodeFromToken(String(token || ''), preferredKind);
  3228. if (!virtualNode) return '';
  3229. registerNode(virtualNode, [token, lookupKey]);
  3230. return String(virtualNode.id);
  3231. };
  3232. const edges = normalizeGraphEdgeCurves(
  3233. rawEdges
  3234. .map((rawEdge, index) => {
  3235. const { sourceKind, targetKind } = getEdgeEndpointKindsByClazz(rawEdge.clazz);
  3236. const source = resolveNodeKey(rawEdge.dx1, sourceKind);
  3237. const target = resolveNodeKey(rawEdge.dx2, targetKind);
  3238. if (!source || !target || source === target) return null;
  3239. return {
  3240. id: rawEdge.id || `analysis-edge-${index}`,
  3241. source,
  3242. target,
  3243. type: 'line',
  3244. data: {
  3245. label: buildAnalysisEdgeLabel(rawEdge),
  3246. rawData: rawEdge,
  3247. isAnalysis: true,
  3248. },
  3249. } as EdgeData;
  3250. })
  3251. .filter((edge): edge is EdgeData => Boolean(edge)),
  3252. );
  3253. const nodes = Array.from(nodeMap.values());
  3254. const shouldUsePerformanceLabels = isLargeGraphData({ nodes, edges, combos: [] });
  3255. const edgeLabelLimit = shouldUsePerformanceLabels
  3256. ? getBoundedLimit(ANALYSIS_PERFORMANCE_EDGE_LABEL_LIMIT, edges.length)
  3257. : 0;
  3258. nodes.forEach((node, index) => {
  3259. node.data = {
  3260. ...(node.data || {}),
  3261. performanceLabel: getNodeLabel(node),
  3262. performanceSecondaryLabel: getNodeSecondaryLabel(node),
  3263. };
  3264. });
  3265. edges.forEach((edge, index) => {
  3266. edge.data = {
  3267. ...(edge.data || {}),
  3268. performanceLabel: index < edgeLabelLimit ? getEdgeLabel(edge) : '',
  3269. };
  3270. });
  3271. return { nodes, edges, combos: [] };
  3272. };
  3273. const normalizeGraphData = (data: GraphData): Required<GraphData> => ({
  3274. nodes: data.nodes || [],
  3275. edges: data.edges || [],
  3276. combos: data.combos || [],
  3277. });
  3278. const createSnapshotFromData = (data: GraphData) => JSON.stringify(normalizeGraphData(data));
  3279. const serializeGraph = () => {
  3280. syncRenderedNodePositionsToGraphData();
  3281. return createSnapshotFromData(graphRef.value?.getData() || { nodes: [], edges: [], combos: [] });
  3282. };
  3283. const cloneGraphData = (data: GraphData): Required<GraphData> =>
  3284. JSON.parse(JSON.stringify(normalizeGraphData(data))) as Required<GraphData>;
  3285. const getAnalysisResponseCounts = (response?: GraphAnalysisResponse | null) => {
  3286. const graphData = (response as any)?.data ?? response;
  3287. return {
  3288. nodeCount: Array.isArray(graphData?.dx) ? graphData.dx.length : 0,
  3289. edgeCount: Array.isArray(graphData?.gx) ? graphData.gx.length : 0,
  3290. };
  3291. };
  3292. const syncActiveCanvasAnalysisTotals = () => {
  3293. const current = activeGraphCanvas.value;
  3294. if (!current) return;
  3295. current.analysisTotalNodeCount = analysisTotalNodeCount.value;
  3296. current.analysisTotalEdgeCount = analysisTotalEdgeCount.value;
  3297. };
  3298. const setAnalysisTotalCounts = (nodeCount = 0, edgeCount = 0) => {
  3299. analysisTotalNodeCount.value = Math.max(0, Number(nodeCount) || 0);
  3300. analysisTotalEdgeCount.value = Math.max(0, Number(edgeCount) || 0);
  3301. syncActiveCanvasAnalysisTotals();
  3302. };
  3303. const clearAnalysisTotalCounts = () => {
  3304. setAnalysisTotalCounts(0, 0);
  3305. };
  3306. const restoreAnalysisTotalCountsFromActiveCanvas = () => {
  3307. setAnalysisTotalCounts(
  3308. Number(activeGraphCanvas.value?.analysisTotalNodeCount || 0),
  3309. Number(activeGraphCanvas.value?.analysisTotalEdgeCount || 0),
  3310. );
  3311. };
  3312. const markActiveCanvasDirty = (dirty = true) => {
  3313. const current = activeGraphCanvas.value;
  3314. if (current) current.dirty = dirty;
  3315. };
  3316. const getFallbackZoomValue = () => {
  3317. const zoomValue = Number.parseFloat(zoomLabel.value);
  3318. return Number.isFinite(zoomValue) && zoomValue > 0 ? zoomValue / 100 : 1;
  3319. };
  3320. const getGraphZoomValue = (graph = graphRef.value) => {
  3321. if (!graph || typeof (graph as any).getZoom !== 'function') return getFallbackZoomValue();
  3322. try {
  3323. const zoom = (graph as any).getZoom();
  3324. return Number.isFinite(zoom) && zoom > 0 ? zoom : getFallbackZoomValue();
  3325. } catch {
  3326. return getFallbackZoomValue();
  3327. }
  3328. };
  3329. const refreshZoomLabel = () => {
  3330. zoomLabel.value = `${Math.round(getGraphZoomValue() * 100)}%`;
  3331. };
  3332. const getGraphLayoutPassCount = (data: GraphData) =>
  3333. isLargeGraphData(data) ? LARGE_GRAPH_LAYOUT_PASS_COUNT : DEFAULT_GRAPH_LAYOUT_PASS_COUNT;
  3334. const getLargeGraphFocusNodeIds = (data: GraphData) => {
  3335. const nodes = data.nodes || [];
  3336. const edges = data.edges || [];
  3337. const { neighborMap, degreeMap } = buildAnalysisGraphNeighborStats(nodes, edges);
  3338. const degreeRecord = getAnalysisGraphDegreeRecord(nodes, degreeMap);
  3339. const seedIds = resolveRelationLayoutSeedIds(nodes, degreeRecord);
  3340. const sortedNodeIds = sortRelationLayoutCandidates(nodes, degreeRecord).map((node) => String(node.id));
  3341. const seedNeighborIds = seedIds.flatMap((seedId) =>
  3342. Array.from(neighborMap.get(seedId) || []).sort((left, right) => {
  3343. const degreeDiff = (degreeMap.get(right) || 0) - (degreeMap.get(left) || 0);
  3344. return degreeDiff || left.localeCompare(right, 'zh-CN');
  3345. }),
  3346. );
  3347. return uniqueValues([...seedIds, ...seedNeighborIds, ...sortedNodeIds]).slice(0, LARGE_GRAPH_VIEW_FOCUS_LIMIT);
  3348. };
  3349. const focusLargeGraphInitialView = async (data: GraphData) => {
  3350. const graph = graphRef.value;
  3351. if (!graph) return false;
  3352. const anchorNodeId = getLargeGraphFocusNodeIds(data).find((nodeId) => {
  3353. try {
  3354. return Boolean(graph.getNodeData(nodeId));
  3355. } catch {
  3356. return false;
  3357. }
  3358. });
  3359. if (!anchorNodeId) return false;
  3360. await graph.focusElement(anchorNodeId, false);
  3361. if (getGraphZoomValue(graph) < LARGE_GRAPH_DEFAULT_FOCUS_ZOOM) {
  3362. await graph.zoomTo(LARGE_GRAPH_DEFAULT_FOCUS_ZOOM, false);
  3363. await graph.focusElement(anchorNodeId, false);
  3364. }
  3365. refreshZoomLabel();
  3366. return true;
  3367. };
  3368. const fitGraphViewForData = async (
  3369. data?: GraphData,
  3370. animation: boolean | { duration: number } = { duration: 260 },
  3371. options: { focusLargeGraph?: boolean } = {},
  3372. ) => {
  3373. const graph = graphRef.value;
  3374. if (!graph) return;
  3375. const normalizedData = normalizeGraphData(data || graph.getData());
  3376. if (!normalizedData.nodes.length && !normalizedData.edges.length) {
  3377. refreshZoomLabel();
  3378. return;
  3379. }
  3380. if (isLargeGraphData(normalizedData)) {
  3381. await graph.fitView({ when: 'always' }, false);
  3382. if (options.focusLargeGraph && (await focusLargeGraphInitialView(normalizedData))) {
  3383. return;
  3384. }
  3385. refreshZoomLabel();
  3386. return;
  3387. }
  3388. await graph.fitView({ when: 'always' }, animation);
  3389. refreshZoomLabel();
  3390. };
  3391. const refreshGraphStats = (data: GraphData) => {
  3392. const normalizedData = normalizeGraphData(data);
  3393. graphStats.nodes = normalizedData.nodes.length;
  3394. graphStats.edges = normalizedData.edges.length;
  3395. graphStats.combos = normalizedData.combos.length;
  3396. };
  3397. const refreshGraphSnapshot = (markDirty = false) => {
  3398. const graph = graphRef.value;
  3399. if (!graph) return;
  3400. if (graphInteractionMoving.value) {
  3401. if (markDirty) markActiveCanvasDirty(true);
  3402. refreshZoomLabel();
  3403. return;
  3404. }
  3405. const data = normalizeGraphData(graph.getData());
  3406. refreshGraphStats(data);
  3407. if (isLargeGraphData(data)) {
  3408. serializedGraphData.value = JSON.stringify(
  3409. {
  3410. performanceMode: true,
  3411. total: {
  3412. nodes: data.nodes.length,
  3413. edges: data.edges.length,
  3414. combos: data.combos.length,
  3415. },
  3416. preview: {
  3417. nodes: data.nodes.slice(0, SNAPSHOT_PREVIEW_NODE_LIMIT),
  3418. edges: data.edges.slice(0, SNAPSHOT_PREVIEW_EDGE_LIMIT),
  3419. },
  3420. },
  3421. null,
  3422. 2,
  3423. );
  3424. } else {
  3425. serializedGraphData.value = JSON.stringify(data, null, 2);
  3426. }
  3427. refreshZoomLabel();
  3428. if (markDirty) markActiveCanvasDirty(true);
  3429. };
  3430. const saveActiveCanvasState = () => {
  3431. const current = activeGraphCanvas.value;
  3432. const graph = graphRef.value;
  3433. if (!current || !graph) return;
  3434. current.snapshot = serializeGraph();
  3435. current.zoom = getGraphZoomValue(graph);
  3436. current.analysisTotalNodeCount = analysisTotalNodeCount.value;
  3437. current.analysisTotalEdgeCount = analysisTotalEdgeCount.value;
  3438. current.dirty = false;
  3439. };
  3440. const pushUndoSnapshot = () => {
  3441. const snapshot = serializeGraph();
  3442. undoStack.value = [...undoStack.value, snapshot].slice(-MAX_UNDO_STACK);
  3443. redoStack.value = [];
  3444. };
  3445. const restoreGraphSnapshot = async (snapshot: string, fitView = true) => {
  3446. const graph = graphRef.value;
  3447. if (!graph) return;
  3448. let data: Required<GraphData>;
  3449. try {
  3450. data = normalizeGraphDataEdgeCurves(normalizeGraphData(JSON.parse(snapshot || EMPTY_GRAPH_SNAPSHOT)));
  3451. } catch {
  3452. data = normalizeGraphData({ nodes: [], edges: [], combos: [] });
  3453. }
  3454. graphPerformanceMode.value = isLargeGraphData(data);
  3455. applyGraphInteractionOptions();
  3456. graph.setNode(createNodeOptions() as any);
  3457. graph.setEdge(createEdgeOptions() as any);
  3458. graph.setCombo(createComboOptions() as any);
  3459. clearGraphLayout(graph);
  3460. graph.setData(data);
  3461. await graph.render();
  3462. if (fitView && (data.nodes.length || data.edges.length)) {
  3463. await fitGraphViewForData(data, false, { focusLargeGraph: isLargeGraphData(data) });
  3464. }
  3465. clearLocalSelection();
  3466. refreshGraphSnapshot(false);
  3467. restoreAnalysisTotalCountsFromActiveCanvas();
  3468. };
  3469. const withGraphMutation = async (mutation: () => void | Promise<void>) => {
  3470. pushUndoSnapshot();
  3471. await mutation();
  3472. refreshGraphSnapshot();
  3473. saveActiveCanvasState();
  3474. };
  3475. const syncSelection = () => {
  3476. const graph = graphRef.value;
  3477. if (!graph) return;
  3478. selectedNodeIds.value = graph.getElementDataByState('node', 'selected').map((node) => String(node.id));
  3479. selectedEdgeIds.value = graph
  3480. .getElementDataByState('edge', 'selected')
  3481. .map((edge) => (edge.id ? String(edge.id) : ''))
  3482. .filter(Boolean);
  3483. selectedComboIds.value = graph.getElementDataByState('combo', 'selected').map((combo) => String(combo.id));
  3484. const activeNode = selectedNodeIds.value[0] ? findNodeData(selectedNodeIds.value[0]) : null;
  3485. const activeEdge = !activeNode && selectedEdgeIds.value[0] ? findEdgeData(selectedEdgeIds.value[0]) : null;
  3486. const activeCombo =
  3487. !activeNode && !activeEdge && selectedComboIds.value[0] ? findComboData(selectedComboIds.value[0]) : null;
  3488. if (activeNode) {
  3489. activeElement.value = {
  3490. id: String(activeNode.id),
  3491. label: getNodeLabel(activeNode),
  3492. type: 'node',
  3493. typeLabel: getEntityKind(activeNode.data?.kind).label,
  3494. };
  3495. } else if (activeEdge) {
  3496. activeElement.value = {
  3497. id: String(activeEdge.id),
  3498. label: getEdgeLabel(activeEdge),
  3499. type: 'edge',
  3500. typeLabel: '关系',
  3501. };
  3502. } else if (activeCombo) {
  3503. activeElement.value = {
  3504. id: String(activeCombo.id),
  3505. label: getComboLabel(activeCombo),
  3506. type: 'combo',
  3507. typeLabel: '分组',
  3508. };
  3509. } else {
  3510. activeElement.value = null;
  3511. }
  3512. refreshZoomLabel();
  3513. };
  3514. const clearSelection = async () => {
  3515. const graph = graphRef.value;
  3516. if (!graph) return;
  3517. const states: Record<string, string[]> = {};
  3518. graph.getData().nodes?.forEach((node) => {
  3519. states[String(node.id)] = [];
  3520. });
  3521. graph.getData().edges?.forEach((edge) => {
  3522. if (edge.id) states[String(edge.id)] = [];
  3523. });
  3524. graph.getData().combos?.forEach((combo) => {
  3525. states[String(combo.id)] = [];
  3526. });
  3527. await graph.setElementState(states, false);
  3528. syncSelection();
  3529. };
  3530. const clearLocalSelection = () => {
  3531. selectedNodeIds.value = [];
  3532. selectedEdgeIds.value = [];
  3533. selectedComboIds.value = [];
  3534. activeElement.value = null;
  3535. };
  3536. const setManyElementSelected = async (ids: string[]) => {
  3537. const graph = graphRef.value;
  3538. if (!graph) return;
  3539. const states = ids.reduce<Record<string, string[]>>((accumulator, id) => {
  3540. accumulator[id] = ['selected'];
  3541. return accumulator;
  3542. }, {});
  3543. await graph.setElementState(states, false);
  3544. };
  3545. const getEventElementId = (event: IElementEvent) => String(event.target?.id || '');
  3546. const getGraphCenterCanvasPoint = () => {
  3547. const graph = graphRef.value;
  3548. const container = graphContainerRef.value;
  3549. if (!graph || !container) return { x: 0, y: 0 };
  3550. const rect = container.getBoundingClientRect();
  3551. const [x, y] = graph.getCanvasByClient([rect.left + rect.width / 2, rect.top + rect.height / 2]);
  3552. return { x, y };
  3553. };
  3554. const getContextMenuCanvasPoint = (event: IElementEvent) => {
  3555. const graph = graphRef.value;
  3556. const eventData = event as any;
  3557. if (typeof eventData.canvas?.x === 'number' && typeof eventData.canvas?.y === 'number') {
  3558. return { x: eventData.canvas.x, y: eventData.canvas.y };
  3559. }
  3560. if (typeof eventData.canvasX === 'number' && typeof eventData.canvasY === 'number') {
  3561. return { x: eventData.canvasX, y: eventData.canvasY };
  3562. }
  3563. const clientX =
  3564. typeof eventData.client?.x === 'number'
  3565. ? eventData.client.x
  3566. : typeof eventData.clientX === 'number'
  3567. ? eventData.clientX
  3568. : undefined;
  3569. const clientY =
  3570. typeof eventData.client?.y === 'number'
  3571. ? eventData.client.y
  3572. : typeof eventData.clientY === 'number'
  3573. ? eventData.clientY
  3574. : undefined;
  3575. if (graph && clientX !== undefined && clientY !== undefined) {
  3576. const [x, y] = graph.getCanvasByClient([clientX, clientY]);
  3577. return { x, y };
  3578. }
  3579. return getGraphCenterCanvasPoint();
  3580. };
  3581. const captureContextMenuState = (event: IElementEvent) => {
  3582. const position = getContextMenuCanvasPoint(event);
  3583. contextMenuState.value = {
  3584. id: getEventElementId(event),
  3585. type: event.targetType,
  3586. canvasX: position.x,
  3587. canvasY: position.y,
  3588. };
  3589. return contextMenuState.value;
  3590. };
  3591. const resolveElementKind = (elementId: string, fallback?: IElementEvent['targetType']): ElementKind | null => {
  3592. if (fallback === 'node' || fallback === 'edge' || fallback === 'combo') return fallback;
  3593. if (findNodeData(elementId)) return 'node';
  3594. if (findEdgeData(elementId)) return 'edge';
  3595. if (findComboData(elementId)) return 'combo';
  3596. return null;
  3597. };
  3598. const getNodeFlowEdges = (nodeId: string, direction: 'incoming' | 'outgoing') => {
  3599. const graph = graphRef.value;
  3600. if (!graph || !nodeId) return [];
  3601. return graph
  3602. .getEdgeData()
  3603. .filter((edge) => (direction === 'incoming' ? String(edge.target) === nodeId : String(edge.source) === nodeId));
  3604. };
  3605. const canOpenAnalysisDetail = (elementId: string, kind: ElementKind | null) => {
  3606. if (!elementId || !kind) return false;
  3607. if (kind === 'node') return Boolean(findNodeData(elementId));
  3608. if (kind === 'combo') return Boolean(findComboData(elementId));
  3609. const edge = findEdgeData(elementId);
  3610. if (!edge) return false;
  3611. const rawData = (edge.data?.rawData || {}) as Record<string, any>;
  3612. const relationTexts = [edge.data?.label, rawData.mc, rawData.sm, rawData.clazz]
  3613. .map((item) => String(item || '').trim())
  3614. .filter(Boolean);
  3615. return !relationTexts.some((text) => text.includes('持卡人') || text.includes('机主'));
  3616. };
  3617. const canShowAssociativeObject = (nodeId: string) => {
  3618. const graph = graphRef.value;
  3619. const node = findNodeData(nodeId);
  3620. if (!graph || !node || String(node.combo || '')) return false;
  3621. const rawData = (node.data?.rawData || {}) as Record<string, any>;
  3622. const rawClass = String(rawData.class || rawData.clazz || '').trim();
  3623. const kind = getEntityKind(node.data?.kind).key;
  3624. if (!node.data?.isAnalysis || !['phone', 'bank'].includes(kind)) return false;
  3625. const relatedEdges = graph
  3626. .getEdgeData()
  3627. .filter((edge) => String(edge.source) === nodeId || String(edge.target) === nodeId);
  3628. if (!relatedEdges.length) return false;
  3629. const expectedClazz = kind === 'phone' || ['tel_gt', 'tel'].includes(rawClass) ? 'tel_tel' : 'card_card';
  3630. return relatedEdges.every((edge) => {
  3631. const rawEdge = (edge.data?.rawData || {}) as Record<string, any>;
  3632. const edgeClazz = String(rawEdge.clazz || '').trim();
  3633. return !edgeClazz || edgeClazz === expectedClazz;
  3634. });
  3635. };
  3636. const isAnalysisNode = (node?: NodeData | null) => Boolean(node?.data?.isAnalysis);
  3637. const isAnalysisEdge = (edge?: EdgeData | null) => Boolean(edge?.data?.isAnalysis);
  3638. const isObjAnalysisNode = (node?: NodeData | null) => {
  3639. if (!isAnalysisNode(node)) return false;
  3640. const rawNode = (node?.data?.rawData || {}) as GraphAnalysisNode;
  3641. const rawClass = String(rawNode.class || rawNode.clazz || '')
  3642. .trim()
  3643. .toLowerCase();
  3644. return rawClass === 'obj';
  3645. };
  3646. const isPhoneAnalysisNode = (node?: NodeData | null) => {
  3647. if (!isAnalysisNode(node)) return false;
  3648. const rawNode = (node?.data?.rawData || {}) as GraphAnalysisNode;
  3649. const rawClass = String(rawNode.class || rawNode.clazz || '')
  3650. .trim()
  3651. .toLowerCase();
  3652. return rawClass === 'tel' || getEntityKind(node?.data?.kind).key === 'phone';
  3653. };
  3654. const shouldFetchNodeDetail = (node?: NodeData | null) => isAnalysisNode(node) && !isObjAnalysisNode(node);
  3655. const buildGraphNodeDetailQuery = (node: NodeData): GraphNodeDetailQuery | null => {
  3656. const rawNode = (node.data?.rawData || {}) as GraphAnalysisNode;
  3657. const kind = getEntityKind(node.data?.kind).key;
  3658. const clazz = normalizeGraphDetailText(rawNode?.clazz ?? rawNode?.class) || getAnalysisClassByKind(kind);
  3659. const nodeToken =
  3660. normalizeGraphDetailText(rawNode?.node) ||
  3661. normalizeGraphDetailText(rawNode?.bm) ||
  3662. normalizeGraphDetailText(rawNode?.id) ||
  3663. normalizeGraphDetailText(rawNode?.dxId);
  3664. if (!nodeToken || !clazz) return null;
  3665. const lxValue = rawNode?.lx;
  3666. const fxValue = rawNode?.fx;
  3667. const lx =
  3668. typeof lxValue === 'number' && Number.isFinite(lxValue)
  3669. ? lxValue
  3670. : Number.isFinite(Number(lxValue))
  3671. ? Number(lxValue)
  3672. : 0;
  3673. const fx =
  3674. typeof fxValue === 'number' && Number.isFinite(fxValue)
  3675. ? fxValue
  3676. : Number.isFinite(Number(fxValue))
  3677. ? Number(fxValue)
  3678. : 0;
  3679. return {
  3680. node: nodeToken,
  3681. clazz,
  3682. lx,
  3683. fx,
  3684. };
  3685. };
  3686. const getEdgeEndpointNodeData = (edge: EdgeData, endpoint: 'source' | 'target') =>
  3687. findNodeData(String(endpoint === 'source' ? edge.source : edge.target));
  3688. const getEdgeEndpointName = (edge: EdgeData, endpoint: 'source' | 'target') => {
  3689. const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
  3690. const endpointNode = getEdgeEndpointNodeData(edge, endpoint);
  3691. if (endpoint === 'source') {
  3692. return String(
  3693. edge.data?.sourceName || getNodeLabel(endpointNode) || rawEdge.obj1_name || rawEdge.dx1 || '未知源节点',
  3694. );
  3695. }
  3696. return String(
  3697. edge.data?.targetName || getNodeLabel(endpointNode) || rawEdge.obj2_name || rawEdge.dx2 || '未知目标节点',
  3698. );
  3699. };
  3700. const isCommunicationAnalysisEdge = (edge?: EdgeData | null) => {
  3701. if (!isAnalysisEdge(edge)) return false;
  3702. const rawEdge = (edge?.data?.rawData || {}) as GraphAnalysisEdge;
  3703. return normalizeAnalysisEdgeClazz(rawEdge.clazz).includes('tel');
  3704. };
  3705. const buildGraphTransDetailQueryByEdge = (edge: EdgeData) => {
  3706. const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
  3707. const personCardNo = normalizeGraphDetailText(rawEdge?.dx1);
  3708. const targetToken = normalizeGraphDetailText(rawEdge?.dx2);
  3709. return buildGraphTransDetailQuery(routeGraphQuery.value, {
  3710. personCardNo,
  3711. otherCardNo: targetToken,
  3712. lx:
  3713. typeof rawEdge?.lx === 'number'
  3714. ? rawEdge.lx
  3715. : normalizeGraphDetailText(rawEdge?.lx)
  3716. ? Number(rawEdge.lx)
  3717. : undefined,
  3718. }) as GraphTransDetailBaseQuery;
  3719. };
  3720. const fetchGraphTransDetailRecords = (query: GraphTransDetailBaseQuery) =>
  3721. fetchGraphTransDetail(query as GraphTransDetailQuery);
  3722. const normalizeGraphCallDetailQuery = (query: GraphCallDetailBaseQuery): GraphCallDetailQuery => {
  3723. const normalizedQuery = {
  3724. ...(query as GraphCallDetailQuery),
  3725. };
  3726. const startDate = normalizeGraphDetailText(query.startDate ?? query.date1);
  3727. const endDate = normalizeGraphDetailText(query.endDate ?? query.date2);
  3728. normalizedQuery.startDate = startDate || undefined;
  3729. normalizedQuery.endDate = endDate || undefined;
  3730. normalizedQuery.date1 = startDate || normalizedQuery.date1;
  3731. normalizedQuery.date2 = endDate || normalizedQuery.date2;
  3732. return normalizedQuery;
  3733. };
  3734. const fetchGraphCallDetailRecords = (query: GraphCallDetailBaseQuery) =>
  3735. fetchGraphCallDetail(normalizeGraphCallDetailQuery(query));
  3736. const openCommunicationCallDetail = (target: NodeData | EdgeData, kind: ElementKind) => {
  3737. if (kind === 'node') {
  3738. const node = target as NodeData;
  3739. if (!isPhoneAnalysisNode(node)) return false;
  3740. const rawNode = (node.data?.rawData || {}) as GraphAnalysisNode;
  3741. const phoneNo = normalizeGraphDetailText(rawNode?.bm);
  3742. if (!rawNode || !phoneNo) {
  3743. message.warning('当前通信节点缺少可查询的号码');
  3744. return false;
  3745. }
  3746. transDetailVisible.value = false;
  3747. analysisDetailVisible.value = false;
  3748. callDetailTitle.value = `${String(node.data?.fullLabel || node.data?.label || phoneNo)} 通信详情`;
  3749. callDetailBaseQuery.value = buildGraphCallDetailQuery(
  3750. routeGraphQuery.value,
  3751. {},
  3752. rawNode,
  3753. ) as GraphCallDetailBaseQuery;
  3754. callDetailVisible.value = true;
  3755. return true;
  3756. }
  3757. const edge = target as EdgeData;
  3758. if (!isCommunicationAnalysisEdge(edge)) return false;
  3759. const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
  3760. const sourceName = getEdgeEndpointName(edge, 'source');
  3761. const targetName = getEdgeEndpointName(edge, 'target');
  3762. transDetailVisible.value = false;
  3763. analysisDetailVisible.value = false;
  3764. callDetailTitle.value = `${sourceName} -> ${targetName} 通信详情`;
  3765. callDetailBaseQuery.value = buildGraphCallDetailQuery(
  3766. routeGraphQuery.value,
  3767. {},
  3768. null,
  3769. rawEdge,
  3770. ) as GraphCallDetailBaseQuery;
  3771. callDetailVisible.value = true;
  3772. return true;
  3773. };
  3774. const openTradeEdgeTransDetail = (edge: EdgeData) => {
  3775. if (!isAnalysisEdge(edge)) return false;
  3776. const rawEdge = (edge.data?.rawData || {}) as GraphAnalysisEdge;
  3777. if (!isTradeAnalysisEdgeClazz(rawEdge.clazz)) return false;
  3778. const personCardNo = normalizeGraphDetailText(rawEdge.dx1);
  3779. const otherCardNo = normalizeGraphDetailText(rawEdge.dx2);
  3780. if (!personCardNo || !otherCardNo) return false;
  3781. const sourceName = getEdgeEndpointName(edge, 'source');
  3782. const targetName = getEdgeEndpointName(edge, 'target');
  3783. callDetailVisible.value = false;
  3784. analysisDetailVisible.value = false;
  3785. transDetailTitle.value = `${sourceName} -> ${targetName} 交易详情`;
  3786. transDetailBaseQuery.value = buildGraphTransDetailQueryByEdge(edge);
  3787. transDetailVisible.value = true;
  3788. return true;
  3789. };
  3790. const getSelectedNodeIdsForContext = (nodeId?: string) => {
  3791. if (!nodeId) return [...selectedNodeIds.value];
  3792. if (selectedNodeIds.value.includes(nodeId)) return [...selectedNodeIds.value];
  3793. return [nodeId];
  3794. };
  3795. const hasUngroupTarget = (elementId: string, kind: ElementKind | null) => {
  3796. if (kind === 'combo' && elementId) return true;
  3797. if (kind === 'node') {
  3798. const node = findNodeData(elementId);
  3799. return Boolean(node?.combo);
  3800. }
  3801. return Boolean(selectedComboIds.value.length);
  3802. };
  3803. const canCollapseExpandedAnalysisNode = (nodeId: string) => {
  3804. const node = findNodeData(nodeId);
  3805. return Boolean(node && (getAnalysisExpandedNeighborCount(node) > 0 || getAnalysisExpandedNodeIds(node).length));
  3806. };
  3807. const getContextMenuItems = (event: IElementEvent): ContextMenuItem[] => {
  3808. const state = captureContextMenuState(event);
  3809. const elementId = state.id;
  3810. const kind = resolveElementKind(elementId, state.type);
  3811. const hasClipboard = Boolean(graphClipboard.value?.nodes.length);
  3812. if (kind === 'edge') {
  3813. return [
  3814. ...(canOpenAnalysisDetail(elementId, kind) ? [{ name: '查看详情', value: 'analysisDetail' as const }] : []),
  3815. ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
  3816. { name: '删除关系', value: 'delete' },
  3817. ];
  3818. }
  3819. if (kind === 'combo') {
  3820. return [
  3821. ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
  3822. { name: '拆分实体', value: 'ungroup' },
  3823. { name: '删除组合', value: 'delete' },
  3824. ];
  3825. }
  3826. if (kind === 'node') {
  3827. const contextNodeIds = getSelectedNodeIdsForContext(elementId);
  3828. const incomingEdges = getNodeFlowEdges(elementId, 'incoming');
  3829. const outgoingEdges = getNodeFlowEdges(elementId, 'outgoing');
  3830. return [
  3831. ...(canOpenAnalysisDetail(elementId, kind) ? [{ name: '查看详情', value: 'analysisDetail' as const }] : []),
  3832. { name: '开始连线', value: 'startEdge' },
  3833. ...(incomingEdges.length ? [{ name: '仅看流入', value: 'onlyLookFlowIn' as const }] : []),
  3834. ...(outgoingEdges.length ? [{ name: '仅看流出', value: 'onlyLookFlowOut' as const }] : []),
  3835. ...(canShowAssociativeObject(elementId) ? [{ name: '关联对象', value: 'associativeObject' as const }] : []),
  3836. {
  3837. name: contextNodeIds.length > 1 ? `复制实体(${contextNodeIds.length})` : '复制实体',
  3838. value: 'copy',
  3839. },
  3840. ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
  3841. {
  3842. name: contextNodeIds.length > 1 ? `删除实体(${contextNodeIds.length})` : '删除实体',
  3843. value: 'delete',
  3844. },
  3845. ...(contextNodeIds.length > 1 ? [{ name: '组合实体', value: 'group' as const }] : []),
  3846. ...(hasUngroupTarget(elementId, kind) ? [{ name: '拆分实体', value: 'ungroup' as const }] : []),
  3847. { name: '复制到新画布', value: 'newCanvas' },
  3848. ];
  3849. }
  3850. return [
  3851. ...(hasClipboard ? [{ name: '粘贴实体', value: 'paste' as const }] : []),
  3852. { name: '新增人员', value: 'createPerson' },
  3853. { name: '适配视图', value: 'fitView' },
  3854. ];
  3855. };
  3856. const createContextMenuPlugin = () => ({
  3857. type: 'contextmenu',
  3858. key: 'g6-editor-contextmenu',
  3859. className: 'g6-editor-contextmenu',
  3860. getItems: (event: IElementEvent) => getContextMenuItems(event),
  3861. onClick: (value: string, _target: HTMLElement, current: any) => {
  3862. void handleContextMenuClick(value as ContextMenuActionKey, current);
  3863. },
  3864. });
  3865. const createGraphPlugins = () => [
  3866. {
  3867. type: 'grid-line',
  3868. size: 24,
  3869. stroke: '#e2e8f0',
  3870. lineWidth: 1,
  3871. },
  3872. createContextMenuPlugin(),
  3873. ];
  3874. const createPerformanceGraphPlugins = () => [createContextMenuPlugin()];
  3875. const scheduleSyncSelection = () => {
  3876. window.setTimeout(syncSelection, 0);
  3877. };
  3878. const isBrushSelectInteraction = (event: any) => Boolean(event?.shiftKey || event?.nativeEvent?.shiftKey);
  3879. const isElementDragInteraction = (event: any) =>
  3880. ['node', 'combo'].includes(String(event?.targetType || '')) && !isBrushSelectInteraction(event);
  3881. const createGraphBehaviors = (performanceMode = false) =>
  3882. [
  3883. {
  3884. type: 'drag-canvas',
  3885. enable: (event: any) => event?.targetType === 'canvas' && !isBrushSelectInteraction(event),
  3886. },
  3887. 'zoom-canvas',
  3888. performanceMode
  3889. ? {
  3890. type: 'drag-element',
  3891. dropEffect: 'move',
  3892. hideEdge: 'both',
  3893. animation: false,
  3894. shadow: true,
  3895. enable: isElementDragInteraction,
  3896. }
  3897. : { type: 'drag-element', dropEffect: 'move', state: 'selected', enable: isElementDragInteraction },
  3898. {
  3899. type: 'click-select',
  3900. multiple: true,
  3901. trigger: ['shift'],
  3902. state: 'selected',
  3903. animation: false,
  3904. onClick: scheduleSyncSelection,
  3905. },
  3906. {
  3907. type: 'brush-select',
  3908. trigger: ['shift'],
  3909. state: 'selected',
  3910. animation: false,
  3911. enableElements: performanceMode ? ['node', 'edge'] : ['node', 'edge', 'combo'],
  3912. onSelect: scheduleSyncSelection,
  3913. },
  3914. {
  3915. type: 'optimize-viewport-transform',
  3916. key: 'g6-editor-transform-performance',
  3917. debounce: performanceMode ? 80 : 120,
  3918. shapes: performanceMode ? { node: ['key'] } : (type: string) => type === 'node',
  3919. },
  3920. ] as any;
  3921. const applyGraphInteractionOptions = (performanceMode = graphPerformanceMode.value) => {
  3922. const graph = graphRef.value;
  3923. if (!graph) return;
  3924. graph.setPlugins(performanceMode ? createPerformanceGraphPlugins() : createGraphPlugins());
  3925. graph.setBehaviors(createGraphBehaviors(performanceMode));
  3926. };
  3927. const createNodeOptions = (): NodeOptions => ({
  3928. type: graphPerformanceMode.value ? 'performance-node' : 'analysis-node',
  3929. style: (node: NodeData) => {
  3930. const entity = getEntityKind(node.data?.kind);
  3931. const performanceMode = graphPerformanceMode.value;
  3932. const hidden = Boolean(node.data?.filterHidden);
  3933. const dimmed = Boolean(node.data?.dimmed);
  3934. const markerColor = String(node.data?.markerColor || '');
  3935. if (performanceMode) {
  3936. const labelText = getPerformanceNodeLabel(node);
  3937. const secondaryLabelText = labelText ? getPerformanceNodeSecondaryLabel(node) : '';
  3938. return {
  3939. size: 22,
  3940. fill: markerColor || entity.color,
  3941. fillOpacity: hidden ? 0 : dimmed ? 0.18 : 0.95,
  3942. stroke: 'transparent',
  3943. strokeOpacity: hidden ? 0 : 1,
  3944. lineWidth: 0,
  3945. cursor: 'pointer',
  3946. iconSrc: entity.paletteIcon,
  3947. iconOpacity: hidden ? 0 : dimmed ? 0.18 : 0.96,
  3948. labelText,
  3949. labelFill: '#334155',
  3950. labelFontSize: 12,
  3951. labelOpacity: hidden ? 0 : dimmed ? 0.18 : 0.82,
  3952. secondaryLabelText,
  3953. secondaryLabelFill: '#94a3b8',
  3954. secondaryLabelFontSize: 9,
  3955. secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.14 : 0.7,
  3956. };
  3957. }
  3958. return {
  3959. size: 96,
  3960. iconBoxSize: 40,
  3961. iconBoxRadius: 11,
  3962. iconSize: 32,
  3963. fill: markerColor || entity.color,
  3964. fillOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
  3965. stroke: 'rgba(255, 255, 255, 0.22)',
  3966. strokeOpacity: hidden ? 0 : 1,
  3967. lineWidth: 1,
  3968. opacity: hidden ? 0 : 1,
  3969. cursor: 'pointer',
  3970. iconSrc: entity.paletteIcon,
  3971. iconOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
  3972. labelText: getNodeLabel(node),
  3973. labelFill: '#334155',
  3974. labelFontSize: 14,
  3975. labelFontWeight: 600,
  3976. labelOpacity: hidden ? 0 : dimmed ? 0.24 : 1,
  3977. secondaryLabelText: getNodeSecondaryLabel(node),
  3978. secondaryLabelFill: '#94a3b8',
  3979. secondaryLabelFontSize: 12,
  3980. secondaryLabelOpacity: hidden ? 0 : dimmed ? 0.2 : 1,
  3981. };
  3982. },
  3983. state: {
  3984. selected: {
  3985. halo: true,
  3986. haloStroke: '#1677ff',
  3987. haloLineWidth: 2,
  3988. stroke: '#1677ff',
  3989. strokeOpacity: 1,
  3990. lineWidth: 3,
  3991. fillOpacity: 1,
  3992. },
  3993. },
  3994. });
  3995. const createEdgeOptions = (): EdgeOptions => ({
  3996. type: (edge: EdgeData) => (graphPerformanceMode.value ? 'performance-edge' : String(edge.type || 'line')),
  3997. style: (edge: EdgeData) => {
  3998. const performanceMode = graphPerformanceMode.value;
  3999. const hidden = Boolean(edge.data?.filterHidden);
  4000. const dimmed = Boolean(edge.data?.dimmed);
  4001. const edgeStyle = getGraphEdgeStyle(edge);
  4002. if (performanceMode) {
  4003. const labelText = getPerformanceEdgeLabel(edge);
  4004. return {
  4005. stroke: '#d9d9df',
  4006. lineWidth: 0.7,
  4007. opacity: hidden ? 0 : dimmed ? 0.12 : 0.78,
  4008. cursor: 'pointer',
  4009. labelText,
  4010. labelFill: '#516076',
  4011. labelFontSize: 9,
  4012. labelOpacity: hidden ? 0 : dimmed ? 0.12 : 0.62,
  4013. };
  4014. }
  4015. return {
  4016. stroke: '#d9d9df',
  4017. lineWidth: 2,
  4018. opacity: hidden ? 0 : dimmed ? 0.18 : 1,
  4019. cursor: 'pointer',
  4020. startArrow: edgeStyle.startArrow === true,
  4021. startArrowOffset: EDGE_TARGET_ARROW_GAP,
  4022. startArrowFill: '#d9d9df',
  4023. endArrow: edgeStyle.endArrow !== false,
  4024. endArrowOffset: EDGE_TARGET_ARROW_GAP,
  4025. endArrowFill: '#d9d9df',
  4026. label: true,
  4027. labelText: getEdgeLabel(edge),
  4028. labelPlacement: 'center',
  4029. labelFill: '#516076',
  4030. labelFontSize: 10,
  4031. labelOpacity: hidden ? 0 : dimmed ? 0.18 : 1,
  4032. labelBackground: true,
  4033. labelBackgroundFill: 'rgba(255, 255, 255, 0.92)',
  4034. labelBackgroundRadius: 4,
  4035. };
  4036. },
  4037. state: {
  4038. selected: {
  4039. stroke: '#2563eb',
  4040. lineWidth: 3,
  4041. startArrowFill: '#2563eb',
  4042. endArrowFill: '#2563eb',
  4043. },
  4044. },
  4045. });
  4046. const createComboOptions = () => ({
  4047. type: 'rect',
  4048. style: (combo: ComboData) => ({
  4049. padding: [30, 34, 40, 34],
  4050. radius: 8,
  4051. fill: 'rgba(37, 99, 235, 0.06)',
  4052. stroke: '#93c5fd',
  4053. lineWidth: 1.5,
  4054. lineDash: [6, 4],
  4055. labelText: getComboLabel(combo),
  4056. labelPlacement: 'top',
  4057. labelFill: '#2563eb',
  4058. labelFontSize: 13,
  4059. }),
  4060. state: {
  4061. selected: {
  4062. stroke: '#1d4ed8',
  4063. lineWidth: 2.2,
  4064. },
  4065. },
  4066. });
  4067. const createGraph = async () => {
  4068. if (!graphContainerRef.value) return;
  4069. registerStressElements();
  4070. const { clientWidth, clientHeight } = graphContainerRef.value;
  4071. const graph = new Graph({
  4072. container: graphContainerRef.value,
  4073. width: clientWidth,
  4074. height: clientHeight,
  4075. autoResize: true,
  4076. animation: false,
  4077. data: createInitialData(),
  4078. node: createNodeOptions(),
  4079. edge: createEdgeOptions(),
  4080. combo: createComboOptions(),
  4081. behaviors: createGraphBehaviors(false),
  4082. plugins: createGraphPlugins(),
  4083. } as any);
  4084. graphRef.value = graph;
  4085. bindGraphEvents(graph);
  4086. await graph.render();
  4087. await graph.fitView({ when: 'always' }, false);
  4088. refreshGraphSnapshot();
  4089. };
  4090. const bindGraphEvents = (graph: Graph) => {
  4091. const handleDragStart = () => {
  4092. graphInteractionMoving.value = true;
  4093. };
  4094. const handleDragEnd = () => {
  4095. if (!graphInteractionMoving.value) return;
  4096. graphInteractionMoving.value = false;
  4097. syncRenderedNodePositionsToGraphData(graph);
  4098. markActiveCanvasDirty(true);
  4099. refreshZoomLabel();
  4100. };
  4101. graph.on<IElementEvent>(NodeEvent.CLICK, (event) => {
  4102. const nodeId = getEventElementId(event);
  4103. if (!nodeId) return;
  4104. if (pendingEdgeSourceId.value) {
  4105. void completeEdgeCreate(nodeId);
  4106. return;
  4107. }
  4108. window.setTimeout(syncSelection, 0);
  4109. });
  4110. graph.on<IElementEvent>(EdgeEvent.CLICK, () => {
  4111. window.setTimeout(syncSelection, 0);
  4112. });
  4113. graph.on<IElementEvent>(ComboEvent.CLICK, () => {
  4114. window.setTimeout(syncSelection, 0);
  4115. });
  4116. graph.on<IElementEvent>(NodeEvent.CONTEXT_MENU, (event) => {
  4117. const nodeId = getEventElementId(event);
  4118. if (!nodeId) return;
  4119. if (!selectedNodeIds.value.includes(nodeId)) {
  4120. void (async () => {
  4121. await clearSelection();
  4122. await graph.setElementState(nodeId, ['selected'], false);
  4123. syncSelection();
  4124. })();
  4125. }
  4126. });
  4127. graph.on(CanvasEvent.CLICK, () => {
  4128. pendingEdgeSourceId.value = '';
  4129. window.setTimeout(syncSelection, 0);
  4130. });
  4131. graph.on(NodeEvent.DRAG_START, handleDragStart);
  4132. graph.on(NodeEvent.DRAG_END, handleDragEnd);
  4133. graph.on(ComboEvent.DRAG_START, handleDragStart);
  4134. graph.on(ComboEvent.DRAG_END, handleDragEnd);
  4135. graph.on(GraphEvent.AFTER_TRANSFORM, refreshZoomLabel);
  4136. };
  4137. const addNodeAt = async (kind: EntityKindKey, x: number, y: number) => {
  4138. const graph = graphRef.value;
  4139. if (!graph) return;
  4140. const entity = getEntityKind(kind);
  4141. const timestamp = Date.now();
  4142. const id = `${kind}-${timestamp}`;
  4143. await withGraphMutation(async () => {
  4144. clearAnalysisTotalCounts();
  4145. graph.addNodeData([
  4146. {
  4147. id,
  4148. data: {
  4149. kind,
  4150. label: `${entity.label}${graphStats.nodes + 1}`,
  4151. },
  4152. style: { x, y },
  4153. },
  4154. ]);
  4155. await graph.draw();
  4156. });
  4157. await clearSelection();
  4158. await graph.setElementState(id, ['selected'], false);
  4159. syncSelection();
  4160. };
  4161. const handleQuickAddEntity = (kind: EntityKindKey) => {
  4162. const graph = graphRef.value;
  4163. if (!graph || !graphContainerRef.value) return;
  4164. const rect = graphContainerRef.value.getBoundingClientRect();
  4165. const [x, y] = graph.getCanvasByClient([
  4166. rect.left + rect.width / 2 + Math.random() * 120 - 60,
  4167. rect.top + rect.height / 2 + Math.random() * 120 - 60,
  4168. ]);
  4169. void addNodeAt(kind, x, y);
  4170. };
  4171. const handlePaletteDragStart = (kind: EntityKindKey, event: DragEvent) => {
  4172. event.dataTransfer?.setData('application/x-g6-entity-kind', kind);
  4173. event.dataTransfer?.setData('text/plain', kind);
  4174. if (event.dataTransfer) {
  4175. event.dataTransfer.effectAllowed = 'copy';
  4176. }
  4177. };
  4178. const handleCanvasDrop = (event: DragEvent) => {
  4179. const graph = graphRef.value;
  4180. if (!graph) return;
  4181. const kind = (event.dataTransfer?.getData('application/x-g6-entity-kind') ||
  4182. event.dataTransfer?.getData('text/plain')) as EntityKindKey;
  4183. if (!entityKinds.some((item) => item.key === kind)) return;
  4184. const [x, y] = graph.getCanvasByClient([event.clientX, event.clientY]);
  4185. void addNodeAt(kind, x, y);
  4186. };
  4187. const completeEdgeCreate = async (targetNodeId: string) => {
  4188. const graph = graphRef.value;
  4189. const sourceNodeId = pendingEdgeSourceId.value;
  4190. if (!graph || !sourceNodeId) return;
  4191. if (sourceNodeId === targetNodeId) {
  4192. message.warning('不能连接到自身');
  4193. return;
  4194. }
  4195. await withGraphMutation(async () => {
  4196. clearAnalysisTotalCounts();
  4197. graph.addEdgeData([
  4198. {
  4199. id: `edge-${Date.now()}`,
  4200. source: sourceNodeId,
  4201. target: targetNodeId,
  4202. data: { label: '关联' },
  4203. },
  4204. ]);
  4205. await refreshCurrentGraphEdgeCurves();
  4206. });
  4207. pendingEdgeSourceId.value = '';
  4208. syncSelection();
  4209. };
  4210. const cancelEdgeCreate = () => {
  4211. pendingEdgeSourceId.value = '';
  4212. };
  4213. const handleCreateCombo = async () => {
  4214. const graph = graphRef.value;
  4215. if (!graph) return;
  4216. const selectedNodes = selectedNodeIds.value.length
  4217. ? selectedNodeIds.value
  4218. : graph.getElementDataByState('node', 'selected').map((node) => String(node.id));
  4219. if (selectedNodes.length < 2) {
  4220. message.warning('至少选择两个节点再创建分组');
  4221. return;
  4222. }
  4223. const comboId = `combo-${Date.now()}`;
  4224. await withGraphMutation(async () => {
  4225. graph.addComboData([
  4226. {
  4227. id: comboId,
  4228. data: { label: `分组 ${graphStats.combos + 1}` },
  4229. },
  4230. ]);
  4231. graph.updateNodeData(selectedNodes.map((id) => ({ id, combo: comboId })));
  4232. await graph.draw();
  4233. });
  4234. await clearSelection();
  4235. await graph.setElementState(comboId, ['selected'], false);
  4236. syncSelection();
  4237. };
  4238. const ungroupCombo = async (comboId: string) => {
  4239. const graph = graphRef.value;
  4240. if (!graph) return;
  4241. const comboNodeIds = graph
  4242. .getNodeData()
  4243. .filter((node) => String(node.combo || '') === comboId)
  4244. .map((node) => String(node.id));
  4245. await withGraphMutation(async () => {
  4246. if (comboNodeIds.length) {
  4247. graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
  4248. }
  4249. graph.removeComboData([comboId]);
  4250. await graph.draw();
  4251. });
  4252. syncSelection();
  4253. };
  4254. const deleteNodes = (nodeIds: string[]) => {
  4255. const graph = graphRef.value;
  4256. if (!graph || !nodeIds.length) return;
  4257. const nodeIdSet = new Set(nodeIds);
  4258. const relatedEdgeIds = graph
  4259. .getEdgeData()
  4260. .filter((edge) => nodeIdSet.has(String(edge.source)) || nodeIdSet.has(String(edge.target)))
  4261. .map((edge) => (edge.id ? String(edge.id) : ''))
  4262. .filter(Boolean);
  4263. if (relatedEdgeIds.length) graph.removeEdgeData(relatedEdgeIds);
  4264. graph.removeNodeData(nodeIds);
  4265. };
  4266. const handleDeleteSelected = async () => {
  4267. const graph = graphRef.value;
  4268. if (!graph) return;
  4269. const edgeIds = [...selectedEdgeIds.value];
  4270. const nodeIds = [...selectedNodeIds.value];
  4271. const comboIds = [...selectedComboIds.value];
  4272. if (!edgeIds.length && !nodeIds.length && !comboIds.length) {
  4273. message.warning('请先选择要删除的元素');
  4274. return;
  4275. }
  4276. await withGraphMutation(async () => {
  4277. if (edgeIds.length || nodeIds.length) {
  4278. clearAnalysisTotalCounts();
  4279. }
  4280. if (edgeIds.length) graph.removeEdgeData(edgeIds);
  4281. if (nodeIds.length) deleteNodes(nodeIds);
  4282. comboIds.forEach((comboId) => {
  4283. const comboNodeIds = graph
  4284. .getNodeData()
  4285. .filter((node) => String(node.combo || '') === comboId)
  4286. .map((node) => String(node.id));
  4287. if (comboNodeIds.length) graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
  4288. graph.removeComboData([comboId]);
  4289. });
  4290. if (edgeIds.length || nodeIds.length) {
  4291. await refreshCurrentGraphEdgeCurves();
  4292. } else {
  4293. await graph.draw();
  4294. }
  4295. });
  4296. selectedNodeIds.value = [];
  4297. selectedEdgeIds.value = [];
  4298. selectedComboIds.value = [];
  4299. activeElement.value = null;
  4300. };
  4301. const loadAllRespondentOptions = async () => {
  4302. if (loadedRespondentOptions.value.length) return;
  4303. try {
  4304. const list = await getPersons();
  4305. const persons = Array.isArray(list) ? list : [];
  4306. loadedRespondentOptions.value = persons
  4307. .filter((item) => item.personName || item.id)
  4308. .map((item) => ({
  4309. id: String(item.objId ?? item.id ?? item.personName ?? ''),
  4310. name: item.personName || String(item.id || ''),
  4311. objId: String(item.objId ?? item.id ?? ''),
  4312. identityCode: item.personCertNo || undefined,
  4313. photograph: item.photograph || '',
  4314. }));
  4315. } catch {
  4316. loadedRespondentOptions.value = [];
  4317. }
  4318. };
  4319. const handleOpenAnalysisCondition = () => {
  4320. analysisConditionVisible.value = true;
  4321. void loadAllRespondentOptions();
  4322. };
  4323. const resolveAnalysisPayload = (payload: AnalysisConditionPayload): AnalysisConditionPayload => {
  4324. const routeRespondents = routeGraphRespondents.value;
  4325. if (!routeRespondents.length) return payload;
  4326. const routeRespondentMap = new Map(routeRespondents.map((item) => [item.id, item]));
  4327. return {
  4328. ...payload,
  4329. respondents: payload.respondents.map((item) => ({
  4330. ...(routeRespondentMap.get(item.id) || {}),
  4331. ...item,
  4332. photograph: item.photograph || routeRespondentMap.get(item.id)?.photograph || '',
  4333. })),
  4334. };
  4335. };
  4336. const buildRouteAnalysisPayload = (): AnalysisConditionPayload | null => {
  4337. if (!routeGraphRespondents.value.length) return null;
  4338. const startDate =
  4339. typeof routeGraphQuery.value.date1 === 'string'
  4340. ? routeGraphQuery.value.date1
  4341. : typeof routeGraphQuery.value.startDate === 'string'
  4342. ? routeGraphQuery.value.startDate
  4343. : '';
  4344. const endDate =
  4345. typeof routeGraphQuery.value.date2 === 'string'
  4346. ? routeGraphQuery.value.date2
  4347. : typeof routeGraphQuery.value.endDate === 'string'
  4348. ? routeGraphQuery.value.endDate
  4349. : '';
  4350. return {
  4351. respondents: JSON.parse(JSON.stringify(routeGraphRespondents.value)) as AnalysisRespondent[],
  4352. queryTime: startDate && endDate ? [startDate, endDate] : undefined,
  4353. tradeMode: 'trade',
  4354. communicationMode: 'contact',
  4355. };
  4356. };
  4357. const clearGraphLayout = (graph = graphRef.value) => {
  4358. if (!graph) return;
  4359. graph.stopLayout?.();
  4360. graph.setLayout(undefined as any);
  4361. };
  4362. const applySmartLayoutToGraphData = async (graphData: GraphData) => {
  4363. const graph = graphRef.value;
  4364. if (!graph) return false;
  4365. const normalizedGraphData = normalizeGraphDataEdgeCurves(graphData);
  4366. const largeGraph = isLargeGraphData(normalizedGraphData);
  4367. graphPerformanceMode.value = largeGraph;
  4368. applyGraphInteractionOptions();
  4369. graph.setNode(createNodeOptions() as any);
  4370. graph.setEdge(createEdgeOptions() as any);
  4371. graph.setCombo(createComboOptions() as any);
  4372. try {
  4373. // Large graphs are rendered more reliably when we precompute positions
  4374. // outside G6's native force-layout pipeline and then render them statically.
  4375. if (largeGraph) {
  4376. clearGraphLayout(graph);
  4377. await applyAnalysisRelationLayout(normalizedGraphData, { preset: 'mingjing-g6' });
  4378. expandAnalysisLeafRingNodes(normalizedGraphData);
  4379. graph.setData(normalizedGraphData);
  4380. await graph.render();
  4381. } else {
  4382. graph.setLayout(createMingjingG6LayoutOptions(normalizedGraphData) as any);
  4383. graph.setData(normalizedGraphData);
  4384. await graph.render();
  4385. syncGraphNodePositionsToData(normalizedGraphData, graph);
  4386. expandAnalysisLeafRingNodes(normalizedGraphData);
  4387. clearGraphLayout(graph);
  4388. graph.setData(normalizedGraphData);
  4389. await graph.draw();
  4390. }
  4391. } catch (error) {
  4392. console.warn('g6 native mingjing layout fallback to legacy layout', error);
  4393. clearGraphLayout(graph);
  4394. await applyAnalysisRelationLayout(normalizedGraphData, { preset: 'mingjing-g6' });
  4395. expandAnalysisLeafRingNodes(normalizedGraphData);
  4396. graph.setData(normalizedGraphData);
  4397. await graph.render();
  4398. }
  4399. syncRenderedNodePositionsToGraphData(graph);
  4400. await fitGraphViewForData(graph.getData(), false, { focusLargeGraph: true });
  4401. return true;
  4402. };
  4403. const applySmartLayoutPasses = async (
  4404. initialGraphData: GraphData,
  4405. passes = getGraphLayoutPassCount(initialGraphData),
  4406. ) => {
  4407. const graph = graphRef.value;
  4408. if (!graph) return false;
  4409. let graphData = cloneGraphData(initialGraphData);
  4410. const normalizedPasses = Math.max(1, Math.floor(passes));
  4411. for (let index = 0; index < normalizedPasses; index += 1) {
  4412. const applied = await applySmartLayoutToGraphData(graphData);
  4413. if (!applied) return false;
  4414. graphData = cloneGraphData(graph.getData());
  4415. }
  4416. return true;
  4417. };
  4418. const applyAnalysisResponseToGraph = async (response: GraphAnalysisResponse) => {
  4419. const graph = graphRef.value;
  4420. if (!graph) return false;
  4421. const graphData = buildAnalysisGraphData(response);
  4422. if (!graphData.nodes?.length) return false;
  4423. const responseCounts = getAnalysisResponseCounts(response);
  4424. pendingEdgeSourceId.value = '';
  4425. await applySmartLayoutPasses(graphData);
  4426. clearLocalSelection();
  4427. setAnalysisTotalCounts(responseCounts.nodeCount, responseCounts.edgeCount);
  4428. refreshGraphSnapshot();
  4429. saveActiveCanvasState();
  4430. if (graphPerformanceMode.value) {
  4431. message.info('节点较多,已启用高性能渲染,布局仍使用力导向');
  4432. }
  4433. return true;
  4434. };
  4435. const executeStartAnalysis = async (payload: AnalysisConditionPayload) => {
  4436. if (analysisLoading.value || analysisLayoutLoading.value) return;
  4437. analysisLoading.value = true;
  4438. const shouldCloseModal = analysisConditionVisible.value;
  4439. try {
  4440. const effectivePayload = resolveAnalysisPayload(payload);
  4441. const response = await fetchGraphAnalysis(effectivePayload, routeGraphQuery.value);
  4442. if (shouldCloseModal) {
  4443. analysisConditionVisible.value = false;
  4444. }
  4445. analysisLoading.value = false;
  4446. analysisLayoutLoading.value = true;
  4447. await nextTick();
  4448. const rendered = await applyAnalysisResponseToGraph(response);
  4449. if (!rendered) {
  4450. if (shouldCloseModal) {
  4451. analysisConditionVisible.value = true;
  4452. }
  4453. message.warning('当前接口返回的数据没有匹配到可展示的图谱关系');
  4454. return;
  4455. }
  4456. lastAnalysisPayload.value = JSON.parse(JSON.stringify(effectivePayload)) as AnalysisConditionPayload;
  4457. syncAnalysisDataRefreshState(true);
  4458. message.success(
  4459. `已加载关系图:节点 ${analysisTotalNodeCount.value || graphStats.nodes},关系 ${analysisTotalEdgeCount.value || graphStats.edges}`,
  4460. );
  4461. } catch (error) {
  4462. if (shouldCloseModal) {
  4463. analysisConditionVisible.value = true;
  4464. }
  4465. console.error('load g6 graph analysis failed', error);
  4466. const errorMessage = error instanceof Error ? error.message : '';
  4467. message.error(errorMessage || '加载图谱数据失败,请稍后重试');
  4468. } finally {
  4469. analysisLoading.value = false;
  4470. analysisLayoutLoading.value = false;
  4471. }
  4472. };
  4473. const handleStartAnalysis = (payload: AnalysisConditionPayload) => {
  4474. const graph = graphRef.value;
  4475. const hasGraphData = Boolean(graph?.getData().nodes?.length || graph?.getData().edges?.length);
  4476. if (!hasGraphData) {
  4477. void executeStartAnalysis(payload);
  4478. return;
  4479. }
  4480. Modal.confirm({
  4481. title: '开始分析',
  4482. content: '当前画布已有图谱数据,重新分析将重新渲染图谱,是否继续?',
  4483. centered: true,
  4484. okText: '继续分析',
  4485. cancelText: '取消',
  4486. onOk: () => executeStartAnalysis(payload),
  4487. });
  4488. };
  4489. const selectContextNodes = async (nodeIds: string[]) => {
  4490. const graph = graphRef.value;
  4491. const uniqueNodeIds = uniqueValues(nodeIds.filter(Boolean));
  4492. if (!graph || !uniqueNodeIds.length) return;
  4493. await clearSelection();
  4494. await setManyElementSelected(uniqueNodeIds);
  4495. syncSelection();
  4496. };
  4497. const deleteContextElement = async (elementId: string, kind: ElementKind | null) => {
  4498. const graph = graphRef.value;
  4499. if (!graph || !elementId || !kind) {
  4500. await handleDeleteSelected();
  4501. return;
  4502. }
  4503. await withGraphMutation(async () => {
  4504. if (kind === 'node') {
  4505. deleteNodes(getSelectedNodeIdsForContext(elementId));
  4506. } else if (kind === 'edge') {
  4507. graph.removeEdgeData([elementId]);
  4508. } else {
  4509. const comboNodeIds = graph
  4510. .getNodeData()
  4511. .filter((node) => String(node.combo || '') === elementId)
  4512. .map((node) => String(node.id));
  4513. if (comboNodeIds.length) {
  4514. graph.updateNodeData(comboNodeIds.map((id) => ({ id, combo: null })));
  4515. }
  4516. graph.removeComboData([elementId]);
  4517. }
  4518. if (kind === 'node' || kind === 'edge') {
  4519. await refreshCurrentGraphEdgeCurves();
  4520. } else {
  4521. await graph.draw();
  4522. }
  4523. });
  4524. syncSelection();
  4525. };
  4526. const handleLookNodeFlow = async (nodeId: string, direction: 'incoming' | 'outgoing') => {
  4527. const graph = graphRef.value;
  4528. if (!graph || !findNodeData(nodeId)) {
  4529. message.warning('请先选择一个分析节点');
  4530. return;
  4531. }
  4532. const flowEdges = getNodeFlowEdges(nodeId, direction);
  4533. if (!flowEdges.length) {
  4534. message.warning(`当前节点没有${direction === 'incoming' ? '流入' : '流出'}关系`);
  4535. return;
  4536. }
  4537. const nodeIds = new Set<string>([nodeId]);
  4538. const edgeIds: string[] = [];
  4539. flowEdges.forEach((edge) => {
  4540. nodeIds.add(String(edge.source));
  4541. nodeIds.add(String(edge.target));
  4542. if (edge.id) edgeIds.push(String(edge.id));
  4543. });
  4544. await clearSelection();
  4545. await setManyElementSelected([...nodeIds, ...edgeIds]);
  4546. await graph.focusElement(nodeId, { duration: 240 });
  4547. syncSelection();
  4548. message.success(`已选中 ${flowEdges.length} 条${direction === 'incoming' ? '流入' : '流出'}关系`);
  4549. };
  4550. const getAnalysisNodePosition = (node?: NodeData | null) => ({
  4551. x: getFiniteNumber((node?.style as any)?.x, getGraphCenterCanvasPoint().x),
  4552. y: getFiniteNumber((node?.style as any)?.y, getGraphCenterCanvasPoint().y),
  4553. });
  4554. const getCollapsedAnalysisNodePositions = (anchorNode: NodeData, nodes: NodeData[]) => {
  4555. const anchorPosition = getAnalysisNodePosition(anchorNode);
  4556. const positionMap = new Map<string, { x: number; y: number }>();
  4557. let cursor = 0;
  4558. let ringIndex = 0;
  4559. while (cursor < nodes.length) {
  4560. const radius = ANALYSIS_EXPAND_FIRST_RING_RADIUS + ringIndex * ANALYSIS_EXPAND_RING_RADIUS_STEP;
  4561. const maxRingCount = Math.max(10, Math.floor((2 * Math.PI * radius) / ANALYSIS_EXPAND_ARC_GAP));
  4562. const ringNodes = nodes.slice(cursor, cursor + maxRingCount);
  4563. const angleOffset = -Math.PI / 2 + ringIndex * 0.17;
  4564. ringNodes.forEach((node, index) => {
  4565. const nodeId = String(node.id || '');
  4566. if (!nodeId) return;
  4567. const angle = angleOffset + (2 * Math.PI * index) / Math.max(1, ringNodes.length);
  4568. positionMap.set(nodeId, {
  4569. x: anchorPosition.x + Math.cos(angle) * radius,
  4570. y: anchorPosition.y + Math.sin(angle) * radius,
  4571. });
  4572. });
  4573. cursor += ringNodes.length;
  4574. ringIndex += 1;
  4575. }
  4576. return positionMap;
  4577. };
  4578. const getAnalysisEdgeDuplicateKey = (edge: EdgeData) =>
  4579. `${String(edge.source || '')}->${String(edge.target || '')}:${getEdgeLabel(edge)}`;
  4580. const resolveAnalysisExpandedEdgeId = (edge: EdgeData, index: number, existingEdgeIdSet: Set<string>) => {
  4581. const rawId = String(edge.id || '').trim();
  4582. if (rawId && !existingEdgeIdSet.has(rawId)) {
  4583. existingEdgeIdSet.add(rawId);
  4584. return rawId;
  4585. }
  4586. const suffix = Date.now();
  4587. let edgeId = `${ANALYSIS_EXPANDED_EDGE_PREFIX}-${suffix}-${index}`;
  4588. let nextIndex = index;
  4589. while (existingEdgeIdSet.has(edgeId)) {
  4590. nextIndex += 1;
  4591. edgeId = `${ANALYSIS_EXPANDED_EDGE_PREFIX}-${suffix}-${nextIndex}`;
  4592. }
  4593. existingEdgeIdSet.add(edgeId);
  4594. return edgeId;
  4595. };
  4596. const refreshGraphOptionsAfterDataChange = async () => {
  4597. const graph = graphRef.value;
  4598. if (!graph) return;
  4599. graphPerformanceMode.value = isLargeGraphData(graph.getData());
  4600. applyGraphInteractionOptions();
  4601. graph.setNode(createNodeOptions() as any);
  4602. graph.setEdge(createEdgeOptions() as any);
  4603. await refreshCurrentGraphEdgeCurves();
  4604. };
  4605. const handleExpandCollapsedAnalysisNode = async (nodeId: string) => {
  4606. const graph = graphRef.value;
  4607. const anchorNode = findNodeData(nodeId);
  4608. if (!graph || !anchorNode) {
  4609. message.warning('当前节点不可展开');
  4610. return;
  4611. }
  4612. const collapsedNodes = getAnalysisCollapsedNodes(anchorNode);
  4613. const collapsedEdges = getAnalysisCollapsedEdges(anchorNode);
  4614. if (!collapsedNodes.length || getAnalysisCollapsedNeighborCount(anchorNode) <= 0) {
  4615. message.warning('当前节点没有可展开的关联节点');
  4616. return;
  4617. }
  4618. if (canCollapseExpandedAnalysisNode(nodeId)) {
  4619. message.warning('当前节点已展开');
  4620. return;
  4621. }
  4622. const existingNodeIdSet = new Set(graph.getNodeData().map((node) => String(node.id)));
  4623. const existingEdgeIdSet = new Set(
  4624. graph
  4625. .getEdgeData()
  4626. .map((edge) => String(edge.id || ''))
  4627. .filter(Boolean),
  4628. );
  4629. const existingEdgeKeySet = new Set(graph.getEdgeData().map((edge) => getAnalysisEdgeDuplicateKey(edge)));
  4630. const positionMap = getCollapsedAnalysisNodePositions(anchorNode, collapsedNodes);
  4631. const expandedGroupId = `analysis-expanded-${nodeId}-${Date.now()}`;
  4632. const expandedNodes = collapsedNodes
  4633. .filter((node) => {
  4634. const collapsedNodeId = String(node.id || '');
  4635. return collapsedNodeId && !existingNodeIdSet.has(collapsedNodeId);
  4636. })
  4637. .map((node) => {
  4638. const expandedNodeId = String(node.id);
  4639. return {
  4640. ...cloneAnalysisNodeData(node),
  4641. combo: undefined,
  4642. data: {
  4643. ...(node.data || {}),
  4644. analysisExpandedGroupId: expandedGroupId,
  4645. analysisExpandedAnchorId: nodeId,
  4646. },
  4647. style: {
  4648. ...(node.style || {}),
  4649. ...(positionMap.get(expandedNodeId) || getAnalysisNodePosition(anchorNode)),
  4650. },
  4651. } as NodeData;
  4652. });
  4653. const visibleNodeIdSet = new Set([
  4654. ...existingNodeIdSet,
  4655. ...expandedNodes.map((node) => String(node.id || '')).filter(Boolean),
  4656. ]);
  4657. const expandedEdges = collapsedEdges
  4658. .map((edge, index) => {
  4659. const source = String(edge.source || '');
  4660. const target = String(edge.target || '');
  4661. if (!source || !target || source === target || !visibleNodeIdSet.has(source) || !visibleNodeIdSet.has(target)) {
  4662. return null;
  4663. }
  4664. const edgeKey = getAnalysisEdgeDuplicateKey(edge);
  4665. if (existingEdgeKeySet.has(edgeKey)) return null;
  4666. existingEdgeKeySet.add(edgeKey);
  4667. const edgeId = resolveAnalysisExpandedEdgeId(edge, index, existingEdgeIdSet);
  4668. return {
  4669. ...cloneAnalysisEdgeData(edge),
  4670. id: edgeId,
  4671. source,
  4672. target,
  4673. data: {
  4674. ...(edge.data || {}),
  4675. analysisExpandedGroupId: expandedGroupId,
  4676. analysisExpandedAnchorId: nodeId,
  4677. },
  4678. } as EdgeData;
  4679. })
  4680. .filter((edge): edge is EdgeData => Boolean(edge));
  4681. const expandedNodeIds = expandedNodes.map((node) => String(node.id));
  4682. const expandedEdgeIds = expandedEdges.map((edge) => String(edge.id || '')).filter(Boolean);
  4683. await withGraphMutation(async () => {
  4684. if (expandedNodes.length) graph.addNodeData(expandedNodes as any);
  4685. if (expandedEdges.length) graph.addEdgeData(expandedEdges);
  4686. graph.updateNodeData([
  4687. {
  4688. id: nodeId,
  4689. data: {
  4690. ...(anchorNode.data || {}),
  4691. analysisExpandedGroupId: expandedGroupId,
  4692. analysisExpandedNeighborCount: collapsedNodes.length,
  4693. analysisExpandedNodeIds: expandedNodeIds,
  4694. analysisExpandedEdgeIds: expandedEdgeIds,
  4695. analysisCollapsedNeighborCount: 0,
  4696. },
  4697. },
  4698. ]);
  4699. await refreshGraphOptionsAfterDataChange();
  4700. });
  4701. await clearSelection();
  4702. await setManyElementSelected([nodeId, ...expandedNodeIds.slice(0, 80)]);
  4703. await graph.focusElement(nodeId, { duration: 240 } as any);
  4704. syncSelection();
  4705. message.success(`已展开 ${collapsedNodes.length} 个关联节点`);
  4706. };
  4707. const handleCollapseExpandedAnalysisNode = async (nodeId: string) => {
  4708. const graph = graphRef.value;
  4709. const anchorNode = findNodeData(nodeId);
  4710. if (!graph || !anchorNode) {
  4711. message.warning('当前节点不可收起');
  4712. return;
  4713. }
  4714. const expandedNodeIds = getAnalysisExpandedNodeIds(anchorNode);
  4715. const expandedEdgeIdSet = new Set(getAnalysisExpandedEdgeIds(anchorNode));
  4716. if (!expandedNodeIds.length && !expandedEdgeIdSet.size) {
  4717. message.warning('当前节点没有已展开的关联节点');
  4718. return;
  4719. }
  4720. const removableNodeIds = expandedNodeIds.filter((expandedNodeId) => {
  4721. const expandedNode = findNodeData(expandedNodeId);
  4722. return getAnalysisNodeData(expandedNode).analysisExpandedAnchorId === nodeId;
  4723. });
  4724. const removableNodeIdSet = new Set(removableNodeIds);
  4725. const edgeIdsToRemove = graph
  4726. .getEdgeData()
  4727. .filter((edge) => {
  4728. const edgeId = String(edge.id || '');
  4729. const source = String(edge.source || '');
  4730. const target = String(edge.target || '');
  4731. const edgeData = getAnalysisEdgeData(edge);
  4732. return (
  4733. expandedEdgeIdSet.has(edgeId) ||
  4734. edgeData.analysisExpandedAnchorId === nodeId ||
  4735. removableNodeIdSet.has(source) ||
  4736. removableNodeIdSet.has(target)
  4737. );
  4738. })
  4739. .map((edge) => String(edge.id || ''))
  4740. .filter(Boolean);
  4741. const collapsedCount = getAnalysisCollapsedNodes(anchorNode).length || getAnalysisExpandedNeighborCount(anchorNode);
  4742. await withGraphMutation(async () => {
  4743. if (edgeIdsToRemove.length) {
  4744. graph.removeEdgeData(uniqueValues(edgeIdsToRemove));
  4745. }
  4746. if (removableNodeIds.length) {
  4747. graph.removeNodeData(removableNodeIds);
  4748. }
  4749. graph.updateNodeData([
  4750. {
  4751. id: nodeId,
  4752. data: {
  4753. ...(anchorNode.data || {}),
  4754. analysisExpandedGroupId: undefined,
  4755. analysisExpandedAnchorId: undefined,
  4756. analysisExpandedNeighborCount: undefined,
  4757. analysisExpandedNodeIds: undefined,
  4758. analysisExpandedEdgeIds: undefined,
  4759. analysisCollapsedNeighborCount: collapsedCount,
  4760. },
  4761. },
  4762. ]);
  4763. await refreshGraphOptionsAfterDataChange();
  4764. });
  4765. await clearSelection();
  4766. await setManyElementSelected([nodeId]);
  4767. syncSelection();
  4768. message.success(`已收起 ${collapsedCount} 个关联节点`);
  4769. };
  4770. const openAssociativeObject = (nodeId: string) => {
  4771. if (!canShowAssociativeObject(nodeId)) {
  4772. message.warning('当前节点不支持关联对象');
  4773. return;
  4774. }
  4775. associativeObjectAnchorId.value = nodeId;
  4776. associativeObjectForm.entityKind = 'person';
  4777. associativeObjectForm.name = '';
  4778. associativeObjectForm.identityCode = '';
  4779. associativeObjectVisible.value = true;
  4780. };
  4781. const getAssociativeObjectPosition = (anchorNodeId: string) => {
  4782. const graph = graphRef.value;
  4783. const anchorNode = findNodeData(anchorNodeId);
  4784. const anchorX = Number((anchorNode?.style as any)?.x || 0);
  4785. const anchorY = Number((anchorNode?.style as any)?.y || 0);
  4786. const relatedCount =
  4787. graph
  4788. ?.getEdgeData()
  4789. .filter((edge) => String(edge.source) === anchorNodeId || String(edge.target) === anchorNodeId).length || 0;
  4790. const ring = Math.floor(relatedCount / 8);
  4791. const radius = 170 + ring * 88;
  4792. const angle = -Math.PI / 2 + (relatedCount % 8) * (Math.PI / 4);
  4793. return {
  4794. x: anchorX + Math.cos(angle) * radius,
  4795. y: anchorY + Math.sin(angle) * radius,
  4796. };
  4797. };
  4798. const handleConfirmAssociativeObject = async () => {
  4799. const graph = graphRef.value;
  4800. const anchorNodeId = associativeObjectAnchorId.value;
  4801. const anchorNode = findNodeData(anchorNodeId);
  4802. const name = associativeObjectForm.name.trim();
  4803. const identityCode = associativeObjectForm.identityCode.trim();
  4804. if (!graph || !anchorNode) {
  4805. message.warning('未找到关联对象锚点');
  4806. return;
  4807. }
  4808. if (!name) {
  4809. message.warning('请输入关联对象名称');
  4810. return;
  4811. }
  4812. const kind = getEntityKind(associativeObjectForm.entityKind).key;
  4813. const fullLabel = [name, identityCode].filter(Boolean).join(' ');
  4814. const position = getAssociativeObjectPosition(anchorNodeId);
  4815. const suffix = Date.now();
  4816. const nodeId = `manual-object-${suffix}`;
  4817. const edgeId = `manual-edge-${suffix}`;
  4818. await withGraphMutation(async () => {
  4819. clearAnalysisTotalCounts();
  4820. graph.addNodeData([
  4821. {
  4822. id: nodeId,
  4823. data: {
  4824. kind,
  4825. label: fullLabel || name,
  4826. fullLabel: fullLabel || name,
  4827. isAnalysis: true,
  4828. rawData: {
  4829. id: nodeId,
  4830. bm: identityCode || name,
  4831. mc: name,
  4832. class: kind,
  4833. },
  4834. },
  4835. style: position,
  4836. },
  4837. ]);
  4838. graph.addEdgeData([
  4839. {
  4840. id: edgeId,
  4841. source: anchorNodeId,
  4842. target: nodeId,
  4843. data: {
  4844. label: '关联对象',
  4845. isAnalysis: true,
  4846. rawData: {
  4847. id: edgeId,
  4848. dx1: String(anchorNode.data?.fullLabel || anchorNode.data?.label || anchorNode.id),
  4849. dx2: identityCode || name,
  4850. clazz: 'custom_assoc',
  4851. mc: '关联对象',
  4852. sm: '关联对象',
  4853. },
  4854. },
  4855. },
  4856. ]);
  4857. await refreshCurrentGraphEdgeCurves();
  4858. });
  4859. await clearSelection();
  4860. await setManyElementSelected([anchorNodeId, nodeId, edgeId]);
  4861. syncSelection();
  4862. associativeObjectVisible.value = false;
  4863. associativeObjectAnchorId.value = '';
  4864. message.success('已创建关联对象');
  4865. };
  4866. const handleContextMenuClick = async (value: ContextMenuActionKey, current: { id?: string } | null) => {
  4867. const graph = graphRef.value;
  4868. const state = contextMenuState.value;
  4869. const elementId = String(current?.id || state?.id || '');
  4870. const kind = resolveElementKind(elementId, state?.type);
  4871. const menuPosition = state ? { x: state.canvasX, y: state.canvasY } : getGraphCenterCanvasPoint();
  4872. if (!graph) return;
  4873. if (value === 'copy') {
  4874. if (kind === 'node') {
  4875. await selectContextNodes(getSelectedNodeIdsForContext(elementId));
  4876. }
  4877. copySelectionToClipboard();
  4878. return;
  4879. }
  4880. if (value === 'paste') {
  4881. await pasteClipboardToCanvas(menuPosition);
  4882. return;
  4883. }
  4884. if (value === 'delete') {
  4885. await deleteContextElement(elementId, kind);
  4886. return;
  4887. }
  4888. if (value === 'group') {
  4889. await selectContextNodes(getSelectedNodeIdsForContext(elementId));
  4890. await handleCreateCombo();
  4891. return;
  4892. }
  4893. if (value === 'ungroup') {
  4894. if (kind === 'combo' && elementId) {
  4895. await ungroupCombo(elementId);
  4896. return;
  4897. }
  4898. const nodeComboId = kind === 'node' ? String(findNodeData(elementId)?.combo || '') : '';
  4899. if (nodeComboId) {
  4900. await ungroupCombo(nodeComboId);
  4901. return;
  4902. }
  4903. await handleRemoveGroup();
  4904. return;
  4905. }
  4906. if (value === 'newCanvas') {
  4907. if (kind === 'node') {
  4908. await selectContextNodes(getSelectedNodeIdsForContext(elementId));
  4909. }
  4910. await handleCreateNewCanvas();
  4911. return;
  4912. }
  4913. if (value === 'analysisDetail' && elementId && kind && canOpenAnalysisDetail(elementId, kind)) {
  4914. await openAnalysisDetail(elementId, kind);
  4915. return;
  4916. }
  4917. if (value === 'startEdge' && elementId && kind === 'node') {
  4918. pendingEdgeSourceId.value = elementId;
  4919. message.info('点击另一个节点即可完成连线');
  4920. return;
  4921. }
  4922. if (value === 'onlyLookFlowIn' && elementId && kind === 'node') {
  4923. await handleLookNodeFlow(elementId, 'incoming');
  4924. return;
  4925. }
  4926. if (value === 'onlyLookFlowOut' && elementId && kind === 'node') {
  4927. await handleLookNodeFlow(elementId, 'outgoing');
  4928. return;
  4929. }
  4930. if (value === 'associativeObject' && elementId && kind === 'node') {
  4931. openAssociativeObject(elementId);
  4932. return;
  4933. }
  4934. if (value === 'createPerson') {
  4935. await addNodeAt('person', menuPosition.x, menuPosition.y);
  4936. return;
  4937. }
  4938. if (value === 'fitView') {
  4939. await handleFitView();
  4940. }
  4941. };
  4942. const handleFitView = async () => {
  4943. await fitGraphViewForData();
  4944. };
  4945. const handleCenterView = async () => {
  4946. const graph = graphRef.value;
  4947. if (!graph) return;
  4948. await graph.fitCenter(false);
  4949. refreshZoomLabel();
  4950. };
  4951. const compareNumericValue = (value: number, operator: FilterForm['targetCondition'], target: number) => {
  4952. if (operator === '>') return value > target;
  4953. if (operator === '>=') return value >= target;
  4954. if (operator === '=') return value === target;
  4955. if (operator === '<') return value < target;
  4956. return value <= target;
  4957. };
  4958. const extractEdgeNumericValues = (edge: EdgeData) => {
  4959. const rawData = (edge.data?.rawData || {}) as Record<string, any>;
  4960. return [rawData.nums, rawData.money, rawData.count, edge.data?.label]
  4961. .map((item) => String(item ?? '').match(/-?\d+(?:\.\d+)?/)?.[0])
  4962. .filter(Boolean)
  4963. .map((item) => Number(item))
  4964. .filter((item) => !Number.isNaN(item));
  4965. };
  4966. const refreshGraphStyleOptions = async () => {
  4967. const graph = graphRef.value;
  4968. if (!graph) return;
  4969. graph.setNode(createNodeOptions() as any);
  4970. graph.setEdge(createEdgeOptions() as any);
  4971. await graph.draw();
  4972. refreshGraphSnapshot();
  4973. };
  4974. const applyFilterPresentation = async () => {
  4975. const graph = graphRef.value;
  4976. if (!graph) return;
  4977. const mutedNodeSet = new Set(filterMutedNodeIds.value);
  4978. const hiddenNodeSet = new Set(filterHidden.value ? filterMutedNodeIds.value : []);
  4979. const matchedNodeSet = new Set(filterMatchedNodeIds.value);
  4980. const matchedEdgeSet = new Set(filterMatchedEdgeIds.value);
  4981. graph.updateNodeData(
  4982. graph.getNodeData().map((node) => ({
  4983. id: node.id,
  4984. data: {
  4985. ...(node.data || {}),
  4986. dimmed: mutedNodeSet.has(String(node.id)),
  4987. filterHidden: hiddenNodeSet.has(String(node.id)),
  4988. },
  4989. })),
  4990. );
  4991. graph.updateEdgeData(
  4992. graph.getEdgeData().map((edge) => ({
  4993. id: edge.id,
  4994. data: {
  4995. ...(edge.data || {}),
  4996. dimmed: filterMatchedNodeIds.value.length ? !matchedEdgeSet.has(String(edge.id)) : false,
  4997. filterHidden:
  4998. filterHidden.value &&
  4999. (!matchedEdgeSet.has(String(edge.id)) ||
  5000. hiddenNodeSet.has(String(edge.source)) ||
  5001. hiddenNodeSet.has(String(edge.target))),
  5002. },
  5003. })),
  5004. );
  5005. await refreshGraphStyleOptions();
  5006. if (matchedNodeSet.size) {
  5007. await clearSelection();
  5008. await setManyElementSelected([...matchedNodeSet]);
  5009. syncSelection();
  5010. }
  5011. };
  5012. const executeFilterCells = async () => {
  5013. const graph = graphRef.value;
  5014. if (!graph) return;
  5015. if (!filterForm.findEntityList.length) {
  5016. message.warning('请选择过滤实体');
  5017. return;
  5018. }
  5019. if (filterForm.setResult === undefined || Number.isNaN(Number(filterForm.setResult))) {
  5020. message.warning('请输入设定结果');
  5021. return;
  5022. }
  5023. const targetKinds = new Set(filterForm.findEntityList);
  5024. const targetValue = Number(filterForm.setResult);
  5025. const matchedNodeIds: string[] = [];
  5026. const mutedNodeIds: string[] = [];
  5027. const matchedEdgeIds = new Set<string>();
  5028. const edgeList = graph.getEdgeData();
  5029. graph.getNodeData().forEach((node) => {
  5030. const nodeId = String(node.id);
  5031. if (!targetKinds.has((node.data?.kind as EntityKindKey) || 'unknown')) {
  5032. mutedNodeIds.push(nodeId);
  5033. return;
  5034. }
  5035. const connectedEdges = edgeList.filter(
  5036. (edge) => String(edge.source) === nodeId || String(edge.target) === nodeId,
  5037. );
  5038. const matched =
  5039. filterForm.filterCondition === 'neighborTotal'
  5040. ? compareNumericValue(connectedEdges.length, filterForm.targetCondition, targetValue)
  5041. : connectedEdges.some((edge) =>
  5042. extractEdgeNumericValues(edge).some((value) =>
  5043. compareNumericValue(value, filterForm.targetCondition, targetValue),
  5044. ),
  5045. );
  5046. if (matched) {
  5047. matchedNodeIds.push(nodeId);
  5048. connectedEdges.forEach((edge) => edge.id && matchedEdgeIds.add(String(edge.id)));
  5049. } else {
  5050. mutedNodeIds.push(nodeId);
  5051. }
  5052. });
  5053. filterMatchedNodeIds.value = matchedNodeIds;
  5054. filterMutedNodeIds.value = mutedNodeIds;
  5055. filterMatchedEdgeIds.value = [...matchedEdgeIds];
  5056. filterHidden.value = false;
  5057. await applyFilterPresentation();
  5058. message.success(`过滤完成:匹配 ${matchedNodeIds.length} 个实体`);
  5059. };
  5060. const handleFilterCancel = async () => {
  5061. filterMatchedNodeIds.value = [];
  5062. filterMutedNodeIds.value = [];
  5063. filterMatchedEdgeIds.value = [];
  5064. filterHidden.value = false;
  5065. await applyFilterPresentation();
  5066. };
  5067. const handleFilterSelectAll = async () => {
  5068. const graph = graphRef.value;
  5069. if (!graph || !filterMatchedNodeIds.value.length) return;
  5070. await clearSelection();
  5071. await setManyElementSelected(filterMatchedNodeIds.value);
  5072. syncSelection();
  5073. };
  5074. const handleFilterToggleDisplay = async () => {
  5075. filterHidden.value = !filterHidden.value;
  5076. await applyFilterPresentation();
  5077. };
  5078. const getEdgeSearchNumericValues = (edge: EdgeData) => {
  5079. const rawData = (edge.data?.rawData || {}) as Record<string, any>;
  5080. const directValues = [rawData.money, rawData.nums, rawData.count]
  5081. .map((item) => Number(item))
  5082. .filter((item) => Number.isFinite(item));
  5083. const labelValues = getEdgeLabel(edge)
  5084. .replace(/[^0-9.,]/g, ' ')
  5085. .split(/\s+/)
  5086. .filter(Boolean)
  5087. .map((item) => Number(item.replace(/,/g, '')))
  5088. .filter((item) => Number.isFinite(item));
  5089. return Array.from(new Set([...directValues, ...labelValues]));
  5090. };
  5091. const findElements = () => {
  5092. const graph = graphRef.value;
  5093. if (!graph) return [];
  5094. const getNodeSearchTexts = (node: NodeData) => [
  5095. getNodeLabel(node),
  5096. getNodeSecondaryLabel(node),
  5097. getPerformanceNodeSecondaryLabel(node),
  5098. String(node.data?.fullLabel || ''),
  5099. String(node.id),
  5100. ];
  5101. if (findForm.scope === 'entity') {
  5102. const keyword = findForm.keyword.trim();
  5103. if (!keyword) return [];
  5104. const normalizedKeyword = sanitizeEdgeLabelText(keyword);
  5105. const matcher = (texts: Array<string | number | null | undefined>) =>
  5106. texts.some((text) => {
  5107. const value = String(text ?? '').trim();
  5108. if (!value) return false;
  5109. const normalizedValue = sanitizeEdgeLabelText(value);
  5110. return findForm.manner === 'precise'
  5111. ? value === keyword || normalizedValue === normalizedKeyword
  5112. : value.includes(keyword) || normalizedValue.includes(normalizedKeyword);
  5113. });
  5114. return graph
  5115. .getNodeData()
  5116. .filter((node) => matcher(getNodeSearchTexts(node)))
  5117. .map((node) => String(node.id));
  5118. }
  5119. const rangeMin =
  5120. findForm.rangeMin === undefined || findForm.rangeMin === null ? undefined : Number(findForm.rangeMin);
  5121. const rangeMax =
  5122. findForm.rangeMax === undefined || findForm.rangeMax === null ? undefined : Number(findForm.rangeMax);
  5123. if (rangeMin === undefined && rangeMax === undefined) return [];
  5124. return graph
  5125. .getEdgeData()
  5126. .filter((edge) => isAnalysisEdge(edge))
  5127. .filter((edge) => {
  5128. const values = getEdgeSearchNumericValues(edge);
  5129. if (!values.length) return false;
  5130. return values.some((value) => {
  5131. if (rangeMin !== undefined && value < rangeMin) return false;
  5132. if (rangeMax !== undefined && value > rangeMax) return false;
  5133. return true;
  5134. });
  5135. })
  5136. .map((edge) => String(edge.id || ''))
  5137. .filter(Boolean);
  5138. };
  5139. const focusFindResult = async (index: number) => {
  5140. const graph = graphRef.value;
  5141. const targetId = findResultIds.value[index];
  5142. if (!graph || !targetId) return;
  5143. await clearSelection();
  5144. await graph.setElementState(targetId, ['selected'], false);
  5145. const targetData = findForm.scope === 'entity' ? findNodeData(targetId) : findEdgeData(targetId);
  5146. if (findForm.scope === 'entity' && targetData?.style) {
  5147. await graph.focusElement(targetId, { animation: { duration: 240 } } as any);
  5148. }
  5149. syncSelection();
  5150. };
  5151. const handleFindConfirm = async () => {
  5152. if (findForm.scope === 'entity') {
  5153. if (!findForm.keyword.trim()) {
  5154. message.warning('请输入查找内容');
  5155. return;
  5156. }
  5157. } else {
  5158. const rangeMin =
  5159. findForm.rangeMin === undefined || findForm.rangeMin === null ? undefined : Number(findForm.rangeMin);
  5160. const rangeMax =
  5161. findForm.rangeMax === undefined || findForm.rangeMax === null ? undefined : Number(findForm.rangeMax);
  5162. if (rangeMin === undefined && rangeMax === undefined) {
  5163. message.warning('请输入关系数值范围');
  5164. return;
  5165. }
  5166. if (
  5167. (rangeMin !== undefined && !Number.isFinite(rangeMin)) ||
  5168. (rangeMax !== undefined && !Number.isFinite(rangeMax))
  5169. ) {
  5170. message.warning('请输入有效的数字范围');
  5171. return;
  5172. }
  5173. if (rangeMin !== undefined && rangeMax !== undefined && rangeMin > rangeMax) {
  5174. message.warning('最小值不能大于最大值');
  5175. return;
  5176. }
  5177. }
  5178. findResultIds.value = findElements();
  5179. findResultIndex.value = findResultIds.value.length ? 0 : -1;
  5180. if (!findResultIds.value.length) {
  5181. message.warning('未找到匹配结果');
  5182. return;
  5183. }
  5184. await focusFindResult(0);
  5185. };
  5186. const handleFindPrev = async () => {
  5187. if (!findResultIds.value.length) return;
  5188. findResultIndex.value = (findResultIndex.value - 1 + findResultIds.value.length) % findResultIds.value.length;
  5189. await focusFindResult(findResultIndex.value);
  5190. };
  5191. const handleFindNext = async () => {
  5192. if (!findResultIds.value.length) return;
  5193. findResultIndex.value = (findResultIndex.value + 1) % findResultIds.value.length;
  5194. await focusFindResult(findResultIndex.value);
  5195. };
  5196. const handleFindSelectAll = async () => {
  5197. const graph = graphRef.value;
  5198. if (!graph || !findResultIds.value.length) return;
  5199. await clearSelection();
  5200. await setManyElementSelected(findResultIds.value);
  5201. syncSelection();
  5202. };
  5203. const handleSetMarker = async (colorIndex: number) => {
  5204. const graph = graphRef.value;
  5205. if (!graph || !selectedNodeIds.value.length) {
  5206. message.warning('请至少选择一个实体后再进行操作');
  5207. return;
  5208. }
  5209. const nextColor = markerColors[colorIndex];
  5210. await withGraphMutation(async () => {
  5211. graph.updateNodeData(
  5212. selectedNodeIds.value.map((id) => {
  5213. const node = findNodeData(id);
  5214. return {
  5215. id,
  5216. data: {
  5217. ...(node?.data || {}),
  5218. markerColor: node?.data?.markerColor === nextColor ? undefined : nextColor,
  5219. },
  5220. };
  5221. }),
  5222. );
  5223. await refreshGraphStyleOptions();
  5224. });
  5225. };
  5226. const handleSelectNeighborEntity = async () => {
  5227. const graph = graphRef.value;
  5228. if (!graph) return;
  5229. const selectedNodeIdSet = new Set(selectedNodeIds.value);
  5230. const selectedEdgeIdSet = new Set(selectedEdgeIds.value);
  5231. if (!selectedNodeIdSet.size && !selectedEdgeIdSet.size) {
  5232. message.warning('请至少选择一个实体或关系后再进行操作');
  5233. return;
  5234. }
  5235. const relatedNodeIds = new Set(selectedNodeIdSet);
  5236. const relatedEdgeIds = new Set(selectedEdgeIdSet);
  5237. const edgeList = graph.getEdgeData();
  5238. selectedEdgeIdSet.forEach((edgeId) => {
  5239. const edge = findEdgeData(edgeId);
  5240. if (edge) {
  5241. relatedNodeIds.add(String(edge.source));
  5242. relatedNodeIds.add(String(edge.target));
  5243. }
  5244. });
  5245. edgeList.forEach((edge) => {
  5246. const source = String(edge.source);
  5247. const target = String(edge.target);
  5248. if (selectedNodeIdSet.has(source) || selectedNodeIdSet.has(target)) {
  5249. edge.id && relatedEdgeIds.add(String(edge.id));
  5250. relatedNodeIds.add(source);
  5251. relatedNodeIds.add(target);
  5252. }
  5253. });
  5254. if (relatedNodeIds.size <= selectedNodeIdSet.size && relatedEdgeIds.size <= selectedEdgeIdSet.size) {
  5255. message.warning('当前选中的实体关系无法进行扩散');
  5256. return;
  5257. }
  5258. await clearSelection();
  5259. await setManyElementSelected([...relatedNodeIds, ...relatedEdgeIds]);
  5260. syncSelection();
  5261. message.success(`已选中 ${relatedNodeIds.size} 个实体`);
  5262. };
  5263. const copySelectionToClipboard = () => {
  5264. const graph = graphRef.value;
  5265. if (!graph || (!selectedNodeIds.value.length && !selectedEdgeIds.value.length)) {
  5266. message.warning('请先选择要复制的元素');
  5267. return;
  5268. }
  5269. syncRenderedNodePositionsToGraphData(graph);
  5270. const nodeIdSet = new Set(selectedNodeIds.value);
  5271. const nodes = cloneGraphData({ nodes: graph.getNodeData().filter((node) => nodeIdSet.has(String(node.id))) }).nodes;
  5272. const edges = cloneGraphData({
  5273. edges: graph
  5274. .getEdgeData()
  5275. .filter(
  5276. (edge) =>
  5277. selectedEdgeIds.value.includes(String(edge.id || '')) ||
  5278. (nodeIdSet.has(String(edge.source)) && nodeIdSet.has(String(edge.target))),
  5279. ),
  5280. }).edges;
  5281. graphClipboard.value = { nodes, edges };
  5282. message.success(`已复制 ${nodes.length} 个实体,${edges.length} 条关系`);
  5283. };
  5284. const pasteClipboardToCanvas = async (position?: { x: number; y: number }) => {
  5285. const graph = graphRef.value;
  5286. const payload = graphClipboard.value;
  5287. if (!graph || !payload || !payload.nodes.length) {
  5288. message.warning('剪贴板为空');
  5289. return;
  5290. }
  5291. const suffix = Date.now();
  5292. const idMap = new Map<string, string>();
  5293. const nodePositions = payload.nodes.map((node) => ({
  5294. x: Number((node.style as any)?.x || 0),
  5295. y: Number((node.style as any)?.y || 0),
  5296. }));
  5297. const minX = Math.min(...nodePositions.map((item) => item.x));
  5298. const minY = Math.min(...nodePositions.map((item) => item.y));
  5299. const offsetX = position ? position.x - minX : 56;
  5300. const offsetY = position ? position.y - minY : 56;
  5301. const nodes: NodeData[] = payload.nodes.map((node, index) => {
  5302. const nextId = `${node.id}-copy-${suffix}-${index}`;
  5303. idMap.set(String(node.id), nextId);
  5304. return {
  5305. ...node,
  5306. id: nextId,
  5307. combo: undefined,
  5308. style: {
  5309. ...(node.style || {}),
  5310. x: Number((node.style as any)?.x || 0) + offsetX,
  5311. y: Number((node.style as any)?.y || 0) + offsetY,
  5312. },
  5313. } as NodeData;
  5314. });
  5315. const edges: EdgeData[] = payload.edges
  5316. .map((edge, index) => {
  5317. const source = idMap.get(String(edge.source));
  5318. const target = idMap.get(String(edge.target));
  5319. if (!source || !target) return null;
  5320. return {
  5321. ...edge,
  5322. id: `${edge.id || 'edge'}-copy-${suffix}-${index}`,
  5323. source,
  5324. target,
  5325. } as EdgeData;
  5326. })
  5327. .filter((edge): edge is EdgeData => Boolean(edge));
  5328. await withGraphMutation(async () => {
  5329. clearAnalysisTotalCounts();
  5330. graph.addNodeData(nodes as any);
  5331. if (edges.length) graph.addEdgeData(edges);
  5332. if (edges.length) {
  5333. await refreshCurrentGraphEdgeCurves();
  5334. } else {
  5335. await graph.draw();
  5336. }
  5337. });
  5338. await clearSelection();
  5339. await setManyElementSelected(nodes.map((node) => String(node.id)));
  5340. syncSelection();
  5341. };
  5342. const handleRemoveGroup = async () => {
  5343. const comboId = selectedComboIds.value[0];
  5344. if (!comboId) {
  5345. message.warning('请先选择分组');
  5346. return;
  5347. }
  5348. await ungroupCombo(comboId);
  5349. };
  5350. const handleCreateNewCanvas = async () => {
  5351. saveActiveCanvasState();
  5352. const shouldCopySelection = selectedSummary.value.total > 0;
  5353. if (shouldCopySelection) {
  5354. copySelectionToClipboard();
  5355. }
  5356. const label = `画布 ${graphCanvasTabs.value.length + 1}`;
  5357. const preserveAnalysisTotals = !shouldCopySelection;
  5358. const snapshot =
  5359. shouldCopySelection && graphClipboard.value
  5360. ? createSnapshotFromData({
  5361. nodes: graphClipboard.value.nodes as NodeData[],
  5362. edges: graphClipboard.value.edges as EdgeData[],
  5363. combos: [],
  5364. })
  5365. : serializeGraph();
  5366. graphCanvasTabs.value.push({
  5367. id: `canvas-${Date.now()}`,
  5368. label,
  5369. snapshot: snapshot || EMPTY_GRAPH_SNAPSHOT,
  5370. zoom: 1,
  5371. lastSavedAt: '',
  5372. analysisTotalNodeCount: preserveAnalysisTotals ? analysisTotalNodeCount.value : 0,
  5373. analysisTotalEdgeCount: preserveAnalysisTotals ? analysisTotalEdgeCount.value : 0,
  5374. dirty: false,
  5375. });
  5376. activeGraphCanvasIndex.value = graphCanvasTabs.value.length - 1;
  5377. await restoreGraphSnapshot(graphCanvasTabs.value[activeGraphCanvasIndex.value].snapshot);
  5378. };
  5379. const handleToggleFullscreen = async () => {
  5380. const target = graphWrapperRef.value || graphContainerRef.value;
  5381. if (!target) return;
  5382. try {
  5383. if (document.fullscreenElement) {
  5384. await document.exitFullscreen?.();
  5385. } else {
  5386. await target.requestFullscreen?.();
  5387. }
  5388. } catch (error) {
  5389. console.error('toggle g6 fullscreen failed', error);
  5390. message.error('切换全屏失败,请稍后重试');
  5391. }
  5392. };
  5393. const handleForceLayout = async () => {
  5394. const graph = graphRef.value;
  5395. if (!graph) return;
  5396. toolbarRunningKey.value = 'forceLayout';
  5397. analysisLayoutLoading.value = true;
  5398. closeActiveToolbarPanel();
  5399. try {
  5400. await nextTick();
  5401. await applySmartLayoutToGraphData(graph.getData());
  5402. syncRenderedNodePositionsToGraphData(graph);
  5403. refreshGraphSnapshot(true);
  5404. saveActiveCanvasState();
  5405. message.success(`已完成智能重布局:节点 ${graphStats.nodes},关系 ${graphStats.edges}`);
  5406. } catch (error) {
  5407. console.error('g6 force layout failed', error);
  5408. message.error('智能布局失败,请稍后重试');
  5409. } finally {
  5410. analysisLayoutLoading.value = false;
  5411. toolbarRunningKey.value = '';
  5412. }
  5413. };
  5414. const openAnalysisDetail = async (elementId: string, kind: ElementKind) => {
  5415. const target =
  5416. kind === 'node' ? findNodeData(elementId) : kind === 'edge' ? findEdgeData(elementId) : findComboData(elementId);
  5417. if (!target) {
  5418. message.warning('当前节点没有可查看的详情');
  5419. return;
  5420. }
  5421. if (kind === 'edge') {
  5422. const edge = target as EdgeData;
  5423. if (openCommunicationCallDetail(edge, kind)) return;
  5424. if (openTradeEdgeTransDetail(edge)) return;
  5425. callDetailVisible.value = false;
  5426. transDetailVisible.value = false;
  5427. const sourceName = getEdgeEndpointName(edge, 'source');
  5428. const targetName = getEdgeEndpointName(edge, 'target');
  5429. analysisDetailTitle.value = `${sourceName} -> ${targetName}`;
  5430. const rawData = (edge.data?.rawData || edge.data || {}) as Record<string, any>;
  5431. analysisDetailEntries.value = [
  5432. { label: 'ID', value: String(edge.id || '-') },
  5433. { label: '名称', value: getEdgeLabel(edge) },
  5434. ...Object.entries(rawData)
  5435. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  5436. .slice(0, 24)
  5437. .map(([label, value]) => ({ label, value: stringifyDetailValue(value) })),
  5438. ];
  5439. analysisDetailQueryBase.value = null;
  5440. analysisDetailTableColumns.value = [];
  5441. analysisDetailTableRows.value = [];
  5442. analysisDetailPagination.value.total = 0;
  5443. analysisDetailVisible.value = true;
  5444. return;
  5445. }
  5446. if (kind === 'node') {
  5447. const node = target as NodeData;
  5448. if (openCommunicationCallDetail(node, kind)) return;
  5449. callDetailVisible.value = false;
  5450. transDetailVisible.value = false;
  5451. analysisDetailTitle.value = String(node.data?.fullLabel || node.data?.label || '实体详情');
  5452. analysisDetailLoading.value = true;
  5453. analysisDetailTableColumns.value = [];
  5454. analysisDetailTableRows.value = [];
  5455. resetAnalysisDetailTableFilters();
  5456. analysisDetailPagination.value = {
  5457. page: 1,
  5458. limit: componentSetting.table.defaultPageSize,
  5459. total: 0,
  5460. };
  5461. if (shouldFetchNodeDetail(node)) {
  5462. const query = buildGraphNodeDetailQuery(node);
  5463. if (!query) {
  5464. analysisDetailLoading.value = false;
  5465. message.warning('当前节点缺少详情查询参数');
  5466. return;
  5467. }
  5468. analysisDetailQueryBase.value = query;
  5469. analysisDetailVisible.value = true;
  5470. await fetchAnalysisDetailTable();
  5471. return;
  5472. }
  5473. analysisDetailQueryBase.value = null;
  5474. analysisDetailEntries.value = buildNodeDetailEntriesFromPayload(node.data?.rawData || node.data || {});
  5475. analysisDetailTableColumns.value = [];
  5476. analysisDetailTableRows.value = [];
  5477. analysisDetailPagination.value.total = 0;
  5478. analysisDetailLoading.value = false;
  5479. analysisDetailVisible.value = true;
  5480. return;
  5481. }
  5482. const rawData = (target.data?.rawData || target.data || {}) as Record<string, any>;
  5483. callDetailVisible.value = false;
  5484. transDetailVisible.value = false;
  5485. analysisDetailTitle.value = '分组详情';
  5486. analysisDetailQueryBase.value = null;
  5487. analysisDetailTableColumns.value = [];
  5488. analysisDetailTableRows.value = [];
  5489. analysisDetailPagination.value.total = 0;
  5490. analysisDetailEntries.value = [
  5491. { label: 'ID', value: String(target.id || '-') },
  5492. {
  5493. label: '名称',
  5494. value: getComboLabel(target as ComboData),
  5495. },
  5496. ...Object.entries(rawData)
  5497. .filter(([, value]) => value !== undefined && value !== null && value !== '')
  5498. .slice(0, 24)
  5499. .map(([label, value]) => ({ label, value: stringifyDetailValue(value) })),
  5500. ];
  5501. analysisDetailVisible.value = true;
  5502. };
  5503. const isToolbarItemActive = (item: AnalysisToolbarItem) =>
  5504. item.panelKey === activeToolbarPanel.value ||
  5505. (item.key === 'fullScreen' && isFullscreen.value) ||
  5506. toolbarRunningKey.value === item.key;
  5507. const getToolbarItemDisabled = (key: AnalysisToolbarKey) => {
  5508. if (key === 'undo') return !undoStack.value.length;
  5509. if (key === 'redo') return !redoStack.value.length;
  5510. if (key === 'forceLayout') return toolbarRunningKey.value === key;
  5511. if (key === 'paste') return !graphClipboard.value;
  5512. if (key === 'copy') return !selectedNodeIds.value.length;
  5513. if (key === 'delete') return !selectedSummary.value.total;
  5514. if (key === 'group') return selectedNodeIds.value.length < 2;
  5515. if (key === 'ungroup') return !selectedComboIds.value.length;
  5516. if (key === 'newCanvas') return !selectedNodeIds.value.length;
  5517. if (key === 'mark') return !selectedNodeIds.value.length;
  5518. if (key === 'selectNeighbor') return !selectedNodeIds.value.length && !selectedEdgeIds.value.length;
  5519. return false;
  5520. };
  5521. const toggleToolbarPanel = (panel: ToolbarPanelKey) => {
  5522. activeToolbarPanel.value = activeToolbarPanel.value === panel ? null : panel;
  5523. };
  5524. const closeActiveToolbarPanel = () => {
  5525. activeToolbarPanel.value = null;
  5526. };
  5527. const handleAnalysisToolbarClick = (item: AnalysisToolbarItem) => {
  5528. if (getToolbarItemDisabled(item.key)) return;
  5529. if (item.panelKey) {
  5530. toggleToolbarPanel(item.panelKey);
  5531. return;
  5532. }
  5533. switch (item.key) {
  5534. case 'undo':
  5535. void handleUndo();
  5536. return;
  5537. case 'redo':
  5538. void handleRedo();
  5539. return;
  5540. case 'mark':
  5541. toggleToolbarPanel('mark');
  5542. return;
  5543. case 'selectNeighbor':
  5544. void handleSelectNeighborEntity();
  5545. return;
  5546. case 'boxSelect':
  5547. message.info('G6 实验页当前使用 Shift + 拖拽框选');
  5548. return;
  5549. case 'copy':
  5550. copySelectionToClipboard();
  5551. return;
  5552. case 'paste':
  5553. void pasteClipboardToCanvas();
  5554. return;
  5555. case 'delete':
  5556. void handleDeleteSelected();
  5557. return;
  5558. case 'group':
  5559. void handleCreateCombo();
  5560. return;
  5561. case 'ungroup':
  5562. void handleRemoveGroup();
  5563. return;
  5564. case 'newCanvas':
  5565. void handleCreateNewCanvas();
  5566. return;
  5567. case 'forceLayout':
  5568. void handleForceLayout();
  5569. return;
  5570. case 'fullScreen':
  5571. void handleToggleFullscreen();
  5572. return;
  5573. default:
  5574. return;
  5575. }
  5576. };
  5577. const handleZoomIn = async () => {
  5578. const graph = graphRef.value;
  5579. if (!graph) return;
  5580. await graph.zoomBy(1.12, { duration: 180 });
  5581. refreshZoomLabel();
  5582. };
  5583. const handleZoomOut = async () => {
  5584. const graph = graphRef.value;
  5585. if (!graph) return;
  5586. await graph.zoomBy(0.88, { duration: 180 });
  5587. refreshZoomLabel();
  5588. };
  5589. const handleResetGraph = async () => {
  5590. const graph = graphRef.value;
  5591. if (!graph) return;
  5592. graphPerformanceMode.value = false;
  5593. pendingEdgeSourceId.value = '';
  5594. graph.setOptions({ animation: false, autoFit: false } as any);
  5595. applyGraphInteractionOptions(false);
  5596. graph.setNode(createNodeOptions() as any);
  5597. graph.setEdge(createEdgeOptions() as any);
  5598. graph.setCombo(createComboOptions() as any);
  5599. clearGraphLayout(graph);
  5600. graph.setData(createInitialData());
  5601. await graph.render();
  5602. await graph.fitView({ when: 'always' }, false);
  5603. syncSelection();
  5604. clearAnalysisTotalCounts();
  5605. saveActiveCanvasState();
  5606. };
  5607. const getCurrentDateTimeLabel = () => {
  5608. const date = new Date();
  5609. const pad = (value: number) => String(value).padStart(2, '0');
  5610. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(
  5611. date.getMinutes(),
  5612. )}`;
  5613. };
  5614. const normalizeHistoryItem = (item: GraphHistoryItem, index: number): LayoutSnapshot | null => {
  5615. const id = String(item.id || item.filePath || item.path || `history-${index}`);
  5616. const payload = String(item.graphData || item.graphJson || item.content || item.data || '');
  5617. if (!id || !payload) return null;
  5618. return {
  5619. id,
  5620. remoteId: item.id ? String(item.id) : undefined,
  5621. name: String(item.graphHisName || item.name || item.graphName || item.title || `历史记录 ${index + 1}`),
  5622. createdAt: String(item.createDateTime || item.createdAt || item.createTime || item.createDate || ''),
  5623. nodeCount: Number(item.nodeCount || 0),
  5624. edgeCount: Number(item.edgeCount || 0),
  5625. payload,
  5626. filePath: String(item.filePath || item.path || ''),
  5627. };
  5628. };
  5629. const fetchSavedLayouts = async () => {
  5630. historyLoading.value = true;
  5631. try {
  5632. const result = await fetchGraphHistory(routeGraphQuery.value);
  5633. const rawList = Array.isArray(result)
  5634. ? result
  5635. : Array.isArray(result?.list)
  5636. ? result.list
  5637. : Array.isArray(result?.records)
  5638. ? result.records
  5639. : Array.isArray(result?.data)
  5640. ? result.data
  5641. : [];
  5642. savedLayouts.value = rawList
  5643. .map((item: GraphHistoryItem, index: number) => normalizeHistoryItem(item, index))
  5644. .filter((item: LayoutSnapshot | null): item is LayoutSnapshot => Boolean(item))
  5645. .slice(0, MAX_HISTORY_SNAPSHOTS);
  5646. } catch (error) {
  5647. console.error('load g6 graph history failed', error);
  5648. savedLayouts.value = [];
  5649. message.error('加载历史记录失败');
  5650. } finally {
  5651. historyLoading.value = false;
  5652. }
  5653. };
  5654. const handleHistoryVisibleChange = (open: boolean) => {
  5655. if (open) {
  5656. void fetchSavedLayouts();
  5657. }
  5658. };
  5659. const handleOpenSaveLayoutModal = () => {
  5660. saveLayoutError.value = '';
  5661. saveLayoutForm.name = activeGraphCanvas.value?.label || `G6关系图谱 ${getCurrentDateTimeLabel()}`;
  5662. saveLayoutVisible.value = true;
  5663. };
  5664. const handleCloseSaveLayoutModal = () => {
  5665. saveLayoutVisible.value = false;
  5666. saveLayoutError.value = '';
  5667. };
  5668. const handleSaveLayout = async () => {
  5669. const name = saveLayoutForm.name.trim();
  5670. if (!name) {
  5671. saveLayoutError.value = '请输入可视化关系名称';
  5672. return;
  5673. }
  5674. const graph = graphRef.value;
  5675. if (!graph) return;
  5676. saveLayoutLoading.value = true;
  5677. try {
  5678. saveActiveCanvasState();
  5679. const payload = serializeGraph();
  5680. await createGraphHistory({
  5681. ...routeGraphQuery.value,
  5682. graphHisName: name,
  5683. name,
  5684. graphName: name,
  5685. content: payload,
  5686. graphData: payload,
  5687. nodeCount: graphStats.nodes,
  5688. edgeCount: graphStats.edges,
  5689. });
  5690. if (activeGraphCanvas.value) {
  5691. activeGraphCanvas.value.label = name;
  5692. activeGraphCanvas.value.lastSavedAt = getCurrentDateTimeLabel();
  5693. }
  5694. saveLayoutVisible.value = false;
  5695. message.success('已保存 G6 图谱布局');
  5696. if (historyVisible.value) {
  5697. await fetchSavedLayouts();
  5698. }
  5699. } catch (error) {
  5700. console.error('save g6 graph history failed', error);
  5701. message.error('保存图谱布局失败');
  5702. } finally {
  5703. saveLayoutLoading.value = false;
  5704. }
  5705. };
  5706. const handleRestoreSnapshot = async (snapshotId: string) => {
  5707. const snapshot = savedLayouts.value.find((item) => item.id === snapshotId);
  5708. if (!snapshot) return;
  5709. activeHistoryId.value = snapshot.id;
  5710. historyVisible.value = false;
  5711. await restoreGraphSnapshot(snapshot.payload);
  5712. if (activeGraphCanvas.value) {
  5713. activeGraphCanvas.value.label = snapshot.name;
  5714. activeGraphCanvas.value.snapshot = snapshot.payload;
  5715. activeGraphCanvas.value.lastSavedAt = snapshot.createdAt;
  5716. activeGraphCanvas.value.analysisTotalNodeCount = 0;
  5717. activeGraphCanvas.value.analysisTotalEdgeCount = 0;
  5718. }
  5719. restoreAnalysisTotalCountsFromActiveCanvas();
  5720. message.success(`已恢复历史记录:${snapshot.name}`);
  5721. };
  5722. const handleRemoveSnapshot = async (snapshotId: string) => {
  5723. const snapshot = savedLayouts.value.find((item) => item.id === snapshotId);
  5724. if (!snapshot) return;
  5725. try {
  5726. if (snapshot.remoteId) {
  5727. await deleteGraphHistory({ id: snapshot.remoteId });
  5728. }
  5729. savedLayouts.value = savedLayouts.value.filter((item) => item.id !== snapshotId);
  5730. if (activeHistoryId.value === snapshotId) activeHistoryId.value = '';
  5731. message.success('已删除历史记录');
  5732. } catch (error) {
  5733. console.error('delete g6 graph history failed', error);
  5734. message.error('删除历史记录失败');
  5735. }
  5736. };
  5737. const ensureCanvasTabs = () => {
  5738. if (graphCanvasTabs.value.length) return;
  5739. graphCanvasTabs.value = [
  5740. {
  5741. id: `canvas-${Date.now()}`,
  5742. label: '画布 1',
  5743. snapshot: serializeGraph(),
  5744. zoom: 1,
  5745. lastSavedAt: '',
  5746. analysisTotalNodeCount: 0,
  5747. analysisTotalEdgeCount: 0,
  5748. dirty: false,
  5749. },
  5750. ];
  5751. activeGraphCanvasIndex.value = 0;
  5752. restoreAnalysisTotalCountsFromActiveCanvas();
  5753. };
  5754. const isGraphCanvasDirty = (index: number) => {
  5755. const tab = graphCanvasTabs.value[index];
  5756. if (!tab || index !== activeGraphCanvasIndex.value) return false;
  5757. return tab.dirty;
  5758. };
  5759. const switchGraphCanvas = async (index: number) => {
  5760. if (index === activeGraphCanvasIndex.value) return;
  5761. saveActiveCanvasState();
  5762. activeGraphCanvasIndex.value = index;
  5763. undoStack.value = [];
  5764. redoStack.value = [];
  5765. await restoreGraphSnapshot(graphCanvasTabs.value[index]?.snapshot || EMPTY_GRAPH_SNAPSHOT);
  5766. };
  5767. const handleAddGraphCanvas = async () => {
  5768. saveActiveCanvasState();
  5769. graphCanvasTabs.value.push({
  5770. id: `canvas-${Date.now()}`,
  5771. label: `画布 ${graphCanvasTabs.value.length + 1}`,
  5772. snapshot: EMPTY_GRAPH_SNAPSHOT,
  5773. zoom: 1,
  5774. lastSavedAt: '',
  5775. analysisTotalNodeCount: 0,
  5776. analysisTotalEdgeCount: 0,
  5777. dirty: false,
  5778. });
  5779. activeGraphCanvasIndex.value = graphCanvasTabs.value.length - 1;
  5780. undoStack.value = [];
  5781. redoStack.value = [];
  5782. await restoreGraphSnapshot(EMPTY_GRAPH_SNAPSHOT, false);
  5783. };
  5784. const executeRemoveGraphCanvas = async (index: number) => {
  5785. if (graphCanvasTabs.value.length <= 1) {
  5786. message.info('至少保留一个画布');
  5787. return;
  5788. }
  5789. if (index === activeGraphCanvasIndex.value) {
  5790. saveActiveCanvasState();
  5791. }
  5792. graphCanvasTabs.value.splice(index, 1);
  5793. activeGraphCanvasIndex.value = Math.min(activeGraphCanvasIndex.value, graphCanvasTabs.value.length - 1);
  5794. await restoreGraphSnapshot(graphCanvasTabs.value[activeGraphCanvasIndex.value]?.snapshot || EMPTY_GRAPH_SNAPSHOT);
  5795. };
  5796. const handleRemoveGraphCanvas = (index: number) => {
  5797. const targetCanvas = graphCanvasTabs.value[index];
  5798. if (!targetCanvas) return;
  5799. Modal.confirm({
  5800. title: '关闭画布',
  5801. content: `确认关闭“${targetCanvas.label}”吗?关闭后将删除该画布中的图谱内容。`,
  5802. centered: true,
  5803. okText: '确认关闭',
  5804. cancelText: '取消',
  5805. onOk: () => executeRemoveGraphCanvas(index),
  5806. });
  5807. };
  5808. const handleUndo = async () => {
  5809. const previousSnapshot = undoStack.value[undoStack.value.length - 1];
  5810. if (!previousSnapshot) return;
  5811. redoStack.value = [...redoStack.value, serializeGraph()].slice(-MAX_UNDO_STACK);
  5812. undoStack.value = undoStack.value.slice(0, -1);
  5813. await restoreGraphSnapshot(previousSnapshot, false);
  5814. saveActiveCanvasState();
  5815. };
  5816. const handleRedo = async () => {
  5817. const nextSnapshot = redoStack.value[redoStack.value.length - 1];
  5818. if (!nextSnapshot) return;
  5819. undoStack.value = [...undoStack.value, serializeGraph()].slice(-MAX_UNDO_STACK);
  5820. redoStack.value = redoStack.value.slice(0, -1);
  5821. await restoreGraphSnapshot(nextSnapshot, false);
  5822. saveActiveCanvasState();
  5823. };
  5824. const handleClearCanvas = () => {
  5825. if (!hasGraphData.value) {
  5826. message.info('当前画布已经是空的');
  5827. return;
  5828. }
  5829. Modal.confirm({
  5830. title: '清空当前图谱',
  5831. content: '清空后将移除当前画布中的节点和关系,可通过撤销恢复。',
  5832. centered: true,
  5833. okText: '确认清空',
  5834. cancelText: '取消',
  5835. onOk: async () => {
  5836. await withGraphMutation(async () => {
  5837. graphRef.value?.setData({ nodes: [], edges: [], combos: [] });
  5838. await graphRef.value?.render();
  5839. });
  5840. clearAnalysisTotalCounts();
  5841. saveActiveCanvasState();
  5842. clearLocalSelection();
  5843. },
  5844. });
  5845. };
  5846. const handleDocumentFullscreenChange = () => {
  5847. isFullscreen.value = Boolean(document.fullscreenElement);
  5848. };
  5849. onMounted(async () => {
  5850. await nextTick();
  5851. await createGraph();
  5852. ensureCanvasTabs();
  5853. document.addEventListener('fullscreenchange', handleDocumentFullscreenChange);
  5854. const routePayload = buildRouteAnalysisPayload();
  5855. if (routePayload) {
  5856. void handleStartAnalysis(routePayload);
  5857. }
  5858. });
  5859. onBeforeUnmount(() => {
  5860. terminateAnalysisLayoutWorker();
  5861. document.removeEventListener('fullscreenchange', handleDocumentFullscreenChange);
  5862. graphRef.value?.destroy();
  5863. graphRef.value = null;
  5864. });
  5865. </script>
  5866. <style lang="less">
  5867. .visual-relation-page {
  5868. height: 100%;
  5869. min-height: 0;
  5870. width: calc(100% + 24px);
  5871. margin: 0 -12px;
  5872. display: flex;
  5873. flex-direction: column;
  5874. padding: 0;
  5875. gap: 0;
  5876. background: #edf3fb;
  5877. box-sizing: border-box;
  5878. overflow: hidden;
  5879. .page-header {
  5880. display: flex;
  5881. align-items: center;
  5882. justify-content: space-between;
  5883. gap: 12px;
  5884. padding: 10px 16px;
  5885. border-radius: 0;
  5886. border: 1px solid rgba(148, 163, 184, 0.24);
  5887. background: rgba(255, 255, 255, 0.92);
  5888. box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
  5889. backdrop-filter: blur(10px);
  5890. }
  5891. .page-title-wrap {
  5892. min-width: 0;
  5893. }
  5894. .page-title {
  5895. font-size: 18px;
  5896. font-weight: 700;
  5897. color: #172554;
  5898. line-height: 24px;
  5899. }
  5900. .page-subtitle {
  5901. margin-top: 2px;
  5902. font-size: 12px;
  5903. color: #64748b;
  5904. line-height: 18px;
  5905. }
  5906. .page-meta {
  5907. display: flex;
  5908. align-items: center;
  5909. gap: 8px;
  5910. flex-wrap: wrap;
  5911. justify-content: center;
  5912. padding: 0 8px;
  5913. color: #475569;
  5914. font-size: 12px;
  5915. }
  5916. .page-meta__text {
  5917. white-space: nowrap;
  5918. }
  5919. .page-actions {
  5920. display: flex;
  5921. align-items: center;
  5922. gap: 8px;
  5923. flex-shrink: 0;
  5924. }
  5925. .canvas-tabs-bar {
  5926. display: flex;
  5927. align-items: center;
  5928. justify-content: space-between;
  5929. gap: 12px;
  5930. min-width: 0;
  5931. padding: 2px 16px 0;
  5932. }
  5933. .canvas-tabs {
  5934. display: flex;
  5935. align-items: center;
  5936. gap: 6px;
  5937. flex: 1;
  5938. min-width: 0;
  5939. overflow-x: auto;
  5940. padding: 0 2px 1px;
  5941. }
  5942. .canvas-tab {
  5943. position: relative;
  5944. display: inline-flex;
  5945. align-items: center;
  5946. gap: 5px;
  5947. flex: 0 0 auto;
  5948. max-width: 220px;
  5949. height: 30px;
  5950. padding: 0 9px 0 11px;
  5951. border-radius: 7px;
  5952. border: 1px solid #e2e8f0;
  5953. background: #ffffff;
  5954. box-shadow: none;
  5955. cursor: pointer;
  5956. transition:
  5957. border-color 0.18s ease,
  5958. background-color 0.18s ease,
  5959. color 0.18s ease;
  5960. }
  5961. .canvas-tab:hover {
  5962. border-color: rgba(59, 130, 246, 0.42);
  5963. }
  5964. .canvas-tab.is-active {
  5965. border-color: #4d6bfe;
  5966. background: #4d6bfe;
  5967. box-shadow: none;
  5968. }
  5969. .canvas-tab__label {
  5970. min-width: 0;
  5971. overflow: hidden;
  5972. text-overflow: ellipsis;
  5973. white-space: nowrap;
  5974. color: #334155;
  5975. font-size: 12px;
  5976. font-weight: 600;
  5977. line-height: 1;
  5978. transition: color 0.18s ease;
  5979. }
  5980. .canvas-tab__dirty {
  5981. width: 6px;
  5982. height: 6px;
  5983. flex-shrink: 0;
  5984. border-radius: 999px;
  5985. background: #f59e0b;
  5986. box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.16);
  5987. }
  5988. .canvas-tab__close {
  5989. display: inline-flex;
  5990. align-items: center;
  5991. justify-content: center;
  5992. width: 16px;
  5993. height: 16px;
  5994. flex-shrink: 0;
  5995. padding: 0;
  5996. border: 0;
  5997. border-radius: 5px;
  5998. background: transparent;
  5999. color: #94a3b8;
  6000. cursor: pointer;
  6001. transition:
  6002. background-color 0.18s ease,
  6003. color 0.18s ease;
  6004. }
  6005. .canvas-tab__close :deep(.anticon) {
  6006. font-size: 12px;
  6007. line-height: 1;
  6008. }
  6009. .canvas-tab__close:hover {
  6010. background: rgba(15, 23, 42, 0.06);
  6011. color: #334155;
  6012. }
  6013. .canvas-tab.is-active .canvas-tab__label {
  6014. color: #ffffff;
  6015. }
  6016. .canvas-tab.is-active .canvas-tab__close {
  6017. color: rgba(255, 255, 255, 0.88);
  6018. }
  6019. .canvas-tab.is-active .canvas-tab__close:hover {
  6020. background: rgba(255, 255, 255, 0.14);
  6021. color: #ffffff;
  6022. }
  6023. .canvas-tab.is-active .canvas-tab__dirty {
  6024. background: #ffffff;
  6025. box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.24);
  6026. }
  6027. .canvas-tab--add {
  6028. justify-content: center;
  6029. width: 30px;
  6030. max-width: 30px;
  6031. padding: 0;
  6032. color: #2563eb;
  6033. }
  6034. .canvas-tabs::-webkit-scrollbar {
  6035. height: 6px;
  6036. }
  6037. .canvas-tabs::-webkit-scrollbar-thumb {
  6038. border-radius: 999px;
  6039. background: rgba(148, 163, 184, 0.45);
  6040. }
  6041. .relation-stage {
  6042. flex: 1;
  6043. min-height: 0;
  6044. overflow: hidden;
  6045. }
  6046. .relation-stage__canvas {
  6047. position: relative;
  6048. height: 100%;
  6049. min-height: 0;
  6050. overflow: hidden;
  6051. border-radius: 0;
  6052. border: 1px solid rgba(148, 163, 184, 0.24);
  6053. background: #f4f7fb;
  6054. box-shadow:
  6055. inset 0 1px 0 rgba(255, 255, 255, 0.75),
  6056. 0 18px 36px rgba(15, 23, 42, 0.06);
  6057. }
  6058. .relation-stage__graph {
  6059. position: relative;
  6060. z-index: 1;
  6061. width: 100%;
  6062. height: 100%;
  6063. contain: layout;
  6064. :deep(.g6-canvas),
  6065. :deep(canvas) {
  6066. transform: translateZ(0);
  6067. backface-visibility: hidden;
  6068. }
  6069. }
  6070. .relation-stage__layout-loading {
  6071. position: absolute;
  6072. inset: 0;
  6073. z-index: 60;
  6074. display: flex;
  6075. align-items: center;
  6076. justify-content: center;
  6077. padding: 24px;
  6078. background:
  6079. linear-gradient(rgba(244, 247, 251, 0.8), rgba(244, 247, 251, 0.8)),
  6080. repeating-linear-gradient(
  6081. -45deg,
  6082. rgba(148, 163, 184, 0.08) 0,
  6083. rgba(148, 163, 184, 0.08) 10px,
  6084. rgba(255, 255, 255, 0.08) 10px,
  6085. rgba(255, 255, 255, 0.08) 20px
  6086. );
  6087. backdrop-filter: blur(2px);
  6088. }
  6089. .relation-stage__layout-loading-card {
  6090. display: flex;
  6091. flex-direction: column;
  6092. align-items: center;
  6093. gap: 10px;
  6094. min-width: 220px;
  6095. max-width: min(360px, calc(100% - 32px));
  6096. padding: 28px 32px;
  6097. border: 1px solid rgba(226, 232, 240, 0.9);
  6098. border-radius: 16px;
  6099. background: rgba(255, 255, 255, 0.96);
  6100. box-shadow:
  6101. 0 18px 36px rgba(15, 23, 42, 0.1),
  6102. 0 2px 8px rgba(15, 23, 42, 0.05);
  6103. text-align: center;
  6104. }
  6105. .relation-stage__layout-loading-spinner {
  6106. width: 34px;
  6107. height: 34px;
  6108. border: 3px solid rgba(37, 99, 235, 0.16);
  6109. border-top-color: #2563eb;
  6110. border-radius: 999px;
  6111. animation: relation-stage-layout-spin 0.9s linear infinite;
  6112. }
  6113. .relation-stage__layout-loading-title {
  6114. font-size: 18px;
  6115. font-weight: 700;
  6116. line-height: 1.2;
  6117. color: #111827;
  6118. }
  6119. .relation-stage__layout-loading-desc {
  6120. font-size: 13px;
  6121. line-height: 1.6;
  6122. color: #6b7280;
  6123. }
  6124. .entity-palette {
  6125. position: absolute;
  6126. top: 20px;
  6127. left: 20px;
  6128. z-index: 12;
  6129. width: 292px;
  6130. padding: 14px 18px 12px;
  6131. border-radius: 22px;
  6132. border: 1px solid rgba(148, 163, 184, 0.16);
  6133. background: rgba(255, 255, 255, 0.98);
  6134. box-shadow:
  6135. 0 18px 36px rgba(15, 23, 42, 0.1),
  6136. inset 0 1px 0 rgba(255, 255, 255, 0.82);
  6137. backdrop-filter: blur(12px);
  6138. overflow: hidden;
  6139. &::before {
  6140. content: none;
  6141. }
  6142. &.is-collapsed {
  6143. padding-bottom: 18px;
  6144. }
  6145. }
  6146. .entity-palette__header {
  6147. position: relative;
  6148. display: flex;
  6149. align-items: center;
  6150. justify-content: space-between;
  6151. gap: 12px;
  6152. }
  6153. .entity-palette__title {
  6154. position: relative;
  6155. font-size: 18px;
  6156. font-weight: 700;
  6157. color: #0f172a;
  6158. letter-spacing: 0.01em;
  6159. }
  6160. .entity-palette__toggle {
  6161. position: relative;
  6162. width: 30px;
  6163. min-width: 30px;
  6164. height: 30px;
  6165. padding: 0;
  6166. border-radius: 10px;
  6167. color: #5b6b82;
  6168. background: rgba(255, 255, 255, 0.72);
  6169. border: 1px solid rgba(148, 163, 184, 0.2);
  6170. box-shadow: 0 8px 16px rgba(15, 23, 42, 0.06);
  6171. &:hover,
  6172. &:focus {
  6173. color: #2563eb;
  6174. background: rgba(239, 246, 255, 0.96);
  6175. border-color: rgba(96, 165, 250, 0.36);
  6176. }
  6177. }
  6178. .entity-palette__desc {
  6179. position: relative;
  6180. margin-top: 4px;
  6181. max-width: 230px;
  6182. font-size: 12px;
  6183. line-height: 1.6;
  6184. color: #64748b;
  6185. }
  6186. .entity-grid {
  6187. position: relative;
  6188. display: grid;
  6189. grid-template-columns: repeat(4, minmax(0, 1fr));
  6190. gap: 10px 14px;
  6191. margin-top: 8px;
  6192. padding: 2px 6px 0;
  6193. border: 0;
  6194. border-radius: 0;
  6195. background: transparent;
  6196. box-shadow: none;
  6197. }
  6198. .entity-grid__item {
  6199. position: relative;
  6200. box-sizing: border-box;
  6201. display: flex;
  6202. flex-direction: column;
  6203. align-items: center;
  6204. justify-content: flex-start;
  6205. gap: 10px;
  6206. min-height: 108px;
  6207. padding: 12px 8px 10px;
  6208. border: none;
  6209. border-radius: 0;
  6210. background: transparent;
  6211. box-shadow: none;
  6212. overflow: hidden;
  6213. cursor: grab;
  6214. appearance: none;
  6215. transition:
  6216. transform 0.2s ease,
  6217. background-color 0.2s ease;
  6218. &::before {
  6219. content: none;
  6220. }
  6221. }
  6222. .entity-grid__item:hover {
  6223. transform: translateY(-3px);
  6224. background: rgba(255, 255, 255, 0.72);
  6225. }
  6226. .entity-grid__item:active {
  6227. cursor: grabbing;
  6228. transform: translateY(0) scale(0.98);
  6229. }
  6230. .entity-grid__item:focus-visible {
  6231. outline: 2px solid rgba(59, 130, 246, 0.35);
  6232. outline-offset: 2px;
  6233. }
  6234. .entity-grid__icon {
  6235. position: relative;
  6236. display: flex;
  6237. align-items: center;
  6238. justify-content: center;
  6239. width: 38px;
  6240. height: 38px;
  6241. border-radius: 12px;
  6242. background: var(--node-accent-color, #667085);
  6243. box-shadow:
  6244. 0 10px 18px rgba(15, 23, 42, 0.12),
  6245. inset 0 1px 0 rgba(255, 255, 255, 0.2);
  6246. &::after {
  6247. content: '';
  6248. position: absolute;
  6249. inset: -1px;
  6250. border-radius: inherit;
  6251. border: 1px solid rgba(255, 255, 255, 0.22);
  6252. pointer-events: none;
  6253. }
  6254. }
  6255. .entity-grid__icon img {
  6256. position: relative;
  6257. z-index: 1;
  6258. width: 32px;
  6259. height: 32px;
  6260. object-fit: contain;
  6261. }
  6262. .entity-grid__label {
  6263. display: flex;
  6264. align-items: center;
  6265. justify-content: center;
  6266. width: 100%;
  6267. min-height: 34px;
  6268. padding: 2px 2px 4px;
  6269. font-size: 15px;
  6270. font-weight: 700;
  6271. color: #16202a;
  6272. line-height: 1.35;
  6273. text-align: center;
  6274. letter-spacing: 0;
  6275. white-space: nowrap;
  6276. word-break: keep-all;
  6277. overflow: visible;
  6278. text-overflow: ellipsis;
  6279. }
  6280. .relation-toggle {
  6281. display: flex;
  6282. gap: 10px;
  6283. margin-top: 18px;
  6284. padding: 6px;
  6285. border-radius: 14px;
  6286. background: #f1f5f9;
  6287. }
  6288. .relation-toggle__item {
  6289. flex: 1;
  6290. height: 36px;
  6291. border: none;
  6292. border-radius: 10px;
  6293. background: transparent;
  6294. color: #475569;
  6295. font-size: 13px;
  6296. font-weight: 600;
  6297. cursor: pointer;
  6298. transition: all 0.2s ease;
  6299. }
  6300. .relation-toggle__item.is-active {
  6301. color: #1d4ed8;
  6302. background: #ffffff;
  6303. box-shadow: 0 8px 18px rgba(59, 130, 246, 0.14);
  6304. }
  6305. .entity-palette__action {
  6306. margin-top: 8px;
  6307. height: 42px;
  6308. border-radius: 14px;
  6309. font-weight: 700;
  6310. box-shadow: 0 14px 28px rgba(49, 94, 251, 0.24);
  6311. &:hover {
  6312. box-shadow: 0 16px 30px rgba(49, 94, 251, 0.28);
  6313. }
  6314. }
  6315. .control-tools-box {
  6316. position: absolute;
  6317. top: 24px;
  6318. right: 24px;
  6319. z-index: 40;
  6320. display: flex;
  6321. flex-direction: column;
  6322. align-items: flex-end;
  6323. gap: 12px;
  6324. max-width: calc(100% - 48px);
  6325. pointer-events: none;
  6326. }
  6327. .custom-atlas-tools-container {
  6328. position: relative;
  6329. overflow: visible;
  6330. border-radius: 10px;
  6331. pointer-events: auto;
  6332. max-width: min(760px, calc(100vw - 360px));
  6333. ul {
  6334. display: flex;
  6335. align-items: center;
  6336. justify-content: flex-end;
  6337. flex-wrap: wrap;
  6338. margin: 0;
  6339. padding: 0;
  6340. list-style: none;
  6341. }
  6342. }
  6343. .tool-item {
  6344. box-sizing: border-box;
  6345. width: 40px;
  6346. height: 44px;
  6347. display: inline-flex;
  6348. align-items: center;
  6349. justify-content: center;
  6350. background: rgba(255, 255, 255, 0.96);
  6351. border: 1px solid rgba(148, 163, 184, 0.18);
  6352. box-shadow: 0 10px 22px rgba(15, 23, 42, 0.08);
  6353. cursor: pointer;
  6354. transition:
  6355. background-color 0.2s ease,
  6356. box-shadow 0.2s ease,
  6357. transform 0.2s ease;
  6358. &:first-child {
  6359. border-top-left-radius: 8px;
  6360. border-bottom-left-radius: 8px;
  6361. }
  6362. &:last-child {
  6363. border-top-right-radius: 8px;
  6364. border-bottom-right-radius: 8px;
  6365. }
  6366. &:hover {
  6367. background-color: #e8ebfb;
  6368. box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
  6369. transform: translateY(-1px);
  6370. .icon-style {
  6371. color: #2563eb;
  6372. }
  6373. }
  6374. }
  6375. .tool-item.is-active {
  6376. background-color: #e8ebfb;
  6377. box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
  6378. .icon-style {
  6379. color: #2563eb;
  6380. }
  6381. }
  6382. .tool-item.disabled-tool-item {
  6383. cursor: not-allowed;
  6384. background: rgba(255, 255, 255, 0.88);
  6385. box-shadow: none;
  6386. .icon-style {
  6387. color: #94a3b8;
  6388. }
  6389. }
  6390. .tool-item.disabled-tool-item:hover {
  6391. transform: none;
  6392. background: rgba(255, 255, 255, 0.88);
  6393. box-shadow: none;
  6394. }
  6395. .tool-item-icon {
  6396. display: inline-flex;
  6397. align-items: center;
  6398. justify-content: center;
  6399. width: 24px;
  6400. height: 24px;
  6401. }
  6402. .tool-item-icon--running {
  6403. animation: tool-icon-rotate 2s linear infinite;
  6404. }
  6405. .gap-item {
  6406. margin-right: 16px;
  6407. border-top-right-radius: 8px;
  6408. border-bottom-right-radius: 8px;
  6409. & + .tool-item {
  6410. border-top-left-radius: 8px;
  6411. border-bottom-left-radius: 8px;
  6412. }
  6413. }
  6414. .icon-style {
  6415. font-size: 20px;
  6416. color: #303133;
  6417. transition: color 0.2s ease;
  6418. }
  6419. @keyframes tool-icon-rotate {
  6420. from {
  6421. transform: rotate(0deg);
  6422. }
  6423. to {
  6424. transform: rotate(360deg);
  6425. }
  6426. }
  6427. @keyframes relation-stage-layout-spin {
  6428. from {
  6429. transform: rotate(0deg);
  6430. }
  6431. to {
  6432. transform: rotate(360deg);
  6433. }
  6434. }
  6435. .tool-panel {
  6436. width: min(360px, calc(100vw - 48px));
  6437. padding: 14px;
  6438. border-radius: 16px;
  6439. border: 1px solid rgba(148, 163, 184, 0.2);
  6440. background: rgba(255, 255, 255, 0.96);
  6441. box-shadow: 0 16px 34px rgba(15, 23, 42, 0.12);
  6442. pointer-events: auto;
  6443. }
  6444. .tool-panel :deep(.ant-input),
  6445. .tool-panel :deep(.ant-select),
  6446. .tool-panel :deep(.ant-input-number) {
  6447. width: 100%;
  6448. }
  6449. .tool-panel__row {
  6450. width: 100%;
  6451. display: block;
  6452. }
  6453. .tool-panel__entity-select {
  6454. display: block;
  6455. width: 100%;
  6456. min-width: 0;
  6457. }
  6458. .tool-panel__entity-select :deep(.ant-select-selector) {
  6459. min-height: 40px;
  6460. padding-inline: 12px;
  6461. }
  6462. .tool-panel__entity-select :deep(.ant-select-selection-wrap),
  6463. .tool-panel__entity-select :deep(.ant-select-selection-overflow) {
  6464. width: 100%;
  6465. min-width: 0;
  6466. }
  6467. .tool-panel__entity-select :deep(.ant-select-selection-item),
  6468. .tool-panel__entity-select :deep(.ant-select-selection-placeholder) {
  6469. max-width: 100%;
  6470. }
  6471. .tool-panel__header {
  6472. display: flex;
  6473. align-items: center;
  6474. justify-content: space-between;
  6475. gap: 12px;
  6476. margin-bottom: 12px;
  6477. }
  6478. .tool-panel__title {
  6479. color: #1e293b;
  6480. font-size: 14px;
  6481. font-weight: 700;
  6482. }
  6483. .tool-panel__close {
  6484. padding: 0 4px;
  6485. color: #64748b;
  6486. }
  6487. .tool-panel__row + .tool-panel__row,
  6488. .tool-panel__row + .tool-panel__switch-row,
  6489. .tool-panel__switch-row + .tool-panel__row,
  6490. .tool-panel__switch-row + .tool-panel__switch-row {
  6491. margin-top: 10px;
  6492. }
  6493. .tool-panel__switch-row {
  6494. display: flex;
  6495. flex-wrap: wrap;
  6496. gap: 8px;
  6497. }
  6498. .tool-panel__range-row {
  6499. display: flex;
  6500. align-items: center;
  6501. gap: 8px;
  6502. }
  6503. .tool-panel__range-separator {
  6504. flex: 0 0 auto;
  6505. color: #64748b;
  6506. font-size: 12px;
  6507. white-space: nowrap;
  6508. }
  6509. .tool-panel__switch {
  6510. border: 1px solid rgba(148, 163, 184, 0.3);
  6511. background: #f8fafc;
  6512. color: #475569;
  6513. border-radius: 10px;
  6514. padding: 6px 12px;
  6515. cursor: pointer;
  6516. transition:
  6517. background-color 0.18s ease,
  6518. border-color 0.18s ease,
  6519. color 0.18s ease;
  6520. }
  6521. .tool-panel__switch--compact {
  6522. min-width: 48px;
  6523. padding-inline: 0;
  6524. }
  6525. .tool-panel__switch.is-active {
  6526. color: #1d4ed8;
  6527. border-color: rgba(59, 130, 246, 0.42);
  6528. background: rgba(59, 130, 246, 0.12);
  6529. }
  6530. .tool-panel__number {
  6531. flex: 1;
  6532. min-width: 110px;
  6533. }
  6534. .tool-panel__footer {
  6535. margin-top: 12px;
  6536. }
  6537. .tool-panel__footer--find {
  6538. display: flex;
  6539. flex-direction: column;
  6540. gap: 8px;
  6541. }
  6542. .tool-panel__result,
  6543. .tool-panel__tip {
  6544. color: #64748b;
  6545. font-size: 12px;
  6546. margin-bottom: 10px;
  6547. }
  6548. .tool-panel__footer--find .tool-panel__result {
  6549. margin-bottom: 0;
  6550. white-space: nowrap;
  6551. }
  6552. .tool-panel__actions {
  6553. display: flex;
  6554. flex-wrap: wrap;
  6555. justify-content: flex-end;
  6556. gap: 8px;
  6557. }
  6558. .tool-panel__actions--find {
  6559. flex-wrap: nowrap;
  6560. justify-content: flex-start;
  6561. gap: 8px;
  6562. }
  6563. .tool-panel__actions--find :deep(.ant-btn) {
  6564. height: 32px;
  6565. padding: 0 14px;
  6566. border-radius: 8px;
  6567. white-space: nowrap;
  6568. flex: 0 0 auto;
  6569. }
  6570. .tool-panel__swatches {
  6571. display: flex;
  6572. flex-wrap: wrap;
  6573. gap: 12px;
  6574. padding: 2px 2px 6px;
  6575. }
  6576. .tool-panel__swatch {
  6577. width: 28px;
  6578. height: 28px;
  6579. border-radius: 999px;
  6580. border: 2px solid rgba(255, 255, 255, 0.82);
  6581. box-shadow: 0 8px 18px rgba(15, 23, 42, 0.12);
  6582. cursor: pointer;
  6583. }
  6584. .canvas-toolbar {
  6585. position: relative;
  6586. z-index: 1;
  6587. display: flex;
  6588. align-items: center;
  6589. gap: 4px;
  6590. padding: 8px;
  6591. border-radius: 18px;
  6592. border: 1px solid rgba(148, 163, 184, 0.2);
  6593. background: rgba(255, 255, 255, 0.97);
  6594. box-shadow: 0 14px 30px rgba(15, 23, 42, 0.08);
  6595. pointer-events: auto;
  6596. }
  6597. .canvas-toolbar__button {
  6598. width: 38px;
  6599. height: 38px;
  6600. border-radius: 12px;
  6601. color: #334155;
  6602. }
  6603. .canvas-toolbar__button:hover:not(:disabled) {
  6604. color: #2563eb;
  6605. background: rgba(59, 130, 246, 0.1);
  6606. }
  6607. .canvas-toolbar__divider {
  6608. width: 1px;
  6609. height: 18px;
  6610. background: rgba(148, 163, 184, 0.35);
  6611. margin: 0 2px;
  6612. }
  6613. .box-select-rect {
  6614. position: absolute;
  6615. z-index: 10;
  6616. border: 1px solid rgba(37, 99, 235, 0.9);
  6617. border-radius: 12px;
  6618. background: rgba(59, 130, 246, 0.12);
  6619. box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.58);
  6620. pointer-events: none;
  6621. }
  6622. .graph-context-menu {
  6623. position: fixed;
  6624. z-index: 30;
  6625. display: flex;
  6626. flex-direction: column;
  6627. min-width: 168px;
  6628. padding: 4px;
  6629. border: 1px solid rgba(148, 163, 184, 0.24);
  6630. border-radius: 10px;
  6631. background: rgba(255, 255, 255, 0.98);
  6632. box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
  6633. }
  6634. .graph-context-menu__item {
  6635. border: 0;
  6636. background: transparent;
  6637. color: #334155;
  6638. text-align: left;
  6639. padding: 9px 12px;
  6640. border-radius: 8px;
  6641. cursor: pointer;
  6642. font-size: 13px;
  6643. transition:
  6644. background-color 0.18s ease,
  6645. color 0.18s ease;
  6646. }
  6647. .graph-context-menu__item:hover:not(:disabled) {
  6648. background: #eff6ff;
  6649. color: #1d4ed8;
  6650. }
  6651. .graph-context-menu__item:disabled {
  6652. color: #94a3b8;
  6653. cursor: not-allowed;
  6654. }
  6655. .graph-context-menu__divider {
  6656. height: 1px;
  6657. margin: 4px 8px;
  6658. background: rgba(148, 163, 184, 0.24);
  6659. }
  6660. .graph-context-menu__item.is-danger:hover:not(:disabled) {
  6661. background: #fff1f2;
  6662. color: #dc2626;
  6663. }
  6664. .canvas-empty {
  6665. position: absolute;
  6666. inset: 50% auto auto 50%;
  6667. z-index: 6;
  6668. display: flex;
  6669. flex-direction: column;
  6670. align-items: center;
  6671. gap: 10px;
  6672. width: min(420px, calc(100% - 80px));
  6673. transform: translate(-50%, -50%);
  6674. text-align: center;
  6675. pointer-events: none;
  6676. }
  6677. .canvas-empty__icon {
  6678. position: relative;
  6679. width: 130px;
  6680. height: 96px;
  6681. }
  6682. .canvas-empty__icon .node {
  6683. position: absolute;
  6684. display: block;
  6685. border-radius: 999px;
  6686. background: linear-gradient(180deg, #8b5cf6 0%, #3b82f6 100%);
  6687. box-shadow: 0 14px 24px rgba(99, 102, 241, 0.24);
  6688. }
  6689. .canvas-empty__icon .node-main {
  6690. width: 26px;
  6691. height: 26px;
  6692. left: 52px;
  6693. top: 34px;
  6694. }
  6695. .canvas-empty__icon .node-branch {
  6696. width: 18px;
  6697. height: 18px;
  6698. background: linear-gradient(180deg, #38bdf8 0%, #0ea5e9 100%);
  6699. }
  6700. .canvas-empty__icon .node-branch::before {
  6701. content: '';
  6702. position: absolute;
  6703. inset: 50% auto auto 50%;
  6704. width: 38px;
  6705. height: 2px;
  6706. transform-origin: left center;
  6707. background: rgba(96, 165, 250, 0.5);
  6708. }
  6709. .canvas-empty__icon .node-branch--left {
  6710. left: 0;
  6711. top: 40px;
  6712. }
  6713. .canvas-empty__icon .node-branch--left::before {
  6714. transform: rotate(0deg);
  6715. }
  6716. .canvas-empty__icon .node-branch--right {
  6717. right: 0;
  6718. top: 40px;
  6719. }
  6720. .canvas-empty__icon .node-branch--right::before {
  6721. width: 36px;
  6722. left: -2px;
  6723. transform-origin: right center;
  6724. transform: rotate(180deg);
  6725. }
  6726. .canvas-empty__icon .node-branch--top {
  6727. left: 56px;
  6728. top: 0;
  6729. }
  6730. .canvas-empty__icon .node-branch--top::before {
  6731. width: 34px;
  6732. left: 50%;
  6733. top: calc(100% + 8px);
  6734. transform-origin: center top;
  6735. transform: translateX(-50%) rotate(90deg);
  6736. }
  6737. .canvas-empty__title {
  6738. font-size: 20px;
  6739. font-weight: 700;
  6740. color: #0f172a;
  6741. }
  6742. .canvas-empty__desc {
  6743. max-width: 420px;
  6744. font-size: 13px;
  6745. line-height: 1.7;
  6746. color: #64748b;
  6747. }
  6748. .canvas-status {
  6749. position: absolute;
  6750. right: 24px;
  6751. bottom: 20px;
  6752. z-index: 8;
  6753. display: inline-flex;
  6754. flex-wrap: wrap;
  6755. justify-content: flex-end;
  6756. gap: 10px;
  6757. max-width: calc(100% - 48px);
  6758. }
  6759. .canvas-status__item {
  6760. padding: 8px 12px;
  6761. border-radius: 999px;
  6762. background: rgba(15, 23, 42, 0.86);
  6763. color: rgba(255, 255, 255, 0.88);
  6764. font-size: 12px;
  6765. line-height: 1;
  6766. }
  6767. .history-loading {
  6768. padding: 42px 0;
  6769. text-align: center;
  6770. color: #64748b;
  6771. font-size: 13px;
  6772. }
  6773. }
  6774. .graph-history-popover {
  6775. .ant-popover-inner {
  6776. padding: 0;
  6777. border-radius: 0;
  6778. box-shadow: 10px 0 12px rgba(173, 174, 177, 0.24);
  6779. }
  6780. .ant-popover-inner-content {
  6781. padding: 0;
  6782. }
  6783. }
  6784. .graph-history-action-popover {
  6785. .ant-popover-inner {
  6786. padding: 0;
  6787. border-radius: 4px;
  6788. box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
  6789. }
  6790. .ant-popover-inner-content {
  6791. padding: 0;
  6792. }
  6793. }
  6794. .history-panel {
  6795. box-sizing: border-box;
  6796. width: 367px;
  6797. height: 426px;
  6798. padding: 16px;
  6799. background: #fff;
  6800. }
  6801. .history-table {
  6802. height: 100%;
  6803. min-height: 0;
  6804. border-bottom: 1px solid #eef2f7;
  6805. overflow: hidden;
  6806. }
  6807. .history-table__head,
  6808. .history-table__row {
  6809. display: grid;
  6810. grid-template-columns: minmax(0, 1.08fr) minmax(0, 1fr) 60px;
  6811. align-items: center;
  6812. }
  6813. .history-table__head {
  6814. height: 32px;
  6815. color: #334155;
  6816. font-size: 14px;
  6817. border-bottom: 1px solid #edf1f7;
  6818. }
  6819. .history-table__body {
  6820. max-height: calc(100% - 32px);
  6821. overflow: auto;
  6822. }
  6823. .history-table__row {
  6824. height: 44px;
  6825. color: #1f2937;
  6826. cursor: pointer;
  6827. transition: background-color 0.16s ease;
  6828. &:hover,
  6829. &.is-active {
  6830. background: #f5f7fb;
  6831. }
  6832. }
  6833. .history-table__cell {
  6834. min-width: 0;
  6835. padding: 0 10px;
  6836. font-size: 14px;
  6837. line-height: 20px;
  6838. white-space: nowrap;
  6839. overflow: hidden;
  6840. text-overflow: ellipsis;
  6841. & + .history-table__cell {
  6842. border-left: 1px solid #edf1f7;
  6843. }
  6844. }
  6845. .history-table__cell--action {
  6846. display: flex;
  6847. align-items: center;
  6848. justify-content: center;
  6849. }
  6850. .history-table__more {
  6851. width: 28px;
  6852. height: 28px;
  6853. color: #1f2937;
  6854. }
  6855. .history-actions-menu {
  6856. width: 96px;
  6857. padding: 10px 0;
  6858. background: #fff;
  6859. }
  6860. .history-actions-menu__item {
  6861. width: 100%;
  6862. height: 38px;
  6863. padding: 0 14px;
  6864. border: 0;
  6865. background: transparent;
  6866. color: #334155;
  6867. cursor: pointer;
  6868. display: flex;
  6869. align-items: center;
  6870. gap: 8px;
  6871. font-size: 14px;
  6872. text-align: left;
  6873. &:hover {
  6874. background: rgba(15, 23, 42, 0.04);
  6875. color: #111827;
  6876. }
  6877. }
  6878. .history-empty {
  6879. padding-top: 96px;
  6880. }
  6881. .g6-editor-page .edge-create-tip {
  6882. position: absolute;
  6883. top: 86px;
  6884. left: 50%;
  6885. z-index: 40;
  6886. display: flex;
  6887. align-items: center;
  6888. gap: 8px;
  6889. padding: 8px 12px;
  6890. border-radius: 999px;
  6891. border: 1px solid rgba(96, 165, 250, 0.32);
  6892. background: rgba(239, 246, 255, 0.96);
  6893. color: #1d4ed8;
  6894. box-shadow: 0 12px 24px rgba(37, 99, 235, 0.12);
  6895. transform: translateX(-50%);
  6896. }
  6897. .custom-atlas-analyse-node-container {
  6898. width: 100%;
  6899. height: 100%;
  6900. position: relative;
  6901. box-sizing: border-box;
  6902. overflow: visible;
  6903. border: 1px solid transparent;
  6904. background: transparent;
  6905. contain: layout paint style;
  6906. transform: translateZ(0);
  6907. backface-visibility: hidden;
  6908. .node-icon-tile {
  6909. position: absolute;
  6910. top: 16px;
  6911. left: 50%;
  6912. z-index: 2;
  6913. width: 40px;
  6914. height: 40px;
  6915. border-radius: 11px;
  6916. transform: translateX(-50%);
  6917. background: var(--node-accent-color, #667085);
  6918. display: flex;
  6919. align-items: center;
  6920. justify-content: center;
  6921. box-shadow: 0 4px 10px rgba(15, 23, 42, 0.1);
  6922. pointer-events: none;
  6923. img {
  6924. width: 32px;
  6925. height: 32px;
  6926. object-fit: contain;
  6927. }
  6928. .node-expand-badge {
  6929. position: absolute;
  6930. top: -7px;
  6931. right: -10px;
  6932. min-width: 18px;
  6933. height: 18px;
  6934. padding: 0 5px;
  6935. border-radius: 999px;
  6936. background: #2563eb;
  6937. color: #ffffff;
  6938. font-size: 10px;
  6939. font-weight: 700;
  6940. line-height: 18px;
  6941. text-align: center;
  6942. box-shadow: 0 6px 12px rgba(37, 99, 235, 0.28);
  6943. }
  6944. }
  6945. .input-value-box {
  6946. position: absolute;
  6947. top: 60px;
  6948. left: 50%;
  6949. width: 96px;
  6950. min-height: 40px;
  6951. padding: 0 8px;
  6952. display: flex;
  6953. flex-direction: column;
  6954. align-items: center;
  6955. justify-content: center;
  6956. transform: translateX(-50%);
  6957. color: #334155;
  6958. text-align: center;
  6959. pointer-events: none;
  6960. gap: 2px;
  6961. overflow: hidden;
  6962. box-sizing: border-box;
  6963. }
  6964. .input-value-box span {
  6965. display: block;
  6966. max-width: 100%;
  6967. overflow: hidden;
  6968. white-space: nowrap;
  6969. word-break: normal;
  6970. text-overflow: ellipsis;
  6971. }
  6972. .input-value-box__primary {
  6973. font-size: 14px;
  6974. line-height: 18px;
  6975. font-weight: 600;
  6976. color: #334155;
  6977. }
  6978. .input-value-box__secondary {
  6979. max-width: none;
  6980. font-size: 12px;
  6981. line-height: 16px;
  6982. font-weight: 500;
  6983. color: #94a3b8;
  6984. }
  6985. }
  6986. .custom-atlas-analyse-node-container.is-selected {
  6987. border-color: var(--themeColor);
  6988. background: rgba(49, 94, 251, 0.06);
  6989. border-radius: 6px;
  6990. }
  6991. .custom-atlas-analyse-node-container.is-dimmed {
  6992. opacity: 0.42;
  6993. }
  6994. .custom-atlas-analyse-node-container.is-large-graph-lite {
  6995. .node-icon-tile {
  6996. top: 18px;
  6997. width: 42px;
  6998. height: 42px;
  6999. }
  7000. .input-value-box {
  7001. top: 62px;
  7002. width: 88px;
  7003. min-height: 28px;
  7004. gap: 0;
  7005. }
  7006. .input-value-box__primary {
  7007. font-size: 12px;
  7008. line-height: 16px;
  7009. font-weight: 600;
  7010. }
  7011. }
  7012. .x6-node-selected .custom-atlas-analyse-node-container,
  7013. .custom-atlas-analyse-node-container:hover {
  7014. border-color: var(--themeColor);
  7015. background: rgba(49, 94, 251, 0.06);
  7016. border-radius: 6px;
  7017. }
  7018. .graph-analysis-detail-modal {
  7019. .ant-modal {
  7020. max-width: calc(100vw - 48px);
  7021. }
  7022. .ant-modal-body > .scroll-container,
  7023. .ant-modal-body > .scroll-container > .scrollbar__wrap,
  7024. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view,
  7025. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view > div {
  7026. height: 100%;
  7027. min-height: 0;
  7028. }
  7029. .ant-modal-body > .scroll-container > .scrollbar__wrap {
  7030. overflow: hidden;
  7031. }
  7032. .ant-modal-body > .scroll-container > .scrollbar__wrap > .scrollbar__view {
  7033. margin: 0 !important;
  7034. }
  7035. .ant-modal-content {
  7036. border-radius: 6px;
  7037. overflow: hidden;
  7038. display: flex;
  7039. flex-direction: column;
  7040. height: min(720px, calc(100vh - 80px));
  7041. max-height: min(720px, calc(100vh - 80px));
  7042. }
  7043. .ant-modal-header {
  7044. padding: 12px 16px;
  7045. border-bottom: 1px solid #e5e7eb;
  7046. flex: 0 0 auto;
  7047. background: @modal-header-bg-color;
  7048. }
  7049. .ant-modal-body {
  7050. flex: 1 1 auto;
  7051. min-height: 0;
  7052. overflow: hidden;
  7053. display: flex;
  7054. flex-direction: column;
  7055. padding: 16px;
  7056. }
  7057. }
  7058. .analysis-detail {
  7059. display: flex;
  7060. flex-direction: column;
  7061. flex: 1 1 auto;
  7062. min-height: 0;
  7063. gap: 16px;
  7064. }
  7065. .analysis-detail__summary {
  7066. display: grid;
  7067. grid-template-columns: repeat(2, minmax(0, 1fr));
  7068. gap: 12px;
  7069. }
  7070. .analysis-detail__entry {
  7071. padding: 14px 16px;
  7072. border: 1px solid #e2e8f0;
  7073. border-radius: 14px;
  7074. background: #f8fbff;
  7075. }
  7076. .analysis-detail__entry-label {
  7077. font-size: 12px;
  7078. color: #64748b;
  7079. line-height: 1;
  7080. }
  7081. .analysis-detail__entry-value {
  7082. margin-top: 8px;
  7083. font-size: 14px;
  7084. color: #0f172a;
  7085. line-height: 1.6;
  7086. word-break: break-all;
  7087. }
  7088. .analysis-associate-form {
  7089. display: flex;
  7090. flex-direction: column;
  7091. gap: 14px;
  7092. }
  7093. .analysis-associate-form__item {
  7094. display: flex;
  7095. flex-direction: column;
  7096. gap: 8px;
  7097. }
  7098. .analysis-associate-form__label {
  7099. font-size: 13px;
  7100. font-weight: 600;
  7101. color: #334155;
  7102. }
  7103. .analysis-associate-form__hint {
  7104. padding: 10px 12px;
  7105. border-radius: 12px;
  7106. background: #f8fafc;
  7107. color: #64748b;
  7108. font-size: 12px;
  7109. line-height: 1.7;
  7110. }
  7111. @media screen and (max-width: 1280px) {
  7112. .visual-relation-page {
  7113. .page-header {
  7114. flex-wrap: wrap;
  7115. }
  7116. .page-meta {
  7117. justify-content: flex-start;
  7118. padding: 0;
  7119. }
  7120. .canvas-tabs-bar {
  7121. align-items: center;
  7122. flex-wrap: wrap;
  7123. }
  7124. .control-tools-box {
  7125. left: 332px;
  7126. right: 16px;
  7127. align-items: stretch;
  7128. max-width: none;
  7129. }
  7130. .custom-atlas-tools-container {
  7131. max-width: 100%;
  7132. }
  7133. .custom-atlas-tools-container ul {
  7134. justify-content: flex-start;
  7135. }
  7136. .canvas-toolbar {
  7137. align-self: flex-end;
  7138. max-width: 100%;
  7139. flex-wrap: wrap;
  7140. justify-content: flex-end;
  7141. }
  7142. }
  7143. }
  7144. @media screen and (max-width: 960px) {
  7145. .visual-relation-page {
  7146. padding: 10px;
  7147. .relation-stage__canvas {
  7148. min-height: 560px;
  7149. }
  7150. .entity-palette {
  7151. width: calc(100% - 32px);
  7152. }
  7153. .entity-grid {
  7154. grid-template-columns: repeat(4, minmax(0, 1fr));
  7155. }
  7156. .control-tools-box {
  7157. top: 16px;
  7158. left: 16px;
  7159. right: 16px;
  7160. gap: 10px;
  7161. align-items: stretch;
  7162. }
  7163. .custom-atlas-tools-container {
  7164. max-width: 100%;
  7165. }
  7166. .tool-panel {
  7167. width: min(320px, calc(100vw - 32px));
  7168. }
  7169. .canvas-toolbar {
  7170. align-self: flex-end;
  7171. max-width: 100%;
  7172. padding: 6px;
  7173. flex-wrap: wrap;
  7174. justify-content: flex-end;
  7175. }
  7176. .canvas-status {
  7177. left: 16px;
  7178. right: 16px;
  7179. justify-content: flex-start;
  7180. }
  7181. }
  7182. .analysis-detail__summary {
  7183. grid-template-columns: 1fr;
  7184. }
  7185. }
  7186. </style>