visualize_paths.py 184 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237323832393240324132423243324432453246324732483249325032513252325332543255325632573258325932603261326232633264326532663267326832693270327132723273327432753276327732783279328032813282328332843285328632873288328932903291329232933294329532963297329832993300330133023303330433053306330733083309331033113312331333143315331633173318331933203321332233233324332533263327332833293330333133323333333433353336333733383339334033413342334333443345334633473348334933503351335233533354335533563357335833593360336133623363336433653366336733683369337033713372337333743375337633773378337933803381338233833384338533863387338833893390339133923393339433953396339733983399340034013402340334043405340634073408340934103411341234133414341534163417341834193420342134223423342434253426342734283429343034313432343334343435343634373438343934403441344234433444344534463447344834493450345134523453345434553456345734583459346034613462346334643465346634673468346934703471347234733474347534763477347834793480348134823483348434853486348734883489349034913492349334943495349634973498349935003501350235033504350535063507350835093510351135123513351435153516351735183519352035213522352335243525352635273528352935303531353235333534353535363537353835393540354135423543354435453546354735483549355035513552355335543555355635573558355935603561356235633564356535663567356835693570357135723573357435753576357735783579358035813582358335843585358635873588358935903591359235933594359535963597359835993600360136023603360436053606360736083609361036113612361336143615361636173618361936203621362236233624362536263627362836293630363136323633363436353636363736383639364036413642364336443645364636473648364936503651365236533654365536563657365836593660366136623663366436653666
  1. import json
  2. import math
  3. import os
  4. import sys
  5. from datetime import datetime
  6. from pathlib import Path
  7. from typing import Dict, List, Optional
  8. def _build_selection_points_from_decode(decode_data: Dict) -> List[Dict]:
  9. """将 examples 解构内容 JSON 转为可视化「帖子选题表」所需的选题点行。"""
  10. result: List[Dict] = []
  11. point_types = ["灵感点", "目的点", "关键点"]
  12. for point_type in point_types:
  13. points = decode_data.get(point_type, [])
  14. if not isinstance(points, list):
  15. continue
  16. for item in points:
  17. if not isinstance(item, dict):
  18. continue
  19. title = item.get("选题点") or item.get("点") or ""
  20. essence: List[str] = []
  21. form: List[str] = []
  22. intent: List[str] = []
  23. for el in item.get("选题点元素") or []:
  24. if not isinstance(el, dict):
  25. continue
  26. name = el.get("元素名称")
  27. if not name:
  28. continue
  29. et = el.get("元素类型") or ""
  30. if et == "实质":
  31. essence.append(name)
  32. elif et == "形式":
  33. form.append(name)
  34. elif et == "意图":
  35. intent.append(name)
  36. else:
  37. essence.append(name)
  38. result.append(
  39. {
  40. "类型": point_type,
  41. "选题点": title,
  42. "实质": essence,
  43. "形式": form,
  44. "意图": intent,
  45. }
  46. )
  47. return result
  48. def load_post_detail_for_visualization(account_name: str, post_id: str) -> Optional[Dict]:
  49. """
  50. 从 Agent 示例目录读取原始帖子与解构内容,供「待解构帖子详情」弹窗与侧边栏使用。
  51. - post_data: input/{account}/原始数据/post_data/{post_id}.json
  52. - 解构: input/{account}/原始数据/解构内容/{post_id}.json
  53. """
  54. base = Path(__file__).resolve().parent
  55. post_path = base / "input" / account_name / "原始数据" / "post_data" / f"{post_id}.json"
  56. decode_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
  57. try:
  58. with open(post_path, "r", encoding="utf-8") as f:
  59. post_data = json.load(f)
  60. except Exception:
  61. return None
  62. decode_data: Dict = {}
  63. try:
  64. with open(decode_path, "r", encoding="utf-8") as f:
  65. decode_data = json.load(f)
  66. except Exception:
  67. pass
  68. out = dict(post_data)
  69. out["选题点"] = _build_selection_points_from_decode(decode_data) if decode_data else []
  70. pid = out.get("channel_content_id") or decode_data.get("帖子ID")
  71. if pid and not out.get("id"):
  72. out["id"] = pid
  73. return out
  74. def generate_all_in_one_visualization(
  75. data_map: Dict[str, dict],
  76. output_path: str,
  77. account_name: str,
  78. derivation_data: Dict[str, list] = None,
  79. post_detail_map: Dict[str, dict] = None,
  80. dimension_analyze_map: Dict[str, dict] = None,
  81. ):
  82. """
  83. 将所有帖子的数据整合到一个 HTML 中,支持动态切换
  84. data_map: { "文件名": json_data, ... }
  85. derivation_data: { "文件名": 推导结果列表, ... }
  86. post_detail_map: { "文件名": 帖子详情(含选题点),来自 load_post_detail_for_visualization }
  87. dimension_analyze_map: { post_id: 整体推导维度分析 JSON(含 rounds.derived_dims 等)}
  88. """
  89. # 提取第一个帖子的数据作为默认展示
  90. first_key = list(data_map.keys())[0]
  91. # 将整个 data_map 转换为 JS 对象
  92. json_data_js = json.dumps(data_map, ensure_ascii=False)
  93. # 将推导数据转换为 JS 对象
  94. if derivation_data is None:
  95. derivation_data = {}
  96. derivation_data_js = json.dumps(derivation_data, ensure_ascii=False)
  97. # 将帖子详情数据转换为 JS 对象(供「待解构帖子」弹窗使用)
  98. if post_detail_map is None:
  99. post_detail_map = {}
  100. post_detail_map_js = json.dumps(post_detail_map, ensure_ascii=False)
  101. if dimension_analyze_map is None:
  102. dimension_analyze_map = {}
  103. dimension_analyze_data_js = json.dumps(dimension_analyze_map, ensure_ascii=False)
  104. account_name_js = json.dumps(account_name, ensure_ascii=False)
  105. html_content = rf'''<!DOCTYPE html>
  106. <html lang="zh-CN">
  107. <head>
  108. <meta charset="UTF-8">
  109. <title>多源数据流可视化 - 完整全景版</title>
  110. <style>
  111. * {{ margin: 0; padding: 0; box-sizing: border-box; }}
  112. body {{
  113. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  114. background: #f8fafc;
  115. overflow: hidden;
  116. user-select: none;
  117. }}
  118. /* 顶部工具栏 */
  119. #top-bar {{
  120. position: fixed; top: 0; left: 0; right: 0; height: 60px;
  121. background: white; border-bottom: 1px solid #e2e8f0;
  122. display: flex; align-items: center; justify-content: space-between;
  123. padding: 0 24px; z-index: 100;
  124. box-shadow: 0 1px 2px rgba(0,0,0,0.05);
  125. }}
  126. .controls {{ display: flex; gap: 16px; align-items: center; }}
  127. .controls input {{
  128. padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
  129. font-size: 14px; width: 220px; transition: border 0.2s;
  130. }}
  131. .controls input:focus {{ border-color: #3b82f6; outline: none; }}
  132. .controls select {{
  133. padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
  134. font-size: 14px; width: 200px; transition: border 0.2s;
  135. }}
  136. .controls select:focus {{ border-color: #3b82f6; outline: none; }}
  137. .controls button {{
  138. padding: 8px 16px; background: #3b82f6; color: white; border: none;
  139. border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
  140. transition: background 0.2s;
  141. }}
  142. .controls button:hover {{ background: #2563eb; }}
  143. /* 画布区域 */
  144. #app-container {{
  145. position: fixed; top: 60px; left: 0; right: 0; bottom: 0;
  146. overflow: hidden; cursor: grab; background: #f8fafc;
  147. /* 移除 transition,让画布缩放瞬间完成 */
  148. z-index: 1;
  149. transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
  150. }}
  151. #app-container.grabbing {{ cursor: grabbing; }}
  152. /* 当侧边栏显示时,画布缩小并向右移动(宽度通过 JavaScript 动态设置) */
  153. #app-container.sidebar-open {{
  154. /* right 和 width 通过 JavaScript 动态设置 */
  155. }}
  156. #canvas {{
  157. position: absolute;
  158. transform-origin: 0 0;
  159. transition: transform 0.1s linear;
  160. }}
  161. #canvas.animating {{ transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); }}
  162. /* 列标题 */
  163. .column-header {{
  164. position: absolute;
  165. height: 36px; line-height: 36px;
  166. font-size: 14px; font-weight: 600; color: #64748b;
  167. background: #f1f5f9; border-radius: 18px;
  168. text-align: center; padding: 0 20px;
  169. z-index: 2; white-space: nowrap;
  170. box-shadow: 0 1px 2px rgba(0,0,0,0.05);
  171. }}
  172. /* 卡片样式(实线框颜色略深) */
  173. .constant-card, .node-card {{
  174. position: absolute; background: white;
  175. border: 1px solid #64748b; border-radius: 10px;
  176. padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
  177. cursor: pointer; transition: all 0.2s ease-out; z-index: 10;
  178. }}
  179. /* 当侧边栏打开时,提高节点卡片的 z-index */
  180. #app-container.sidebar-open .constant-card,
  181. #app-container.sidebar-open .node-card {{
  182. z-index: 150;
  183. }}
  184. .constant-card {{ width: 280px; border-left: 5px solid #8b5cf6; }}
  185. .node-card {{ width: 320px; }}
  186. .constant-card:hover, .node-card:hover {{
  187. transform: translateY(-2px);
  188. box-shadow: 0 10px 20px -5px rgba(0,0,0,0.1);
  189. border-color: #475569;
  190. }}
  191. /* 高亮样式(实线蓝框) */
  192. .highlight {{
  193. border: 2px solid #2563eb !important;
  194. background: #eff6ff !important;
  195. box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
  196. z-index: 20;
  197. }}
  198. /* 虚线框节点高亮时保持虚线蓝框(用渐变虚线,去掉实线 border) */
  199. .node-card.not-fully-derived.highlight {{
  200. border: none !important;
  201. background-color: #eff6ff !important;
  202. box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
  203. }}
  204. /* 变暗样式 */
  205. .dimmed {{ opacity: 0.1; filter: grayscale(100%); pointer-events: none; }}
  206. /* 未完全推导的节点:虚线框(用渐变模拟较疏虚线,颜色略深) */
  207. .node-card.not-fully-derived {{
  208. border: none;
  209. border-radius: 10px;
  210. background-color: white;
  211. background-image:
  212. linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
  213. linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
  214. linear-gradient(0deg, #475569 0 8px, transparent 8px 20px),
  215. linear-gradient(0deg, #475569 0 8px, transparent 8px 20px);
  216. background-size: 28px 2px, 28px 2px, 2px 28px, 2px 28px;
  217. background-position: left top, left bottom, left top, right top;
  218. background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
  219. }}
  220. .node-card.not-fully-derived.highlight {{
  221. background-image:
  222. linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
  223. linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
  224. linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
  225. linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
  226. }}
  227. .edge-path.dimmed {{
  228. opacity: 0.05;
  229. marker-end: none !important;
  230. }}
  231. .edge-label-text.dimmed {{ opacity: 0; }}
  232. .edge-label-sub.dimmed {{ opacity: 0; }}
  233. .connector-dot.dimmed {{ opacity: 0; }}
  234. /* 内容排版 */
  235. .node-header {{ font-weight: 700; font-size: 15px; margin-bottom: 12px; color: #0f172a; }}
  236. .constant-name {{ font-weight: 700; font-size: 14px; color: #1e293b; margin-bottom: 6px; }}
  237. .constant-value {{ font-size: 13px; color: #64748b; }}
  238. .row {{ display: flex; margin-bottom: 6px; font-size: 12px; line-height: 1.5; align-items: baseline; }}
  239. .key {{ color: #94a3b8; width: 50px; flex-shrink: 0; text-align: right; margin-right: 12px; white-space: nowrap; }}
  240. .val {{ color: #334155; font-weight: 500; }}
  241. .row-root-source .key {{ width: 80px; }}
  242. .row-root-source .val {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }}
  243. .row-post-topic {{ margin-top: 6px; }}
  244. .row-post-topic .key {{ margin-right: 20px; }}
  245. /* SVG */
  246. .edge-layer {{ position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; }}
  247. .edge-layer g {{ pointer-events: all; }}
  248. .edge-path {{
  249. fill: none; stroke: #cbd5e1; stroke-width: 1.5px;
  250. stroke-linejoin: round; transition: stroke 0.3s, opacity 0.3s;
  251. pointer-events: stroke; cursor: pointer;
  252. }}
  253. .edge-path.highlight {{ stroke: #2563eb; stroke-width: 2.5px; opacity: 1; }}
  254. /* 主标签样式 */
  255. .edge-label-text {{
  256. font-size: 12px; fill: #475569; text-anchor: middle;
  257. font-family: monospace; paint-order: stroke;
  258. stroke: #f8fafc; stroke-width: 4px;
  259. transition: opacity 0.3s;
  260. pointer-events: all; cursor: pointer;
  261. }}
  262. /* 副标签样式(概率) */
  263. .edge-label-sub {{
  264. font-size: 10px; fill: #94a3b8; text-anchor: middle;
  265. font-family: -apple-system, BlinkMacSystemFont, sans-serif;
  266. paint-order: stroke; stroke: #f8fafc; stroke-width: 3px;
  267. transition: opacity 0.3s;
  268. pointer-events: all; cursor: pointer;
  269. }}
  270. .edge-label-text.highlight {{ fill: #2563eb; font-weight: 700; opacity: 1; }}
  271. .edge-label-sub.highlight {{ fill: #2563eb; font-weight: 600; opacity: 1; }}
  272. .connector-dot {{ fill: #cbd5e1; transition: fill 0.3s; pointer-events: all; cursor: pointer; }}
  273. .connector-dot.highlight {{ fill: #2563eb; }}
  274. /* 侧边栏 */
  275. #sidebar {{
  276. position: fixed; top: 60px; right: 0; width: 380px; min-width: 250px; max-width: 60vw;
  277. height: calc(100vh - 60px);
  278. background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 15px rgba(0,0,0,0.05);
  279. transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
  280. z-index: 100; display: flex; flex-direction: column;
  281. }}
  282. #sidebar.active {{ transform: translateX(0); }}
  283. /* 侧边栏拉伸器 */
  284. #sidebar-resizer {{
  285. position: fixed; top: 60px; right: 0; width: 8px; height: calc(100vh - 60px);
  286. background: #e0e0e0; cursor: col-resize; z-index: 101;
  287. transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
  288. display: flex; align-items: center; justify-content: center;
  289. }}
  290. #sidebar-resizer.active {{ transform: translateX(0); }}
  291. #sidebar-resizer:hover {{ background: #3b82f6; }}
  292. #sidebar-resizer::before {{
  293. content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
  294. width: 2px; background: #999; transform: translateX(-50%);
  295. }}
  296. #sidebar-resizer:hover::before {{ background: #3b82f6; }}
  297. body.resizing {{ user-select: none; }}
  298. body.resizing #sidebar-resizer {{ background: #3b82f6; }}
  299. .sidebar-header {{ padding: 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }}
  300. .sidebar-content {{ padding: 20px; overflow-y: auto; flex: 1; }}
  301. .detail-item {{ margin-bottom: 20px; }}
  302. .detail-item label {{ display: block; font-size: 11px; font-weight: 600; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }}
  303. .detail-val {{ font-size: 14px; color: #1e293b; padding: 12px; background: #f8fafc; border-radius: 8px; border: 1px solid #e2e8f0; line-height: 1.6; white-space: pre-wrap; }}
  304. .detail-empty {{ color: #999; font-style: italic; text-align: center; padding: 40px 20px; }}
  305. .query-block-header {{ cursor: pointer; padding: 8px 0; user-select: none; }}
  306. .query-block-header:hover {{ color: #3b82f6; }}
  307. .query-block-body {{ margin-top: 8px; }}
  308. .external-post-card {{ border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fafafa; }}
  309. .root-detail-section {{ margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
  310. .root-detail-section:last-child {{ border-bottom: none; }}
  311. .root-detail-title {{ font-size: 18px; font-weight: bold; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }}
  312. .root-detail-title::before {{ content: ""; display: inline-block; width: 4px; height: 18px; background: #3b82f6; border-radius: 2px; }}
  313. .post-title {{ font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #444; }}
  314. .post-body {{ font-size: 14px; white-space: pre-wrap; color: #666; background: #f9f9f9; padding: 12px; border-radius: 6px; margin-bottom: 15px; }}
  315. .post-stats {{ display: flex; gap: 20px; margin-bottom: 15px; font-size: 14px; color: #888; }}
  316. .post-stats span {{ display: flex; align-items: center; gap: 4px; }}
  317. .image-gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; margin-top: 10px; }}
  318. .image-item {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; cursor: pointer; transition: transform 0.2s; border: 1px solid #ddd; }}
  319. .image-item:hover {{ transform: scale(1.05); border-color: #3b82f6; }}
  320. .jump-link {{ display: flex; align-items: center; justify-content: space-between; padding: 12px 15px; background: #f0f4f8; color: #3b82f6; text-decoration: none; border-radius: 8px; font-weight: bold; transition: background 0.2s; margin-bottom: 10px; }}
  321. .jump-link:hover {{ background: #e1e9f0; color: #2563eb; }}
  322. .jump-link::after {{ content: "→"; font-size: 18px; }}
  323. /* 推导进度区域 - 底部面板 */
  324. #derivation-progress-section {{
  325. position: fixed; bottom: 0; left: 0; right: 0;
  326. height: 600px; max-height: 80vh; min-height: 200px;
  327. background: white; border-top: 1px solid #e2e8f0; box-shadow: 0 -2px 15px rgba(0,0,0,0.05);
  328. transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
  329. z-index: 99; display: flex; flex-direction: column;
  330. }}
  331. #derivation-resizer {{
  332. position: absolute; top: 0; left: 0; right: 0; height: 6px;
  333. cursor: row-resize; z-index: 100;
  334. background: transparent;
  335. }}
  336. #derivation-resizer:hover, #derivation-resizer.active {{
  337. background: #3b82f6;
  338. }}
  339. #derivation-progress-section.active {{ transform: translateY(0); }}
  340. .derivation-progress-title {{
  341. padding: 15px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc;
  342. flex-shrink: 0;
  343. }}
  344. .derivation-progress-title span {{
  345. font-weight: 700; color: #334155; font-size: 16px;
  346. }}
  347. .derivation-color-legend {{
  348. display: flex; gap: 15px; align-items: center; margin-left: 20px; font-size: 12px;
  349. }}
  350. .derivation-color-legend-item {{
  351. display: flex; align-items: center; gap: 6px;
  352. }}
  353. .derivation-color-legend-color {{
  354. width: 16px; height: 16px; border-radius: 4px; border: 1px solid #d1d5db;
  355. }}
  356. .legend-black {{ background: #f3f4f6; border-color: #d1d5db; }}
  357. .legend-yellow {{ background: #fef3c7; border-color: #fcd34d; }}
  358. .legend-green {{ background: #d1fae5; border-color: #6ee7b7; }}
  359. .derivation-progress-toggle {{
  360. padding: 6px 12px; background: #3b82f6; color: white; border: none;
  361. border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
  362. transition: background 0.2s;
  363. }}
  364. .derivation-progress-toggle:hover {{ background: #2563eb; }}
  365. #derivation-progress-content {{
  366. padding: 20px; overflow-x: auto; overflow-y: auto; flex: 1;
  367. }}
  368. /* 当推导进度面板打开时,调整画布底部边距 */
  369. #app-container.derivation-open {{
  370. bottom: 600px;
  371. transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
  372. }}
  373. .derivation-empty {{
  374. color: #999; font-style: italic; text-align: center; padding: 40px 20px;
  375. }}
  376. .derivation-timeline {{
  377. display: flex; flex-direction: row; gap: 20px; align-items: flex-start;
  378. min-width: max-content;
  379. }}
  380. .derivation-round-block {{
  381. border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #fafafa;
  382. min-width: 350px; max-width: 450px; flex-shrink: 0;
  383. display: flex; flex-direction: column;
  384. }}
  385. .derivation-round-title {{
  386. font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #3b82f6;
  387. flex-shrink: 0;
  388. }}
  389. .derivation-table {{
  390. width: 100%; border-collapse: collapse; font-size: 11px;
  391. }}
  392. .derivation-table th {{
  393. background: #f1f5f9; padding: 6px 4px; text-align: left; font-weight: 600; color: #475569; border: 1px solid #e2e8f0;
  394. font-size: 10px;
  395. }}
  396. .derivation-table td {{
  397. padding: 4px; border: 1px solid #e2e8f0; vertical-align: top;
  398. font-size: 10px;
  399. }}
  400. .derivation-table .col-type {{ width: 50px; }}
  401. .derivation-table .col-source {{ width: 100px; }}
  402. .derivation-table .col-dim {{ width: auto; min-width: 80px; }}
  403. .derivation-topic-item {{
  404. display: inline-block; margin: 2px 4px 2px 0; padding: 2px 6px; border-radius: 4px; font-size: 11px;
  405. cursor: pointer; transition: all 0.2s;
  406. }}
  407. /* 未点亮的词 - 黑色 */
  408. .derivation-topic-underedived {{
  409. background: #f3f4f6; color: #000000; border: 1px solid #d1d5db;
  410. }}
  411. .derivation-dimension-extra {{
  412. margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e2e8f0;
  413. font-size: 11px; line-height: 1.5;
  414. }}
  415. .derivation-dim-line {{ margin-bottom: 6px; word-break: break-all; }}
  416. .derivation-dim-label {{
  417. display: inline-block; min-width: 72px; font-weight: 600; color: #64748b;
  418. }}
  419. .derivation-dim-val.dim-derived {{ color: #15803d; }}
  420. .derivation-dim-val.dim-underived {{ color: #b45309; }}
  421. .derivation-dim-line.dim-muted {{ color: #94a3b8; font-style: italic; }}
  422. .btn-dimension-patterns {{
  423. margin-top: 8px; padding: 5px 12px; font-size: 11px;
  424. background: #6366f1; color: white; border: none; border-radius: 6px;
  425. cursor: pointer; font-weight: 500;
  426. }}
  427. .btn-dimension-patterns:hover {{ background: #4f46e5; }}
  428. #dimension-patterns-modal {{
  429. display: none; position: fixed; inset: 0; z-index: 200;
  430. background: rgba(15, 23, 42, 0.45); align-items: center; justify-content: center;
  431. padding: 24px;
  432. }}
  433. #dimension-patterns-modal.active {{ display: flex; }}
  434. .dimension-patterns-dialog {{
  435. background: white; border-radius: 12px; max-width: 900px; width: 100%;
  436. max-height: 80vh; display: flex; flex-direction: column;
  437. box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
  438. }}
  439. .dimension-patterns-head {{
  440. padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
  441. display: flex; justify-content: space-between; align-items: center;
  442. flex-shrink: 0;
  443. }}
  444. .dimension-patterns-head span {{ font-weight: 700; color: #1e293b; font-size: 15px; }}
  445. .dimension-patterns-close {{
  446. padding: 6px 14px; background: #f1f5f9; border: none; border-radius: 6px;
  447. cursor: pointer; font-size: 13px;
  448. }}
  449. .dimension-patterns-close:hover {{ background: #e2e8f0; }}
  450. .dimension-patterns-body {{
  451. padding: 16px 18px; overflow-y: auto; font-size: 12px; line-height: 1.6;
  452. }}
  453. .dimension-patterns-title {{ font-weight: 600; color: #475569; margin-bottom: 12px; }}
  454. .pattern-line {{
  455. padding: 8px 10px; margin-bottom: 6px; background: #f8fafc;
  456. border-radius: 6px; border: 1px solid #e2e8f0; word-break: break-all;
  457. }}
  458. .pattern-plus {{ color: #94a3b8; font-weight: 600; margin: 0 4px; }}
  459. .pattern-item-derived {{
  460. color: #15803d; font-weight: 700; background: #dcfce7;
  461. padding: 1px 4px; border-radius: 4px;
  462. }}
  463. /* 当前轮次点亮的点 - 黄色 */
  464. .derivation-topic-new {{
  465. background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; font-weight: 600;
  466. }}
  467. /* 未完全推导的选题点:虚线框 */
  468. .derivation-topic-item.derivation-topic-not-fully-derived {{
  469. border: 1px dashed #475569 !important;
  470. }}
  471. /* 之前已经点亮过的点 - 绿色 */
  472. .derivation-topic-derived {{
  473. background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7;
  474. }}
  475. /* 推导结果为空时,由解构内容回填的基准选题(不可点击定位) */
  476. .derivation-topic-baseline {{
  477. background: #e0e7ff; color: #312e81; border: 1px solid #a5b4fc; cursor: default;
  478. }}
  479. .derivation-topic-item.derivation-topic-baseline:hover {{
  480. transform: none; box-shadow: none;
  481. }}
  482. .derivation-topic-item:hover {{
  483. transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  484. }}
  485. .derivation-topic-search-icon {{ color: #2196F3; margin-left: 2px; }}
  486. .derivation-topic-tool-icon {{ color: #ff9800; margin-left: 2px; }}
  487. /* 待解构帖子数据 入口 */
  488. .top-bar-left {{ display: flex; align-items: center; gap: 16px; }}
  489. #btn-pending-decode-post {{
  490. padding: 8px 16px; background: #8b5cf6; color: white; border: none;
  491. border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
  492. transition: background 0.2s;
  493. }}
  494. #btn-pending-decode-post:hover {{ background: #7c3aed; }}
  495. .modal-overlay {{
  496. position: fixed; top: 0; left: 0; right: 0; bottom: 0;
  497. background: rgba(0,0,0,0.4); z-index: 1000; display: none;
  498. align-items: center; justify-content: center;
  499. }}
  500. .modal-overlay.active {{ display: flex; }}
  501. .modal-box {{
  502. background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15);
  503. max-width: 480px; width: 90%; max-height: 85vh; overflow: hidden;
  504. display: flex; flex-direction: column;
  505. }}
  506. .modal-box.post-detail-modal {{ max-width: 720px; }}
  507. .modal-header {{
  508. padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex;
  509. justify-content: space-between; align-items: center; background: #f8fafc;
  510. }}
  511. .modal-header span {{ font-weight: 700; font-size: 16px; color: #334155; }}
  512. .modal-close {{ background: none; border: none; font-size: 24px; cursor: pointer; color: #94a3b8; line-height: 1; }}
  513. .modal-close:hover {{ color: #64748b; }}
  514. .modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
  515. /* 图集大图查看(灯箱) */
  516. #image-lightbox {{
  517. position: fixed; top: 0; left: 0; right: 0; bottom: 0;
  518. background: rgba(0,0,0,0.9); z-index: 2000; display: none;
  519. align-items: center; justify-content: center;
  520. }}
  521. #image-lightbox.active {{ display: flex; }}
  522. #image-lightbox .lightbox-close {{
  523. position: absolute; top: 16px; right: 20px;
  524. background: none; border: none; color: #fff; font-size: 32px;
  525. cursor: pointer; line-height: 1; opacity: 0.8;
  526. }}
  527. #image-lightbox .lightbox-close:hover {{ opacity: 1; }}
  528. #image-lightbox .lightbox-prev,
  529. #image-lightbox .lightbox-next {{
  530. position: absolute; top: 50%; transform: translateY(-50%);
  531. width: 48px; height: 48px; border: none; border-radius: 50%;
  532. background: rgba(255,255,255,0.2); color: #fff; font-size: 24px;
  533. cursor: pointer; display: flex; align-items: center; justify-content: center;
  534. transition: background 0.2s;
  535. }}
  536. #image-lightbox .lightbox-prev:hover,
  537. #image-lightbox .lightbox-next:hover {{ background: rgba(255,255,255,0.35); }}
  538. #image-lightbox .lightbox-prev {{ left: 20px; }}
  539. #image-lightbox .lightbox-next {{ right: 20px; }}
  540. #image-lightbox .lightbox-img-wrap {{
  541. max-width: 90vw; max-height: 85vh; display: flex; align-items: center; justify-content: center;
  542. }}
  543. #image-lightbox .lightbox-img-wrap img {{
  544. max-width: 100%; max-height: 85vh; object-fit: contain;
  545. }}
  546. #image-lightbox .lightbox-counter {{
  547. position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
  548. color: rgba(255,255,255,0.9); font-size: 14px;
  549. }}
  550. </style>
  551. </head>
  552. <body>
  553. <div id="top-bar">
  554. <div class="top-bar-left">
  555. <button type="button" id="btn-pending-decode-post">待解构帖子数据</button>
  556. <h2 style="font-size:18px; color:#1e293b; font-weight:600;">多源数据流可视化 - 完整全景版</h2>
  557. </div>
  558. <div class="controls">
  559. <select id="postSelector" onchange="switchPost(this.value)">
  560. {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
  561. </select>
  562. <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
  563. <button onclick="resetView()">重置视图</button>
  564. <button onclick="toggleDerivationProgress()">推导进度</button>
  565. </div>
  566. </div>
  567. <div id="sidebar">
  568. <div class="sidebar-header">
  569. <div id="sidebar-title" style="font-weight:700; color:#334155;">节点详情</div>
  570. <button onclick="closeSidebar()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#94a3b8;">&times;</button>
  571. </div>
  572. <div class="sidebar-content" id="sidebar-content"></div>
  573. </div>
  574. <div id="sidebar-resizer"></div>
  575. <div id="app-container">
  576. <div id="canvas"></div>
  577. </div>
  578. <div id="derivation-progress-section">
  579. <div id="derivation-resizer"></div>
  580. <div class="derivation-progress-title">
  581. <div style="display: flex; align-items: center;">
  582. <span>推导进度</span>
  583. <div class="derivation-color-legend">
  584. <div class="derivation-color-legend-item">
  585. <div class="derivation-color-legend-color legend-black"></div>
  586. <span>未点亮</span>
  587. </div>
  588. <div class="derivation-color-legend-item">
  589. <div class="derivation-color-legend-color legend-yellow"></div>
  590. <span>当前轮次点亮</span>
  591. </div>
  592. <div class="derivation-color-legend-item">
  593. <div class="derivation-color-legend-color legend-green"></div>
  594. <span>之前已点亮</span>
  595. </div>
  596. </div>
  597. </div>
  598. <button class="derivation-progress-toggle" onclick="toggleDerivationProgress()">收起</button>
  599. </div>
  600. <div id="derivation-progress-content"></div>
  601. </div>
  602. <!-- 待解构帖子详情弹窗 -->
  603. <div id="post-detail-modal" class="modal-overlay">
  604. <div class="modal-box post-detail-modal">
  605. <div class="modal-header">
  606. <span>待解构帖子详情</span>
  607. <button type="button" class="modal-close" onclick="closePostDetailModal()">&times;</button>
  608. </div>
  609. <div class="modal-body" id="post-detail-modal-content"></div>
  610. </div>
  611. </div>
  612. <!-- 图集大图灯箱 -->
  613. <div id="image-lightbox">
  614. <button type="button" class="lightbox-close" onclick="closeImageLightbox()">&times;</button>
  615. <button type="button" class="lightbox-prev" onclick="lightboxPrev()">&#10094;</button>
  616. <div class="lightbox-img-wrap">
  617. <img id="lightbox-img" src="" alt="" />
  618. </div>
  619. <button type="button" class="lightbox-next" onclick="lightboxNext()">&#10095;</button>
  620. <div class="lightbox-counter" id="lightbox-counter"></div>
  621. </div>
  622. <div id="dimension-patterns-modal" onclick="if(event.target===this) closeDimensionPatternsModal()">
  623. <div class="dimension-patterns-dialog" onclick="event.stopPropagation()">
  624. <div class="dimension-patterns-head">
  625. <span id="dimension-patterns-modal-title">维度 patterns</span>
  626. <button type="button" class="dimension-patterns-close" onclick="closeDimensionPatternsModal()">关闭</button>
  627. </div>
  628. <div class="dimension-patterns-body" id="dimension-patterns-modal-body"></div>
  629. </div>
  630. </div>
  631. <script>
  632. const allData = {json_data_js};
  633. const derivationData = {derivation_data_js};
  634. const dimensionAnalyzeData = {dimension_analyze_data_js};
  635. const postDetailMap = {post_detail_map_js};
  636. const accountName = {account_name_js};
  637. const CONFIG = {{
  638. cardWidth: 320,
  639. constWidth: 280,
  640. colSpacing: 900,
  641. rowSpacing: 30,
  642. paddingX: 80,
  643. paddingY: 100,
  644. busOffset: 450,
  645. forkOffset: 40
  646. }};
  647. const canvas = document.getElementById('canvas');
  648. let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
  649. let edgeGroups = {{}};
  650. let currentPostKey = "{first_key}";
  651. // 1. 数据解析 - 适配 node_list 和 edge_list 格式
  652. function parseData(postKey) {{
  653. flatData = {{ nodesByLevel: {{}}, map: {{}} }};
  654. edgeGroups = {{}};
  655. const data = allData[postKey];
  656. const nodesData = data.node_list || [];
  657. const edgesData = data.edge_list || [];
  658. const allUsedTreeNodes = data.all_used_tree_nodes || [];
  659. // 创建节点映射
  660. const nodeMap = {{}};
  661. nodesData.forEach(node => {{
  662. nodeMap[node.name] = node;
  663. }});
  664. // 处理人设/全局常量节点(放在 level -1,第一轮推导左侧)
  665. // 注意:所有节点都需要展示,不受 is_constant 和 is_local_constant 字段影响
  666. const constantLevel = -1;
  667. if (!flatData.nodesByLevel[constantLevel]) flatData.nodesByLevel[constantLevel] = [];
  668. // 遍历所有节点,全部添加到列表中(不进行任何过滤,全部展示)
  669. allUsedTreeNodes.forEach((constantNode, index) => {{
  670. // 使用索引确保即使名称重复也能区分
  671. const uniqueId = constantNode.name + '_const_' + index;
  672. const item = {{
  673. id: uniqueId,
  674. name: constantNode.name,
  675. data: {{
  676. ...constantNode,
  677. type: constantNode.type || '',
  678. is_constant: constantNode.is_constant || false,
  679. is_local_constant: constantNode.is_local_constant || false
  680. }},
  681. type: 'node',
  682. level: constantLevel,
  683. sources: [],
  684. edgeName: '',
  685. edgeScore: 0
  686. }};
  687. // 添加到数组和映射中(数组用于渲染,确保所有节点都显示)
  688. flatData.nodesByLevel[constantLevel].push(item);
  689. flatData.map[item.id] = item;
  690. // 名称映射用于查找(如果有重复名称,最后一个会覆盖,但不影响数组中的显示)
  691. flatData.map[item.name] = item;
  692. }});
  693. // 按 level 分组节点(同名节点可能出现在多轮,如「居家生活场景」level 1 与 level 2 各有一个)
  694. nodesData.forEach(node => {{
  695. const level = node.level || 0;
  696. if (!flatData.nodesByLevel[level]) flatData.nodesByLevel[level] = [];
  697. const uniqueId = node.name + '__L' + level;
  698. const item = {{
  699. id: node.name,
  700. uid: uniqueId,
  701. name: node.name,
  702. data: node,
  703. type: 'node',
  704. level: level,
  705. sources: [],
  706. edgeName: '',
  707. edgeScore: 0
  708. }};
  709. flatData.nodesByLevel[level].push(item);
  710. flatData.map[uniqueId] = item;
  711. flatData.map[item.name] = item;
  712. }});
  713. // 处理边,建立连接关系
  714. // 边对象有 level 字段,表示轮次;边只能连接同轮次的 output 输出节点
  715. // output_nodes 为对象列表,每项有 name 字段表示输出节点名称
  716. edgesData.forEach(edge => {{
  717. const outputNodes = edge.output_nodes || [];
  718. const inputPostNodes = edge.input_post_nodes || [];
  719. const usedTreeNodes = edge.used_tree_nodes || edge.input_tree_nodes || [];
  720. const edgeLevel = edge.level;
  721. // 处理 input_post_nodes 作为输入节点(这些节点在推导过程中,不在 level -1)
  722. const inputPostNames = inputPostNodes.map(n => n.name || n).filter(name => name);
  723. // 处理 used_tree_nodes / input_tree_nodes,匹配到 all_used_tree_nodes 中的节点(这些节点在 level -1)
  724. const usedTreeNames = [];
  725. usedTreeNodes.forEach(usedNode => {{
  726. const usedName = usedNode.name || usedNode;
  727. // 在 all_used_tree_nodes 中查找匹配的节点(通过 name 匹配)
  728. const matchedNode = allUsedTreeNodes.find(n => n.name === usedName);
  729. if (matchedNode) {{
  730. usedTreeNames.push(usedName);
  731. }}
  732. }});
  733. // 合并所有输入节点名称(但需要区分来源)
  734. // 保存输入节点的来源信息,用于后续查找正确的节点
  735. const allInputNames = [];
  736. const inputSourceMap = {{}}; // 记录每个输入节点的来源:'post' 或 'tree'
  737. inputPostNames.forEach(name => {{
  738. allInputNames.push(name);
  739. inputSourceMap[name] = 'post'; // 来自推导节点
  740. }});
  741. usedTreeNames.forEach(name => {{
  742. allInputNames.push(name);
  743. inputSourceMap[name] = 'tree'; // 来自人设/全局常量节点(level -1)
  744. }});
  745. // 先收集所有有效的输出节点:仅同轮次(边只能连接 edge.level 对应的 output 节点)
  746. // 同名节点可能出现在多轮,需按 name + level 查找
  747. const validOutputItems = [];
  748. outputNodes.forEach(outputNode => {{
  749. const outputName = (typeof outputNode === 'object' && outputNode !== null && outputNode.name != null) ? outputNode.name : outputNode;
  750. let outputItem = null;
  751. if (edgeLevel != null && flatData.nodesByLevel[edgeLevel]) {{
  752. outputItem = flatData.nodesByLevel[edgeLevel].find(n => n.name === outputName) || null;
  753. }}
  754. if (!outputItem) outputItem = flatData.map[outputName] || null;
  755. if (outputItem && (edgeLevel == null || outputItem.level === edgeLevel)) {{
  756. validOutputItems.push(outputItem);
  757. }}
  758. }});
  759. // 如果没有有效的输出节点,跳过
  760. if (validOutputItems.length === 0) return;
  761. // 为整个边创建一个边组(所有输出节点共享同一个边组)
  762. const edgeName = edge.name || '';
  763. const edgeScore = edge.score || 0;
  764. // 收集输出节点名称用于生成唯一的 edgeKey
  765. const outputNames = validOutputItems.map(item => item.name).sort();
  766. // edgeKey 需要包含输入节点、输出节点和边名称,确保每条边都有唯一的 key
  767. const inputKey = allInputNames.length > 0
  768. ? allInputNames.slice().sort().join('|')
  769. : 'empty';
  770. const outputKey = outputNames.join('|');
  771. const edgeKey = inputKey + '||' + outputKey + '||' + edgeName;
  772. if (!edgeGroups[edgeKey]) {{
  773. edgeGroups[edgeKey] = {{
  774. key: edgeKey,
  775. targets: [],
  776. sources: allInputNames,
  777. sourceMap: inputSourceMap, // 保存输入节点的来源映射
  778. edgeName: edgeName,
  779. edgeScore: edgeScore,
  780. edgeData: edge // 保存完整的边数据
  781. }};
  782. }}
  783. // 将所有输出节点添加到同一个边组,并设置相同的边信息
  784. validOutputItems.forEach(outputItem => {{
  785. // 更新输出节点的边信息
  786. outputItem.sources = allInputNames;
  787. outputItem.edgeName = edgeName;
  788. outputItem.edgeScore = edgeScore;
  789. outputItem.edgeGroupKey = edgeKey;
  790. // 添加到边组
  791. edgeGroups[edgeKey].targets.push(outputItem);
  792. }});
  793. }});
  794. }}
  795. // 2. 布局计算(按 level 排序,但 x 用列索引排列,空缺的 level 不占位)
  796. function calculateLayout() {{
  797. const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
  798. levels.forEach((level, colIndex) => {{
  799. const nodes = flatData.nodesByLevel[level];
  800. // level -1 放第一列;其余列按 colIndex 紧密排列,不因 level 空缺留白
  801. const x = CONFIG.paddingX + colIndex * CONFIG.colSpacing;
  802. let y = CONFIG.paddingY;
  803. createHeader(level, x);
  804. nodes.forEach(node => {{
  805. const h = estimateHeight(node);
  806. node.x = x;
  807. node.y = y;
  808. node.width = node.type === 'constant' ? CONFIG.constWidth : CONFIG.cardWidth;
  809. node.height = h;
  810. node.inputPoint = {{ x: node.x, y: node.y + h/2 }};
  811. node.outputPoint = {{ x: node.x + node.width, y: node.y + h/2 }};
  812. y += h + CONFIG.rowSpacing;
  813. }});
  814. }});
  815. }}
  816. function estimateHeight(node) {{
  817. if (node.type === 'constant') return 80;
  818. // level -1 的常量节点,只显示 name 和 type,固定高度
  819. if (node.level === -1) {{
  820. return 60 + 22; // name + type
  821. }}
  822. // 为了保证不同节点类型高度一致,这里统一按 point/dimension/root_source 的存在情况估算行数
  823. let lines = 1; // node-header
  824. if (node.data && node.data.point) lines++;
  825. if (node.data && node.data.dimension) lines++;
  826. if (node.data && node.data.root_source) lines++;
  827. return 60 + lines * 22;
  828. }}
  829. function createHeader(level, x) {{
  830. const existing = document.querySelector(`.column-header[data-level="${{level}}"]`);
  831. if (existing) existing.remove();
  832. const el = document.createElement('div');
  833. el.className = 'column-header';
  834. el.dataset.level = level;
  835. el.style.left = x + 'px';
  836. el.style.top = '40px';
  837. el.style.width = CONFIG.cardWidth + 'px';
  838. if (level === -1) {{
  839. el.textContent = '人设/全局常量';
  840. }} else {{
  841. const nums = ['一','二','三','四','五','六','七','八','九','十'];
  842. el.textContent = `第${{nums[level-1] || level}}轮推导`;
  843. }}
  844. canvas.appendChild(el);
  845. }}
  846. function renderNodes() {{
  847. // 清空现有节点
  848. document.querySelectorAll('.node-card, .constant-card').forEach(el => el.remove());
  849. const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
  850. levels.forEach(level => {{
  851. const nodes = flatData.nodesByLevel[level] || [];
  852. nodes.forEach(node => {{
  853. const el = document.createElement('div');
  854. el.dataset.id = node.uid != null ? node.uid : node.id;
  855. el.style.left = node.x + 'px';
  856. el.style.top = node.y + 'px';
  857. el.style.width = node.width + 'px';
  858. if (node.type === 'constant') {{
  859. el.className = 'constant-card';
  860. el.style.height = (node.height || estimateHeight(node)) + 'px';
  861. el.innerHTML = `
  862. <div class="constant-name">${{node.name}}</div>
  863. <div class="constant-value">${{node.data.value || ''}}</div>
  864. `;
  865. }} else if (node.level === -1) {{
  866. // level -1 的常量节点(人设/全局常量),只显示 name 和 type
  867. el.className = 'node-card';
  868. el.style.height = (node.height || estimateHeight(node)) + 'px';
  869. let html = `<div class="node-header">${{node.name}}</div>`;
  870. if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
  871. el.innerHTML = html;
  872. }} else {{
  873. // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
  874. el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
  875. el.style.height = (node.height || estimateHeight(node)) + 'px';
  876. const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
  877. ? node.data.derivation_output_point : node.name;
  878. let html = `<div class="node-header">${{displayName}}</div>`;
  879. if (node.data.is_fully_derived === false) {{
  880. // 未完全推导:只显示「帖子选题点」,值为原 node_list.name
  881. if (node.data.name != null && node.data.name !== '') html += `<div class="row row-post-topic"><span class="key">帖子选题点</span><span class="val">${{node.data.name}}</span></div>`;
  882. }} else {{
  883. // 常规节点:显示 类型(原关键点)、维度、所属选题点
  884. if (node.data.point) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.point}}</span></div>`;
  885. if (node.data.dimension) html += `<div class="row"><span class="key">维度</span><span class="val">${{node.data.dimension}}</span></div>`;
  886. if (node.data.root_source) html += `<div class="row row-root-source"><span class="key">所属选题点</span><span class="val">${{node.data.root_source}}</span></div>`;
  887. }}
  888. el.innerHTML = html;
  889. }}
  890. el.onclick = (e) => {{
  891. e.stopPropagation();
  892. // 保存当前选中的节点
  893. currentSelectedNode = node;
  894. currentSelectedEdgeGroup = null; // 清除边组选中状态
  895. // 不自动缩放,保持当前视图大小和位置
  896. // 立即高亮和显示侧边栏(无延迟)
  897. highlightDirectSources(node);
  898. const sidebarTitle = (node.data && node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
  899. ? `节点: ${{node.data.derivation_output_point}}` : `节点: ${{node.name}}`;
  900. showSidebar(node.data, sidebarTitle, node, 'node');
  901. }};
  902. canvas.appendChild(el);
  903. node.el = el;
  904. }});
  905. }});
  906. }}
  907. // 3. 渲染连线 - 按组渲染
  908. function renderEdges() {{
  909. // 移除旧的 SVG
  910. const oldSvg = document.querySelector('.edge-layer');
  911. if (oldSvg) oldSvg.remove();
  912. const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
  913. svg.classList.add('edge-layer');
  914. svg.setAttribute('width', '10000');
  915. svg.setAttribute('height', '8000');
  916. // 定义箭头
  917. const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
  918. const createMarker = (id, color) => {{
  919. const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
  920. m.setAttribute('id', id);
  921. m.setAttribute('markerWidth', '10'); m.setAttribute('markerHeight', '7');
  922. m.setAttribute('refX', '9'); m.setAttribute('refY', '3.5');
  923. m.setAttribute('orient', 'auto');
  924. const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  925. p.setAttribute('d', 'M0,0 L0,7 L9,3.5 z');
  926. p.setAttribute('fill', color);
  927. m.appendChild(p);
  928. return m;
  929. }};
  930. defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
  931. defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
  932. svg.appendChild(defs);
  933. Object.values(edgeGroups).forEach(group => {{
  934. const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
  935. g.dataset.edgeGroup = group.key;
  936. const targets = group.targets;
  937. const sourceNames = group.sources;
  938. // 如果没有目标节点,跳过
  939. if (!targets.length) return;
  940. const targetX = targets[0].inputPoint.x;
  941. const busX = targetX - CONFIG.busOffset;
  942. const forkX = targetX - CONFIG.forkOffset;
  943. // 获取源节点(同名多轮时取低于目标层级的源)
  944. const sourceNodes = [];
  945. const sourceMap = group.sourceMap || {{}};
  946. const minTargetLevel = targets.length ? Math.min(...targets.map(t => t.level)) : 0;
  947. sourceNames.forEach(name => {{
  948. const sourceType = sourceMap[name];
  949. let node = null;
  950. if (sourceType === 'tree') {{
  951. const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
  952. node = levelMinusOneNodes.find(n => n.name === name);
  953. }} else {{
  954. for (let l = minTargetLevel - 1; l >= 0; l--) {{
  955. const found = (flatData.nodesByLevel[l] || []).find(n => n.name === name);
  956. if (found) {{ node = found; break; }}
  957. }}
  958. if (!node) {{
  959. const candidate = flatData.map[name];
  960. if (candidate && candidate.level !== -1) node = candidate;
  961. }}
  962. }}
  963. if (node) sourceNodes.push(node);
  964. }});
  965. targets.sort((a,b) => a.y - b.y);
  966. const tMinY = targets[0].inputPoint.y;
  967. const tMaxY = targets[targets.length - 1].inputPoint.y;
  968. // 核心连线的 Y 坐标(使用第一个目标节点的 Y 坐标,与参考文件保持一致)
  969. const mainY = tMinY;
  970. // 创建点击事件处理函数
  971. const handleGroupClick = (e) => {{
  972. e.stopPropagation();
  973. handleEdgeClick(group);
  974. }};
  975. // 如果有源节点,渲染左侧部分
  976. if (sourceNodes.length > 0) {{
  977. let sMinY = Infinity, sMaxY = -Infinity;
  978. sourceNodes.forEach(s => {{
  979. sMinY = Math.min(sMinY, s.outputPoint.y);
  980. sMaxY = Math.max(sMaxY, s.outputPoint.y);
  981. }});
  982. // A. 左侧源头馈线
  983. sourceNodes.forEach(s => {{
  984. const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  985. p.setAttribute('d', `M ${{s.outputPoint.x}} ${{s.outputPoint.y}} L ${{busX}} ${{s.outputPoint.y}}`);
  986. p.classList.add('edge-path', 'feeder');
  987. p.style.cursor = 'pointer';
  988. p.addEventListener('click', handleGroupClick);
  989. g.appendChild(p);
  990. const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  991. dot.setAttribute('cx', s.outputPoint.x); dot.setAttribute('cy', s.outputPoint.y);
  992. dot.setAttribute('r', 3); dot.classList.add('connector-dot');
  993. dot.style.cursor = 'pointer';
  994. dot.addEventListener('click', handleGroupClick);
  995. g.appendChild(dot);
  996. }});
  997. // B. 左侧主干
  998. if (sMinY !== sMaxY) {{
  999. const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1000. trunk.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{sMaxY}}`);
  1001. trunk.classList.add('edge-path', 'trunk');
  1002. trunk.style.cursor = 'pointer';
  1003. trunk.addEventListener('click', handleGroupClick);
  1004. g.appendChild(trunk);
  1005. }}
  1006. // C. 长连接线 (连接源头区域到主线高度)
  1007. if (mainY < sMinY) {{
  1008. const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1009. link.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{mainY}}`);
  1010. link.classList.add('edge-path', 'trunk');
  1011. link.style.cursor = 'pointer';
  1012. link.addEventListener('click', handleGroupClick);
  1013. g.appendChild(link);
  1014. }} else if (mainY > sMaxY) {{
  1015. const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1016. link.setAttribute('d', `M ${{busX}} ${{sMaxY}} L ${{busX}} ${{mainY}}`);
  1017. link.classList.add('edge-path', 'trunk');
  1018. link.style.cursor = 'pointer';
  1019. link.addEventListener('click', handleGroupClick);
  1020. g.appendChild(link);
  1021. }}
  1022. }}
  1023. // 核心连线(无论是否有源节点都要渲染)
  1024. const mainLine = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1025. mainLine.setAttribute('d', `M ${{busX}} ${{mainY}} L ${{forkX}} ${{mainY}}`);
  1026. mainLine.classList.add('edge-path', 'main-flow');
  1027. mainLine.style.cursor = 'pointer';
  1028. mainLine.addEventListener('click', handleGroupClick);
  1029. g.appendChild(mainLine);
  1030. // D. 右侧分叉主干
  1031. if (targets.length > 1) {{
  1032. const fork = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1033. fork.setAttribute('d', `M ${{forkX}} ${{mainY}} L ${{forkX}} ${{tMaxY}}`);
  1034. fork.classList.add('edge-path', 'trunk');
  1035. fork.style.cursor = 'pointer';
  1036. fork.addEventListener('click', handleGroupClick);
  1037. g.appendChild(fork);
  1038. }}
  1039. // E. 目标接入线
  1040. targets.forEach(t => {{
  1041. const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
  1042. p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
  1043. p.classList.add('edge-path', 'entry');
  1044. p.setAttribute('marker-end', 'url(#arrow-head)');
  1045. p.style.cursor = 'pointer';
  1046. p.addEventListener('click', handleGroupClick);
  1047. g.appendChild(p);
  1048. if (targets.length > 1) {{
  1049. const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  1050. dot.setAttribute('cx', forkX); dot.setAttribute('cy', t.inputPoint.y);
  1051. dot.setAttribute('r', 2); dot.classList.add('connector-dot');
  1052. dot.style.cursor = 'pointer';
  1053. dot.addEventListener('click', handleGroupClick);
  1054. g.appendChild(dot);
  1055. }}
  1056. }});
  1057. // 连接点标记(只在有源节点时显示)
  1058. if (sourceNodes.length > 0) {{
  1059. const busDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
  1060. busDot.setAttribute('cx', busX); busDot.setAttribute('cy', mainY);
  1061. busDot.setAttribute('r', 3); busDot.classList.add('connector-dot');
  1062. busDot.style.cursor = 'pointer';
  1063. busDot.addEventListener('click', handleGroupClick);
  1064. g.appendChild(busDot);
  1065. }}
  1066. // F. 文字标签
  1067. if (group.edgeName) {{
  1068. const textX = (busX + forkX) / 2;
  1069. const textY = mainY - 5;
  1070. const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  1071. text.setAttribute('x', textX); text.setAttribute('y', textY);
  1072. text.classList.add('edge-label-text');
  1073. text.textContent = group.edgeName;
  1074. text.style.cursor = 'pointer';
  1075. text.addEventListener('click', handleGroupClick);
  1076. g.appendChild(text);
  1077. // 仅当边数据中有 score 字段时才在连线下方显示条件概率
  1078. const hasScore = group.edgeData && group.edgeData.score !== undefined && group.edgeData.score !== null;
  1079. if (hasScore) {{
  1080. const subText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
  1081. subText.setAttribute('x', textX);
  1082. subText.setAttribute('y', mainY + 14);
  1083. subText.classList.add('edge-label-sub');
  1084. let labelPrefix = "条件概率";
  1085. if (group.edgeName && group.edgeName.startsWith("外部搜索")) {{
  1086. labelPrefix = "搜索出现概率";
  1087. }}
  1088. subText.textContent = `${{labelPrefix}}:${{group.edgeScore}}`;
  1089. subText.style.cursor = 'pointer';
  1090. subText.addEventListener('click', handleGroupClick);
  1091. g.appendChild(subText);
  1092. }}
  1093. }}
  1094. svg.appendChild(g);
  1095. }});
  1096. canvas.insertBefore(svg, canvas.firstChild);
  1097. }}
  1098. // 处理边的点击事件
  1099. function handleEdgeClick(group) {{
  1100. // 保存当前选中的边组
  1101. currentSelectedEdgeGroup = group;
  1102. currentSelectedNode = null; // 清除节点选中状态
  1103. // 获取边的详细信息
  1104. const edgeData = group.edgeData || {{}};
  1105. const edgeDetail = edgeData.detail || {{}};
  1106. const edgeName = group.edgeName || '';
  1107. const edgeScore = group.edgeScore || 0;
  1108. // 获取目标节点名称(用于外部边和工具边的展示)
  1109. const targetNames = group.targets.map(t => t.name) || [];
  1110. const targetNodeName = targetNames.length > 0 ? targetNames[0] : '';
  1111. // 构建边的完整数据对象
  1112. const fullEdgeData = {{
  1113. name: targetNodeName, // 用于外部边和工具边的展示
  1114. edgeName: edgeName,
  1115. edgeScore: edgeScore,
  1116. sources: group.sources || [],
  1117. targets: targetNames,
  1118. type: edgeName.includes('外部搜索') || edgeName.includes('外部寻找') ? '外部边' :
  1119. edgeName.includes('工具') ? '工具边' : '普通边',
  1120. ...edgeData
  1121. }};
  1122. // 高亮相关的节点和边
  1123. highlightEdgeGroup(group);
  1124. // 显示边的详情(先打开侧边栏)
  1125. const sourceNames = group.sources.join('、');
  1126. const targetNamesStr = targetNames.join('、');
  1127. const title = `连线: ${{sourceNames}} → ${{targetNamesStr}}`;
  1128. showSidebar(edgeDetail, title, fullEdgeData, 'edge');
  1129. }}
  1130. // 计算边组相关节点的边界框并缩放显示
  1131. function fitEdgeGroupToView(group, useAnimation = true) {{
  1132. if (!group) return;
  1133. // 收集所有相关节点(源节点和目标节点)
  1134. const relatedNodes = new Set();
  1135. // 添加所有源节点(需要根据来源区分查找)
  1136. const sourceMap = group.sourceMap || {{}};
  1137. group.sources.forEach(sName => {{
  1138. const sourceType = sourceMap[sName];
  1139. let sNode = null;
  1140. if (sourceType === 'tree') {{
  1141. // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
  1142. const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
  1143. sNode = levelMinusOneNodes.find(n => n.name === sName);
  1144. }} else {{
  1145. // input_post_nodes:从推导节点中查找(排除 level -1)
  1146. const candidate = flatData.map[sName];
  1147. if (candidate && candidate.level !== -1) {{
  1148. sNode = candidate;
  1149. }} else {{
  1150. // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
  1151. for (let level in flatData.nodesByLevel) {{
  1152. const levelNum = parseInt(level);
  1153. if (levelNum !== -1) {{
  1154. const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
  1155. if (found) {{
  1156. sNode = found;
  1157. break;
  1158. }}
  1159. }}
  1160. }}
  1161. }}
  1162. }}
  1163. if (sNode && sNode.x !== undefined) {{
  1164. relatedNodes.add(sNode);
  1165. }}
  1166. }});
  1167. // 添加所有目标节点
  1168. group.targets.forEach(t => {{
  1169. if (t && t.x !== undefined) {{
  1170. relatedNodes.add(t);
  1171. }}
  1172. }});
  1173. // 如果没有任何节点,直接返回
  1174. if (relatedNodes.size === 0) return;
  1175. // 计算边界框(包括节点和连线可能延伸的区域)
  1176. let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  1177. relatedNodes.forEach(node => {{
  1178. if (node.x !== undefined && node.y !== undefined) {{
  1179. // 节点本身的边界
  1180. minX = Math.min(minX, node.x);
  1181. minY = Math.min(minY, node.y);
  1182. maxX = Math.max(maxX, node.x + (node.width || 0));
  1183. maxY = Math.max(maxY, node.y + (node.height || 0));
  1184. // 考虑连线可能延伸到左侧(busOffset)
  1185. if (node.inputPoint) {{
  1186. const leftExtend = node.inputPoint.x - CONFIG.busOffset;
  1187. minX = Math.min(minX, leftExtend);
  1188. }}
  1189. if (node.outputPoint) {{
  1190. const rightExtend = node.outputPoint.x + CONFIG.busOffset;
  1191. maxX = Math.max(maxX, rightExtend);
  1192. }}
  1193. }}
  1194. }});
  1195. // 如果边界框无效,直接返回
  1196. if (minX === Infinity) return;
  1197. // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
  1198. const padding = 40;
  1199. const contentWidth = maxX - minX + padding * 2;
  1200. const contentHeight = maxY - minY + padding * 2;
  1201. const contentCenterX = (minX + maxX) / 2;
  1202. const contentCenterY = (minY + maxY) / 2;
  1203. // 获取视口大小(考虑侧边栏是否打开)
  1204. const sidebar = document.getElementById('sidebar');
  1205. const isSidebarOpen = sidebar && sidebar.classList.contains('active');
  1206. // 当侧边栏打开时,画布宽度会缩小(减去侧边栏实际宽度)
  1207. const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
  1208. const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
  1209. const viewH = container.offsetHeight;
  1210. // 计算缩放比例,确保内容能完全显示
  1211. const scaleX = (viewW - padding * 2) / contentWidth;
  1212. const scaleY = (viewH - padding * 2) / contentHeight;
  1213. // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
  1214. scale = Math.min(scaleX, scaleY, 2.0);
  1215. // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
  1216. const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
  1217. translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
  1218. translateY = (viewH / 2) - (contentCenterY * scale);
  1219. // 根据参数决定是否使用动画
  1220. if (useAnimation) {{
  1221. canvas.classList.add('animating');
  1222. updateTransform();
  1223. setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
  1224. }} else {{
  1225. // 移除动画类,确保瞬间完成
  1226. canvas.classList.remove('animating');
  1227. updateTransform();
  1228. }}
  1229. }}
  1230. // 高亮边组
  1231. function highlightEdgeGroup(group) {{
  1232. // Reset
  1233. document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
  1234. el.classList.remove('highlight');
  1235. el.classList.add('dimmed');
  1236. }});
  1237. document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
  1238. el.classList.remove('highlight');
  1239. el.classList.add('dimmed');
  1240. }});
  1241. document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
  1242. document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
  1243. // 高亮源节点(根据来源区分查找)
  1244. const sourceMap = group.sourceMap || {{}};
  1245. group.sources.forEach(sourceName => {{
  1246. const sourceType = sourceMap[sourceName];
  1247. let sourceNode = null;
  1248. if (sourceType === 'tree') {{
  1249. // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
  1250. const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
  1251. sourceNode = levelMinusOneNodes.find(n => n.name === sourceName);
  1252. }} else {{
  1253. // input_post_nodes:从推导节点中查找(排除 level -1)
  1254. const candidate = flatData.map[sourceName];
  1255. if (candidate && candidate.level !== -1) {{
  1256. sourceNode = candidate;
  1257. }} else {{
  1258. // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
  1259. for (let level in flatData.nodesByLevel) {{
  1260. const levelNum = parseInt(level);
  1261. if (levelNum !== -1) {{
  1262. const found = flatData.nodesByLevel[levelNum].find(n => n.name === sourceName);
  1263. if (found) {{
  1264. sourceNode = found;
  1265. break;
  1266. }}
  1267. }}
  1268. }}
  1269. }}
  1270. }}
  1271. if (sourceNode && sourceNode.el) {{
  1272. sourceNode.el.classList.remove('dimmed');
  1273. sourceNode.el.classList.add('highlight');
  1274. }}
  1275. }});
  1276. // 高亮目标节点
  1277. group.targets.forEach(target => {{
  1278. if (target.el) {{
  1279. target.el.classList.remove('dimmed');
  1280. target.el.classList.add('highlight');
  1281. }}
  1282. }});
  1283. // 高亮边组
  1284. const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
  1285. if (edgeGroupEl) {{
  1286. Array.from(edgeGroupEl.children).forEach(child => {{
  1287. child.classList.remove('dimmed');
  1288. child.classList.add('highlight');
  1289. if (child.classList.contains('entry')) {{
  1290. child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
  1291. }}
  1292. }});
  1293. }}
  1294. }}
  1295. // 4. 交互:高亮组
  1296. function highlightDirectSources(targetNode) {{
  1297. // Reset
  1298. document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
  1299. el.classList.remove('highlight');
  1300. el.classList.add('dimmed');
  1301. }});
  1302. document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
  1303. el.classList.remove('highlight');
  1304. el.classList.add('dimmed');
  1305. }});
  1306. document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
  1307. document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
  1308. let nodesToHighlight = [targetNode];
  1309. if (targetNode.edgeGroupKey) {{
  1310. const group = edgeGroups[targetNode.edgeGroupKey];
  1311. if (group) {{
  1312. nodesToHighlight = group.targets;
  1313. const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
  1314. if (edgeGroupEl) {{
  1315. Array.from(edgeGroupEl.children).forEach(child => {{
  1316. child.classList.remove('dimmed');
  1317. child.classList.add('highlight');
  1318. if(child.classList.contains('entry')) {{
  1319. child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
  1320. }}
  1321. }});
  1322. }}
  1323. // 高亮源节点(根据来源区分查找;同名多轮时取作为“源”的那一轮)
  1324. const sourceMap = group.sourceMap || {{}};
  1325. const minTargetLevel = group.targets.length ? Math.min(...group.targets.map(t => t.level)) : 0;
  1326. group.sources.forEach(sName => {{
  1327. const sourceType = sourceMap[sName];
  1328. let sNode = null;
  1329. if (sourceType === 'tree') {{
  1330. const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
  1331. sNode = levelMinusOneNodes.find(n => n.name === sName);
  1332. }} else {{
  1333. // input_post_nodes:取层级低于目标且同名的节点(从最接近目标的一轮开始找)
  1334. for (let l = minTargetLevel - 1; l >= 0; l--) {{
  1335. const found = (flatData.nodesByLevel[l] || []).find(n => n.name === sName);
  1336. if (found) {{ sNode = found; break; }}
  1337. }}
  1338. if (!sNode) {{
  1339. const candidate = flatData.map[sName];
  1340. if (candidate && candidate.level !== -1) sNode = candidate;
  1341. }}
  1342. }}
  1343. if (sNode && sNode.el) {{
  1344. sNode.el.classList.remove('dimmed');
  1345. sNode.el.classList.add('highlight');
  1346. }}
  1347. }});
  1348. }}
  1349. }}
  1350. nodesToHighlight.forEach(n => {{
  1351. if (n.el) {{
  1352. n.el.classList.remove('dimmed');
  1353. n.el.classList.add('highlight');
  1354. }}
  1355. }});
  1356. }}
  1357. // --- 视图控制 ---
  1358. let scale = 0.8, translateX = 50, translateY = 50;
  1359. let isDragging = false, startClientX, startClientY, startTranslateX, startTranslateY;
  1360. const DRAG_SENSITIVITY = 1.35; // 拖拽灵敏度,>1 更跟手
  1361. let currentSelectedNode = null; // 跟踪当前选中的节点
  1362. let currentSelectedEdgeGroup = null; // 跟踪当前选中的边组
  1363. const container = document.getElementById('app-container');
  1364. function updateTransform() {{
  1365. // 采用先缩放再平移的顺序,使平移量与缩放无关,便于以视图中心进行缩放和平移
  1366. canvas.style.transform = `scale(${{scale}}) translate(${{translateX}}px, ${{translateY}}px)`;
  1367. }}
  1368. // 计算相关节点的边界框并缩放显示(当前节点及其连线上的节点)
  1369. function fitRelatedNodesToView(targetNode, useAnimation = true) {{
  1370. if (!targetNode || targetNode.x === undefined) return;
  1371. // 收集所有相关节点
  1372. const relatedNodes = new Set();
  1373. relatedNodes.add(targetNode);
  1374. // 如果节点有边组,添加同组的其他目标节点
  1375. if (targetNode.edgeGroupKey) {{
  1376. const group = edgeGroups[targetNode.edgeGroupKey];
  1377. if (group) {{
  1378. // 添加同组的所有目标节点
  1379. group.targets.forEach(t => {{
  1380. if (t && t.x !== undefined) relatedNodes.add(t);
  1381. }});
  1382. // 添加所有源节点(需要根据来源区分查找)
  1383. const sourceMap = group.sourceMap || {{}};
  1384. group.sources.forEach(sName => {{
  1385. const sourceType = sourceMap[sName];
  1386. let sNode = null;
  1387. if (sourceType === 'tree') {{
  1388. // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
  1389. const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
  1390. sNode = levelMinusOneNodes.find(n => n.name === sName);
  1391. }} else {{
  1392. // input_post_nodes:从推导节点中查找(排除 level -1)
  1393. const candidate = flatData.map[sName];
  1394. if (candidate && candidate.level !== -1) {{
  1395. sNode = candidate;
  1396. }} else {{
  1397. // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
  1398. for (let level in flatData.nodesByLevel) {{
  1399. const levelNum = parseInt(level);
  1400. if (levelNum !== -1) {{
  1401. const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
  1402. if (found) {{
  1403. sNode = found;
  1404. break;
  1405. }}
  1406. }}
  1407. }}
  1408. }}
  1409. }}
  1410. if (sNode && sNode.x !== undefined) {{
  1411. relatedNodes.add(sNode);
  1412. }}
  1413. }});
  1414. }}
  1415. }}
  1416. const sidebar = document.getElementById('sidebar');
  1417. const isSidebarOpen = sidebar && sidebar.classList.contains('active');
  1418. const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
  1419. const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
  1420. const viewH = container.offsetHeight;
  1421. // 仅当节点没有连线(只有一个相关节点)时:只平移使节点居中,不改变缩放,避免视图被放大超出
  1422. if (relatedNodes.size === 1) {{
  1423. const nodeCenterX = targetNode.x + (targetNode.width || 0) / 2;
  1424. const nodeCenterY = targetNode.y + (targetNode.height || 0) / 2;
  1425. const offsetX = isSidebarOpen ? 0 : 0;
  1426. translateX = (viewW / 2) - (nodeCenterX * scale) + offsetX;
  1427. translateY = (viewH / 2) - (nodeCenterY * scale);
  1428. if (useAnimation) {{
  1429. canvas.classList.add('animating');
  1430. updateTransform();
  1431. setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
  1432. }} else {{
  1433. canvas.classList.remove('animating');
  1434. updateTransform();
  1435. }}
  1436. return;
  1437. }}
  1438. // 计算边界框(包括节点和连线可能延伸的区域)
  1439. let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
  1440. relatedNodes.forEach(node => {{
  1441. if (node.x !== undefined && node.y !== undefined) {{
  1442. // 节点本身的边界
  1443. minX = Math.min(minX, node.x);
  1444. minY = Math.min(minY, node.y);
  1445. maxX = Math.max(maxX, node.x + (node.width || 0));
  1446. maxY = Math.max(maxY, node.y + (node.height || 0));
  1447. // 考虑连线可能延伸到左侧(busOffset)
  1448. if (node.inputPoint) {{
  1449. const leftExtend = node.inputPoint.x - CONFIG.busOffset;
  1450. minX = Math.min(minX, leftExtend);
  1451. }}
  1452. }}
  1453. }});
  1454. // 如果边界框无效,直接返回
  1455. if (minX === Infinity) return;
  1456. // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
  1457. const padding = 40;
  1458. const contentWidth = maxX - minX + padding * 2;
  1459. const contentHeight = maxY - minY + padding * 2;
  1460. const contentCenterX = (minX + maxX) / 2;
  1461. const contentCenterY = (minY + maxY) / 2;
  1462. // 计算缩放比例,确保内容能完全显示
  1463. const scaleX = (viewW - padding * 2) / contentWidth;
  1464. const scaleY = (viewH - padding * 2) / contentHeight;
  1465. // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
  1466. scale = Math.min(scaleX, scaleY, 2.0);
  1467. // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
  1468. const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
  1469. translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
  1470. translateY = (viewH / 2) - (contentCenterY * scale);
  1471. // 根据参数决定是否使用动画
  1472. if (useAnimation) {{
  1473. canvas.classList.add('animating');
  1474. updateTransform();
  1475. setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
  1476. }} else {{
  1477. // 移除动画类,确保瞬间完成
  1478. canvas.classList.remove('animating');
  1479. updateTransform();
  1480. }}
  1481. }}
  1482. let hasDragged = false; // 标记是否发生了拖动
  1483. container.addEventListener('click', e => {{
  1484. // 只有在没有拖动的情况下才重置视图
  1485. if (!hasDragged && (e.target.id === 'app-container' || e.target.id === 'canvas' || e.target.classList.contains('edge-layer'))) {{
  1486. resetView();
  1487. }}
  1488. // 处理完点击事件后重置标志
  1489. hasDragged = false;
  1490. }});
  1491. function resetView() {{
  1492. document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
  1493. document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
  1494. document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
  1495. document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
  1496. document.getElementById('search-input').value = '';
  1497. currentSelectedNode = null; // 清除当前选中的节点
  1498. currentSelectedEdgeGroup = null; // 清除当前选中的边组
  1499. closeSidebar();
  1500. }}
  1501. const searchInput = document.getElementById('search-input');
  1502. searchInput.addEventListener('input', (e) => {{
  1503. const val = e.target.value.toLowerCase();
  1504. document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
  1505. const name = el.innerText.toLowerCase();
  1506. if(!val) el.classList.remove('dimmed');
  1507. else if (name.includes(val)) el.classList.remove('dimmed');
  1508. else el.classList.add('dimmed');
  1509. }});
  1510. }});
  1511. searchInput.addEventListener('keydown', (e) => {{
  1512. if (e.key === 'Enter') {{
  1513. const val = searchInput.value.toLowerCase();
  1514. if (!val) return;
  1515. const match = Object.values(flatData.map).find(n => n.name.toLowerCase().includes(val));
  1516. if (match) focusOnNode(match);
  1517. }}
  1518. }});
  1519. function focusOnNode(node) {{
  1520. const nodeCenterX = node.x + node.width / 2;
  1521. const nodeCenterY = node.y + node.height / 2;
  1522. const viewW = container.offsetWidth;
  1523. const viewH = container.offsetHeight;
  1524. translateX = (viewW / 2) - (nodeCenterX * scale);
  1525. translateY = (viewH / 2) - (nodeCenterY * scale);
  1526. canvas.classList.add('animating');
  1527. updateTransform();
  1528. setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
  1529. highlightDirectSources(node);
  1530. }}
  1531. container.addEventListener('mousedown', e => {{
  1532. if (e.target === container || e.target.id === 'canvas' || e.target.classList.contains('edge-layer')) {{
  1533. isDragging = true;
  1534. hasDragged = false; // 重置拖动标志
  1535. startClientX = e.clientX;
  1536. startClientY = e.clientY;
  1537. startTranslateX = translateX;
  1538. startTranslateY = translateY;
  1539. container.classList.add('grabbing');
  1540. }}
  1541. }});
  1542. window.addEventListener('mousemove', e => {{
  1543. if (isDragging) {{
  1544. e.preventDefault();
  1545. translateX = startTranslateX + (e.clientX - startClientX) * DRAG_SENSITIVITY;
  1546. translateY = startTranslateY + (e.clientY - startClientY) * DRAG_SENSITIVITY;
  1547. updateTransform();
  1548. hasDragged = true; // 标记发生了拖动
  1549. }}
  1550. }});
  1551. window.addEventListener('mouseup', () => {{
  1552. isDragging = false;
  1553. container.classList.remove('grabbing');
  1554. }});
  1555. container.addEventListener('wheel', e => {{
  1556. e.preventDefault();
  1557. // 以当前视图中心为缩放中心,保证缩放时画面不会“往左上角跑”
  1558. const viewW = container.offsetWidth;
  1559. const viewH = container.offsetHeight;
  1560. const centerScreenX = viewW / 2;
  1561. const centerScreenY = viewH / 2;
  1562. // 当前视图中心对应的画布坐标(world coords)
  1563. const centerWorldX = (centerScreenX - translateX) / scale;
  1564. const centerWorldY = (centerScreenY - translateY) / scale;
  1565. // 更小的缩放步长,让滚轮缩放更平滑
  1566. const zoomStep = 0.05; // 每次滚轮约 5% 的缩放变化
  1567. const zoomFactor = e.deltaY > 0 ? (1 - zoomStep) : (1 + zoomStep);
  1568. const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
  1569. // 根据新的缩放比例,调整平移量,使视图中心保持不动
  1570. translateX = centerScreenX - centerWorldX * newScale;
  1571. translateY = centerScreenY - centerWorldY * newScale;
  1572. scale = newScale;
  1573. updateTransform();
  1574. }}, {{ passive: false }});
  1575. function escapeHtml(text) {{
  1576. if (!text) return "";
  1577. const div = document.createElement("div");
  1578. div.textContent = text;
  1579. return div.innerHTML;
  1580. }}
  1581. function showSidebar(detail, title, fullData, sidebarType) {{
  1582. const container = document.getElementById('sidebar-content');
  1583. const sidebar = document.getElementById('sidebar');
  1584. const appContainer = document.getElementById('app-container');
  1585. const titleEl = document.getElementById('sidebar-title');
  1586. if (titleEl) titleEl.textContent = (sidebarType === 'edge') ? '边详情' : '节点详情';
  1587. container.innerHTML = '';
  1588. if (fullData && (fullData.id === "root" || fullData.name === "root")) {{
  1589. renderRootDetail(detail, container);
  1590. sidebar.classList.add('active');
  1591. appContainer.classList.add('sidebar-open');
  1592. updateCanvasWidth();
  1593. return;
  1594. }}
  1595. // 添加标题
  1596. const titleDiv = document.createElement('div');
  1597. titleDiv.className = 'detail-item';
  1598. const titleLabel = document.createElement('label');
  1599. titleLabel.textContent = title || '节点详情';
  1600. titleDiv.appendChild(titleLabel);
  1601. container.appendChild(titleDiv);
  1602. // 检查是否是外部边或工具边(通过 edgeName 判断)
  1603. const edgeName = fullData?.edgeName || '';
  1604. const isExternalEdge = edgeName && (edgeName.includes('外部搜索') || edgeName.includes('外部寻找'));
  1605. const isToolEdge = edgeName && edgeName.includes('工具');
  1606. // 外部边:特殊展示逻辑
  1607. if (isExternalEdge) {{
  1608. renderExternalEdgeDetail(detail, container, fullData?.name || '');
  1609. if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
  1610. const scoreItem = document.createElement('div');
  1611. scoreItem.className = 'detail-item';
  1612. const scoreLabel = document.createElement('label');
  1613. scoreLabel.textContent = 'Score:';
  1614. scoreItem.appendChild(scoreLabel);
  1615. const scoreVal = document.createElement('div');
  1616. scoreVal.className = 'detail-val';
  1617. scoreVal.textContent = fullData.edgeScore.toFixed(4);
  1618. scoreItem.appendChild(scoreVal);
  1619. container.appendChild(scoreItem);
  1620. }}
  1621. sidebar.classList.add('active');
  1622. appContainer.classList.add('sidebar-open');
  1623. updateCanvasWidth();
  1624. return;
  1625. }}
  1626. // 工具边:特殊展示逻辑
  1627. if (isToolEdge) {{
  1628. renderToolEdgeDetail(detail, container, fullData?.name || '');
  1629. if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
  1630. const scoreItem = document.createElement('div');
  1631. scoreItem.className = 'detail-item';
  1632. const scoreLabel = document.createElement('label');
  1633. scoreLabel.textContent = 'Score:';
  1634. scoreItem.appendChild(scoreLabel);
  1635. const scoreVal = document.createElement('div');
  1636. scoreVal.className = 'detail-val';
  1637. scoreVal.textContent = fullData.edgeScore.toFixed(4);
  1638. scoreItem.appendChild(scoreVal);
  1639. container.appendChild(scoreItem);
  1640. }}
  1641. sidebar.classList.add('active');
  1642. appContainer.classList.add('sidebar-open');
  1643. updateCanvasWidth();
  1644. return;
  1645. }}
  1646. // 过滤掉路径相关的字段
  1647. const pathFields = ['source', 'target', 'id', 'originalData', 'internal', 'external', 'internal_edge', 'external_edge', 'children', 'parent_edge', 'sources', 'edgeName', 'edgeScore', 'edgeGroupKey'];
  1648. // 过滤detail中的路径字段
  1649. const filteredDetail = {{}};
  1650. if (detail && typeof detail === "object") {{
  1651. Object.entries(detail).forEach(([key, value]) => {{
  1652. if (!pathFields.includes(key)) {{
  1653. filteredDetail[key] = value;
  1654. }}
  1655. }});
  1656. }}
  1657. // 如果没有detail内容
  1658. if (!filteredDetail || Object.keys(filteredDetail).length === 0) {{
  1659. const emptyDiv = document.createElement('div');
  1660. emptyDiv.className = 'detail-empty';
  1661. emptyDiv.textContent = '暂无详情信息';
  1662. container.appendChild(emptyDiv);
  1663. sidebar.classList.add('active');
  1664. appContainer.classList.add('sidebar-open');
  1665. updateCanvasWidth();
  1666. return;
  1667. }}
  1668. // 显示detail内容
  1669. Object.entries(filteredDetail).forEach(([key, value]) => {{
  1670. // 跳过空值
  1671. if (value === null || value === undefined || value === "") return;
  1672. const item = document.createElement('div');
  1673. item.className = 'detail-item';
  1674. const label = document.createElement('label');
  1675. label.textContent = key + ':';
  1676. item.appendChild(label);
  1677. if (typeof value === "object" && value !== null && !Array.isArray(value)) {{
  1678. // 对象结构,展示 KV 列表
  1679. const subContainer = document.createElement('div');
  1680. subContainer.className = 'detail-val';
  1681. subContainer.style.paddingLeft = '15px';
  1682. subContainer.style.borderLeft = '3px solid #eee';
  1683. subContainer.style.marginTop = '10px';
  1684. subContainer.style.fontSize = '14px';
  1685. Object.entries(value).forEach(([subKey, subValue]) => {{
  1686. if (subValue === null || subValue === undefined || subValue === "") return;
  1687. const subItem = document.createElement('div');
  1688. subItem.style.marginBottom = '8px';
  1689. const subKeySpan = document.createElement('span');
  1690. subKeySpan.style.color = '#666';
  1691. subKeySpan.textContent = subKey + ': ';
  1692. subItem.appendChild(subKeySpan);
  1693. const subValSpan = document.createElement('span');
  1694. subValSpan.textContent = typeof subValue === 'object' ? JSON.stringify(subValue) : subValue;
  1695. subItem.appendChild(subValSpan);
  1696. subContainer.appendChild(subItem);
  1697. }});
  1698. item.appendChild(subContainer);
  1699. }} else {{
  1700. const valueContainer = document.createElement('div');
  1701. valueContainer.className = 'detail-val';
  1702. if (Array.isArray(value)) {{
  1703. if (value.length === 0) return;
  1704. // 检查数组元素是否为对象
  1705. const isArrayOfObjects = value.length > 0 && typeof value[0] === 'object' && value[0] !== null;
  1706. if (isArrayOfObjects) {{
  1707. // 数组元素为对象时,使用表格展示
  1708. const table = document.createElement('table');
  1709. table.style.width = '100%';
  1710. table.style.borderCollapse = 'collapse';
  1711. table.style.fontSize = '13px';
  1712. table.style.marginTop = '5px';
  1713. // 统计所有列名(字段)
  1714. const columnsSet = new Set();
  1715. value.forEach(v => {{
  1716. if (v && typeof v === 'object') {{
  1717. Object.keys(v).forEach(k => columnsSet.add(k));
  1718. }}
  1719. }});
  1720. const columns = Array.from(columnsSet);
  1721. // 表头
  1722. const thead = document.createElement('thead');
  1723. const headerRow = document.createElement('tr');
  1724. const thIndex = document.createElement('th');
  1725. thIndex.textContent = '#';
  1726. thIndex.style.borderBottom = '1px solid #eee';
  1727. thIndex.style.padding = '4px 6px';
  1728. thIndex.style.textAlign = 'left';
  1729. thIndex.style.color = '#888';
  1730. headerRow.appendChild(thIndex);
  1731. columns.forEach(col => {{
  1732. const th = document.createElement('th');
  1733. th.textContent = col;
  1734. th.style.borderBottom = '1px solid #eee';
  1735. th.style.padding = '4px 6px';
  1736. th.style.textAlign = 'left';
  1737. th.style.color = '#555';
  1738. headerRow.appendChild(th);
  1739. }});
  1740. thead.appendChild(headerRow);
  1741. table.appendChild(thead);
  1742. // 表体
  1743. const tbody = document.createElement('tbody');
  1744. value.forEach((v, i) => {{
  1745. const row = document.createElement('tr');
  1746. row.style.borderBottom = '1px dashed #f0f0f0';
  1747. const tdIndex = document.createElement('td');
  1748. tdIndex.textContent = i + 1;
  1749. tdIndex.style.padding = '4px 6px';
  1750. tdIndex.style.color = '#999';
  1751. row.appendChild(tdIndex);
  1752. columns.forEach(col => {{
  1753. const cellVal = v && typeof v === 'object' ? v[col] : undefined;
  1754. const td = document.createElement('td');
  1755. td.textContent = cellVal === undefined || cellVal === null
  1756. ? ""
  1757. : (typeof cellVal === 'object' ? JSON.stringify(cellVal) : cellVal);
  1758. td.style.padding = '4px 6px';
  1759. td.style.color = '#666';
  1760. td.style.verticalAlign = 'top';
  1761. row.appendChild(td);
  1762. }});
  1763. tbody.appendChild(row);
  1764. }});
  1765. table.appendChild(tbody);
  1766. valueContainer.appendChild(table);
  1767. }} else {{
  1768. // 普通数组:每个元素一行的单列表格
  1769. const table = document.createElement('table');
  1770. table.style.width = '100%';
  1771. table.style.borderCollapse = 'collapse';
  1772. table.style.fontSize = '13px';
  1773. table.style.marginTop = '5px';
  1774. const thead = document.createElement('thead');
  1775. const headerRow = document.createElement('tr');
  1776. const thIndex = document.createElement('th');
  1777. thIndex.textContent = '#';
  1778. thIndex.style.borderBottom = '1px solid #eee';
  1779. thIndex.style.padding = '4px 6px';
  1780. thIndex.style.textAlign = 'left';
  1781. thIndex.style.color = '#888';
  1782. headerRow.appendChild(thIndex);
  1783. const thValue = document.createElement('th');
  1784. thValue.textContent = 'value';
  1785. thValue.style.borderBottom = '1px solid #eee';
  1786. thValue.style.padding = '4px 6px';
  1787. thValue.style.textAlign = 'left';
  1788. thValue.style.color = '#555';
  1789. headerRow.appendChild(thValue);
  1790. thead.appendChild(headerRow);
  1791. table.appendChild(thead);
  1792. const tbody = document.createElement('tbody');
  1793. value.forEach((v, i) => {{
  1794. const row = document.createElement('tr');
  1795. row.style.borderBottom = '1px dashed #f0f0f0';
  1796. const tdIndex = document.createElement('td');
  1797. tdIndex.textContent = i + 1;
  1798. tdIndex.style.padding = '4px 6px';
  1799. tdIndex.style.color = '#999';
  1800. row.appendChild(tdIndex);
  1801. const tdValue = document.createElement('td');
  1802. tdValue.textContent = typeof v === 'object' ? JSON.stringify(v) : v;
  1803. tdValue.style.padding = '4px 6px';
  1804. tdValue.style.color = '#666';
  1805. tdValue.style.verticalAlign = 'top';
  1806. row.appendChild(tdValue);
  1807. tbody.appendChild(row);
  1808. }});
  1809. table.appendChild(tbody);
  1810. valueContainer.appendChild(table);
  1811. }}
  1812. }} else {{
  1813. let displayValue = value;
  1814. if (typeof value === "string" && value.length > 500) {{
  1815. displayValue = value.substring(0, 500) + "...";
  1816. }}
  1817. valueContainer.textContent = displayValue;
  1818. }}
  1819. item.appendChild(valueContainer);
  1820. }}
  1821. container.appendChild(item);
  1822. }});
  1823. // 如果有score,也显示
  1824. if (fullData && fullData.score !== undefined && fullData.score !== null) {{
  1825. const scoreItem = document.createElement('div');
  1826. scoreItem.className = 'detail-item';
  1827. const scoreLabel = document.createElement('label');
  1828. scoreLabel.textContent = 'Score:';
  1829. scoreItem.appendChild(scoreLabel);
  1830. const scoreVal = document.createElement('div');
  1831. scoreVal.className = 'detail-val';
  1832. scoreVal.textContent = fullData.score.toFixed(4);
  1833. scoreItem.appendChild(scoreVal);
  1834. container.appendChild(scoreItem);
  1835. }}
  1836. sidebar.classList.add('active');
  1837. appContainer.classList.add('sidebar-open');
  1838. updateCanvasWidth();
  1839. }}
  1840. function renderExternalEdgeDetail(detail, container, targetNodeName) {{
  1841. if (!detail) return;
  1842. const targetName = targetNodeName || "";
  1843. // 1. 全局常量
  1844. const globalConstants = detail["全局常量"] || [];
  1845. if (Array.isArray(globalConstants) && globalConstants.length > 0) {{
  1846. const globalSection = document.createElement('div');
  1847. globalSection.className = 'detail-item';
  1848. const globalLabel = document.createElement('label');
  1849. globalLabel.textContent = '全局常量:';
  1850. globalSection.appendChild(globalLabel);
  1851. const globalValue = document.createElement('div');
  1852. globalValue.className = 'detail-val';
  1853. globalValue.style.marginTop = '8px';
  1854. globalValue.textContent = globalConstants.join('、');
  1855. globalSection.appendChild(globalValue);
  1856. container.appendChild(globalSection);
  1857. }}
  1858. // 2. 局部常量
  1859. const localConstants = detail["局部常量"] || [];
  1860. if (Array.isArray(localConstants) && localConstants.length > 0) {{
  1861. const localSection = document.createElement('div');
  1862. localSection.className = 'detail-item';
  1863. const localLabel = document.createElement('label');
  1864. localLabel.textContent = '局部常量:';
  1865. localSection.appendChild(localLabel);
  1866. const localValue = document.createElement('div');
  1867. localValue.className = 'detail-val';
  1868. localValue.style.marginTop = '8px';
  1869. localValue.textContent = localConstants.join('、');
  1870. localSection.appendChild(localValue);
  1871. container.appendChild(localSection);
  1872. }}
  1873. // 3. 匹配到的模式
  1874. const matchedPatterns = detail["匹配到的模式"] || [];
  1875. if (Array.isArray(matchedPatterns) && matchedPatterns.length > 0) {{
  1876. const patternSection = document.createElement('div');
  1877. patternSection.className = 'detail-item';
  1878. const patternLabel = document.createElement('label');
  1879. patternLabel.textContent = '匹配到的模式:';
  1880. patternSection.appendChild(patternLabel);
  1881. const patternValue = document.createElement('div');
  1882. patternValue.className = 'detail-val';
  1883. patternValue.style.marginTop = '8px';
  1884. container.appendChild(patternSection);
  1885. patternSection.appendChild(patternValue);
  1886. matchedPatterns.forEach((pattern, idx) => {{
  1887. const patternBlock = document.createElement('div');
  1888. patternBlock.style.marginBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
  1889. patternBlock.style.paddingBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
  1890. patternBlock.style.borderBottom = idx < matchedPatterns.length - 1 ? '1px solid #eee' : 'none';
  1891. // 显示 id 和 support
  1892. const patternHeader = document.createElement('div');
  1893. patternHeader.style.fontWeight = 'bold';
  1894. patternHeader.style.marginBottom = '6px';
  1895. patternHeader.style.fontSize = '13px';
  1896. patternHeader.textContent = '模式 ID: ' + (pattern.id || "") + ', Support: ' + (pattern.support !== undefined ? pattern.support.toFixed(4) : "");
  1897. patternBlock.appendChild(patternHeader);
  1898. // items 展示成表格
  1899. const items = pattern.items || [];
  1900. if (items.length > 0) {{
  1901. const itemsTable = document.createElement('table');
  1902. itemsTable.style.width = '100%';
  1903. itemsTable.style.borderCollapse = 'collapse';
  1904. itemsTable.style.fontSize = '13px';
  1905. itemsTable.style.marginTop = '6px';
  1906. itemsTable.style.marginBottom = '6px';
  1907. const thead = document.createElement('thead');
  1908. const headerRow = document.createElement('tr');
  1909. ["name", "point", "dimension", "type"].forEach(col => {{
  1910. const th = document.createElement('th');
  1911. th.textContent = col;
  1912. th.style.border = '1px solid #ddd';
  1913. th.style.padding = '6px 8px';
  1914. th.style.textAlign = 'left';
  1915. th.style.background = '#f5f5f5';
  1916. headerRow.appendChild(th);
  1917. }});
  1918. thead.appendChild(headerRow);
  1919. itemsTable.appendChild(thead);
  1920. const tbody = document.createElement('tbody');
  1921. items.forEach(item => {{
  1922. const tr = document.createElement('tr');
  1923. ["name", "point", "dimension", "type"].forEach(col => {{
  1924. const td = document.createElement('td');
  1925. td.textContent = item[col] || "";
  1926. td.style.border = '1px solid #ddd';
  1927. td.style.padding = '6px 8px';
  1928. tr.appendChild(td);
  1929. }});
  1930. tbody.appendChild(tr);
  1931. }});
  1932. itemsTable.appendChild(tbody);
  1933. patternBlock.appendChild(itemsTable);
  1934. }}
  1935. // match_points
  1936. const matchPoints = pattern.match_points || [];
  1937. if (matchPoints.length > 0) {{
  1938. const matchPointsDiv = document.createElement('div');
  1939. matchPointsDiv.style.fontSize = '13px';
  1940. matchPointsDiv.style.color = '#666';
  1941. matchPointsDiv.style.marginTop = '4px';
  1942. matchPointsDiv.textContent = '匹配点: ' + matchPoints.join('、');
  1943. patternBlock.appendChild(matchPointsDiv);
  1944. }}
  1945. patternValue.appendChild(patternBlock);
  1946. }});
  1947. }}
  1948. // 4. 动态常量
  1949. const dynamicConstants = detail["动态常量"] || [];
  1950. if (Array.isArray(dynamicConstants) && dynamicConstants.length > 0) {{
  1951. const dynamicSection = document.createElement('div');
  1952. dynamicSection.className = 'detail-item';
  1953. const dynamicLabel = document.createElement('label');
  1954. dynamicLabel.textContent = '动态常量:';
  1955. dynamicSection.appendChild(dynamicLabel);
  1956. const dynamicValue = document.createElement('div');
  1957. dynamicValue.className = 'detail-val';
  1958. dynamicValue.style.marginTop = '8px';
  1959. container.appendChild(dynamicSection);
  1960. dynamicSection.appendChild(dynamicValue);
  1961. const dynamicTable = document.createElement('table');
  1962. dynamicTable.style.width = '100%';
  1963. dynamicTable.style.borderCollapse = 'collapse';
  1964. dynamicTable.style.fontSize = '13px';
  1965. const thead = document.createElement('thead');
  1966. const headerRow = document.createElement('tr');
  1967. const columns = ["point", "tree_parent_node", "match_score", "tree_child_node", "relative_ratio"];
  1968. columns.forEach(col => {{
  1969. const th = document.createElement('th');
  1970. th.textContent = col;
  1971. th.style.border = '1px solid #ddd';
  1972. th.style.padding = '6px 8px';
  1973. th.style.textAlign = 'left';
  1974. th.style.background = '#f5f5f5';
  1975. headerRow.appendChild(th);
  1976. }});
  1977. thead.appendChild(headerRow);
  1978. dynamicTable.appendChild(thead);
  1979. // 计算每列的合并信息
  1980. const getValue = (dc, col) => {{
  1981. if (col === "match_score") return dc.match_score !== undefined ? dc.match_score.toFixed(4) : "";
  1982. if (col === "relative_ratio") return dc.relative_ratio !== undefined ? dc.relative_ratio.toFixed(4) : "";
  1983. return dc[col] || "";
  1984. }};
  1985. const getRowspan = (colIdx, rowIdx) => {{
  1986. const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
  1987. let span = 1;
  1988. for (let i = rowIdx + 1; i < dynamicConstants.length; i++) {{
  1989. const nextValue = getValue(dynamicConstants[i], columns[colIdx]);
  1990. if (nextValue === currentValue) {{
  1991. span++;
  1992. }} else {{
  1993. break;
  1994. }}
  1995. }}
  1996. return span;
  1997. }};
  1998. const shouldSkipCell = (colIdx, rowIdx) => {{
  1999. if (rowIdx === 0) return false;
  2000. const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
  2001. const prevValue = getValue(dynamicConstants[rowIdx - 1], columns[colIdx]);
  2002. return currentValue === prevValue;
  2003. }};
  2004. const tbody = document.createElement('tbody');
  2005. dynamicConstants.forEach((dc, rowIdx) => {{
  2006. const tr = document.createElement('tr');
  2007. columns.forEach((col, colIdx) => {{
  2008. if (shouldSkipCell(colIdx, rowIdx)) {{
  2009. // 跳过,因为会被上一行的 rowspan 覆盖
  2010. return;
  2011. }}
  2012. const cellValue = getValue(dc, col);
  2013. const span = getRowspan(colIdx, rowIdx);
  2014. const td = document.createElement('td');
  2015. td.textContent = cellValue;
  2016. td.style.border = '1px solid #ddd';
  2017. td.style.padding = '6px 8px';
  2018. if (span > 1) {{
  2019. td.setAttribute('rowspan', span);
  2020. }}
  2021. tr.appendChild(td);
  2022. }});
  2023. tbody.appendChild(tr);
  2024. }});
  2025. dynamicTable.appendChild(tbody);
  2026. dynamicValue.appendChild(dynamicTable);
  2027. }}
  2028. // 4.5. 推导成功的选题点
  2029. const successfulPointsRaw = detail["推导成功的选题点"] || [];
  2030. if (Array.isArray(successfulPointsRaw) && successfulPointsRaw.length > 0) {{
  2031. // 处理字符串数组或对象数组(对象需有 name 字段)
  2032. const successfulPoints = successfulPointsRaw.map(item => {{
  2033. if (typeof item === "string") {{
  2034. return item;
  2035. }} else if (item && typeof item === "object" && item.name) {{
  2036. return item.name;
  2037. }}
  2038. return "";
  2039. }}).filter(Boolean);
  2040. if (successfulPoints.length > 0) {{
  2041. const successfulSection = document.createElement('div');
  2042. successfulSection.className = 'detail-item';
  2043. const successfulLabel = document.createElement('label');
  2044. successfulLabel.textContent = '推导成功的选题点:';
  2045. successfulSection.appendChild(successfulLabel);
  2046. const successfulValue = document.createElement('div');
  2047. successfulValue.className = 'detail-val';
  2048. successfulValue.style.marginTop = '8px';
  2049. successfulValue.textContent = successfulPoints.join('、');
  2050. successfulSection.appendChild(successfulValue);
  2051. container.appendChild(successfulSection);
  2052. }}
  2053. }}
  2054. // 5. Query列表
  2055. const querySection = document.createElement('div');
  2056. querySection.className = 'detail-item';
  2057. const queryLabelRow = document.createElement('div');
  2058. queryLabelRow.style.display = 'flex';
  2059. queryLabelRow.style.justifyContent = 'space-between';
  2060. queryLabelRow.style.alignItems = 'center';
  2061. queryLabelRow.style.marginBottom = '8px';
  2062. const queryLabel = document.createElement('label');
  2063. queryLabel.style.margin = '0';
  2064. queryLabel.textContent = 'Query列表:';
  2065. queryLabelRow.appendChild(queryLabel);
  2066. querySection.appendChild(queryLabelRow);
  2067. const queryList = detail.query_list || [];
  2068. const queryStrs = queryList.map(q => q && q.query_str ? q.query_str : (typeof q === "string" ? q : "")).filter(Boolean);
  2069. const queryValueDiv = document.createElement('div');
  2070. queryValueDiv.className = 'detail-val';
  2071. queryValueDiv.style.marginTop = '4px';
  2072. queryValueDiv.style.whiteSpace = 'pre-wrap';
  2073. if (queryStrs.length) {{
  2074. queryValueDiv.textContent = queryStrs.join('\\n');
  2075. }} else {{
  2076. queryValueDiv.textContent = '暂无';
  2077. }}
  2078. querySection.appendChild(queryValueDiv);
  2079. container.appendChild(querySection);
  2080. // 6. 外部寻找结果 (match_result 为数组,按匹配率倒序显示)
  2081. const matchSection = document.createElement('div');
  2082. matchSection.className = 'detail-item';
  2083. const matchLabelRow = document.createElement('div');
  2084. matchLabelRow.style.display = 'flex';
  2085. matchLabelRow.style.justifyContent = 'space-between';
  2086. matchLabelRow.style.alignItems = 'center';
  2087. matchLabelRow.style.marginBottom = '8px';
  2088. const matchLabel = document.createElement('label');
  2089. matchLabel.style.margin = '0';
  2090. matchLabel.textContent = '外部寻找结果:';
  2091. matchLabelRow.appendChild(matchLabel);
  2092. matchSection.appendChild(matchLabelRow);
  2093. const matchResultArr = detail.match_result || [];
  2094. const matchWithStats = matchResultArr.map(mr => {{
  2095. const nodeList = mr.node_list || [];
  2096. const searchCount = nodeList.length;
  2097. const matchCount = nodeList.filter(n => n.eval_result && n.eval_result.匹配类型 === "完全匹配").length;
  2098. const matchRate = searchCount > 0 ? (matchCount / searchCount * 100) : 0;
  2099. return {{ ...mr, searchCount, matchCount, matchRate }};
  2100. }});
  2101. const sortedMatchResult = [...matchWithStats].sort((a, b) => b.matchRate - a.matchRate);
  2102. const matchValue = document.createElement('div');
  2103. matchValue.className = 'detail-val';
  2104. matchValue.style.marginTop = '8px';
  2105. matchSection.appendChild(matchValue);
  2106. container.appendChild(matchSection);
  2107. sortedMatchResult.forEach((matchResult, mrIdx) => {{
  2108. const nodeList = matchResult.node_list || [];
  2109. const sortedNodes = [...nodeList].sort((a, b) => {{
  2110. const scoreA = a.eval_result && a.eval_result.综合得分 !== undefined ? a.eval_result.综合得分 : -1;
  2111. const scoreB = b.eval_result && b.eval_result.综合得分 !== undefined ? b.eval_result.综合得分 : -1;
  2112. return scoreB - scoreA;
  2113. }});
  2114. const queryBlock = document.createElement('div');
  2115. queryBlock.className = 'query-block';
  2116. queryBlock.style.marginBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
  2117. queryBlock.style.paddingBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
  2118. queryBlock.style.borderBottom = mrIdx < sortedMatchResult.length - 1 ? '1px solid #eee' : 'none';
  2119. const queryBody = document.createElement('div');
  2120. queryBody.className = 'query-block-body';
  2121. const headerDiv = document.createElement('div');
  2122. headerDiv.className = 'query-block-header';
  2123. headerDiv.style.fontWeight = 'bold';
  2124. headerDiv.style.fontSize = '14px';
  2125. headerDiv.style.cursor = 'pointer';
  2126. const toggleSpan = document.createElement('span');
  2127. toggleSpan.className = 'query-toggle';
  2128. toggleSpan.style.marginRight = '6px';
  2129. toggleSpan.style.display = 'inline-block';
  2130. toggleSpan.style.width = '16px';
  2131. toggleSpan.textContent = '▼';
  2132. headerDiv.appendChild(toggleSpan);
  2133. const querySpan = document.createElement('span');
  2134. querySpan.textContent = 'Query: ' + (matchResult.query_str || "暂无");
  2135. headerDiv.appendChild(querySpan);
  2136. const statsDiv = document.createElement('div');
  2137. statsDiv.style.fontSize = '13px';
  2138. statsDiv.style.color = '#666';
  2139. statsDiv.style.fontWeight = 'normal';
  2140. statsDiv.style.marginTop = '4px';
  2141. statsDiv.textContent = '搜索帖子数: ' + matchResult.searchCount + ',匹配帖子数: ' + matchResult.matchCount + ',匹配率: ' + matchResult.matchRate.toFixed(1) + '%';
  2142. headerDiv.appendChild(statsDiv);
  2143. let isExpanded = true;
  2144. headerDiv.addEventListener('click', function() {{
  2145. isExpanded = !isExpanded;
  2146. queryBody.style.display = isExpanded ? 'block' : 'none';
  2147. toggleSpan.textContent = isExpanded ? '▼' : '▶';
  2148. }});
  2149. queryBlock.insertBefore(headerDiv, queryBlock.firstChild);
  2150. queryBlock.appendChild(queryBody);
  2151. sortedNodes.forEach((node, i) => {{
  2152. const postCard = document.createElement('div');
  2153. postCard.className = 'external-post-card';
  2154. postCard.style.border = '1px solid #eee';
  2155. postCard.style.borderRadius = '8px';
  2156. postCard.style.padding = '12px';
  2157. postCard.style.marginTop = '12px';
  2158. postCard.style.background = '#fafafa';
  2159. const titleDiv = document.createElement('div');
  2160. titleDiv.style.fontWeight = 'bold';
  2161. titleDiv.style.marginBottom = '6px';
  2162. titleDiv.style.fontSize = '14px';
  2163. titleDiv.textContent = (i + 1) + '. ' + (node.title || "无标题");
  2164. postCard.appendChild(titleDiv);
  2165. const bodyText = node.body_text || "";
  2166. const bodyDiv = document.createElement('div');
  2167. bodyDiv.style.fontSize = '13px';
  2168. bodyDiv.style.color = '#666';
  2169. bodyDiv.style.marginBottom = '8px';
  2170. bodyDiv.style.whiteSpace = 'pre-wrap';
  2171. bodyDiv.style.maxHeight = '100px';
  2172. bodyDiv.style.overflowY = 'auto';
  2173. bodyDiv.textContent = bodyText.length > 200 ? bodyText.substring(0, 200) + "..." : bodyText;
  2174. postCard.appendChild(bodyDiv);
  2175. const imgList = node.image_url_list || [];
  2176. const urlList = imgList.map(img => (img && img.image_url) ? img.image_url : (typeof img === "string" ? img : "")).filter(Boolean);
  2177. if (urlList.length > 0) {{
  2178. const gallery = document.createElement('div');
  2179. gallery.style.display = 'flex';
  2180. gallery.style.flexWrap = 'wrap';
  2181. gallery.style.gap = '6px';
  2182. gallery.style.marginBottom = '8px';
  2183. urlList.forEach((url, idx) => {{
  2184. const thumb = document.createElement('div');
  2185. thumb.style.display = 'block';
  2186. thumb.style.cursor = 'pointer';
  2187. const img = document.createElement('img');
  2188. img.src = url;
  2189. img.alt = '图' + (idx + 1);
  2190. img.setAttribute('data-url', url);
  2191. img.style.width = '60px';
  2192. img.style.height = '60px';
  2193. img.style.objectFit = 'cover';
  2194. img.style.borderRadius = '4px';
  2195. img.style.pointerEvents = 'none';
  2196. thumb.appendChild(img);
  2197. gallery.appendChild(thumb);
  2198. }});
  2199. postCard.appendChild(gallery);
  2200. }}
  2201. const evalResult = node.eval_result || {{}};
  2202. if (evalResult && Object.keys(evalResult).length > 0) {{
  2203. const evalDiv = document.createElement('div');
  2204. evalDiv.style.marginTop = '8px';
  2205. evalDiv.style.padding = '8px';
  2206. evalDiv.style.background = '#fff';
  2207. evalDiv.style.borderRadius = '4px';
  2208. evalDiv.style.borderLeft = '3px solid #2196F3';
  2209. const matchType = evalResult.匹配类型 || "无";
  2210. const matchTypeColor = matchType === "完全匹配" ? "#5ba85f" : "inherit";
  2211. const matchTypeDiv = document.createElement('div');
  2212. matchTypeDiv.style.fontSize = '13px';
  2213. matchTypeDiv.style.marginBottom = '4px';
  2214. matchTypeDiv.innerHTML = '<strong>匹配类型:</strong> <strong style="color:' + matchTypeColor + '">' + escapeHtml(matchType) + '</strong>';
  2215. evalDiv.appendChild(matchTypeDiv);
  2216. const reasonDiv = document.createElement('div');
  2217. reasonDiv.style.fontSize = '13px';
  2218. reasonDiv.style.marginBottom = '4px';
  2219. reasonDiv.innerHTML = '<strong>评分说明:</strong> ' + (evalResult.评分说明 || "无");
  2220. evalDiv.appendChild(reasonDiv);
  2221. const keyPoints = evalResult.关键匹配点;
  2222. if (keyPoints && Array.isArray(keyPoints) && keyPoints.length > 0) {{
  2223. const keyPointsLabel = document.createElement('div');
  2224. keyPointsLabel.style.fontSize = '13px';
  2225. keyPointsLabel.style.marginBottom = '4px';
  2226. keyPointsLabel.style.fontWeight = 'bold';
  2227. keyPointsLabel.textContent = '关键匹配点:';
  2228. evalDiv.appendChild(keyPointsLabel);
  2229. keyPoints.forEach(kp => {{
  2230. const kpDiv = document.createElement('div');
  2231. kpDiv.style.fontSize = '14px';
  2232. kpDiv.style.fontWeight = 'bold';
  2233. kpDiv.style.color = '#555';
  2234. kpDiv.style.marginLeft = '12px';
  2235. kpDiv.textContent = '• ' + kp;
  2236. evalDiv.appendChild(kpDiv);
  2237. }});
  2238. }}
  2239. postCard.appendChild(evalDiv);
  2240. }}
  2241. queryBody.appendChild(postCard);
  2242. }});
  2243. matchValue.appendChild(queryBlock);
  2244. }});
  2245. }}
  2246. function renderToolEdgeDetail(detail, container, targetNodeName) {{
  2247. if (!detail) return;
  2248. const toolDataList = detail.tool_data_list || [];
  2249. if (toolDataList.length === 0) {{
  2250. const emptyDiv = document.createElement('div');
  2251. emptyDiv.className = 'detail-empty';
  2252. emptyDiv.textContent = '暂无工具数据';
  2253. container.appendChild(emptyDiv);
  2254. return;
  2255. }}
  2256. // 添加列表标题
  2257. const listHeader = document.createElement('div');
  2258. listHeader.style.fontSize = '18px';
  2259. listHeader.style.fontWeight = 'bold';
  2260. listHeader.style.color = '#333';
  2261. listHeader.style.marginBottom = '15px';
  2262. listHeader.style.paddingBottom = '10px';
  2263. listHeader.style.borderBottom = '2px solid #2196F3';
  2264. listHeader.textContent = '工具数据列表 (共 ' + toolDataList.length + ' 项)';
  2265. container.appendChild(listHeader);
  2266. // 遍历每个工具数据
  2267. toolDataList.forEach((toolData, idx) => {{
  2268. const toolBlock = document.createElement('div');
  2269. toolBlock.className = 'tool-block';
  2270. toolBlock.style.marginBottom = idx < toolDataList.length - 1 ? '12px' : '0';
  2271. toolBlock.style.paddingBottom = idx < toolDataList.length - 1 ? '12px' : '0';
  2272. toolBlock.style.borderBottom = idx < toolDataList.length - 1 ? '1px solid #eee' : 'none';
  2273. const toolBody = document.createElement('div');
  2274. toolBody.className = 'tool-block-body';
  2275. toolBody.style.display = 'block'; // 默认展开
  2276. // 获取关键信息用于标题显示
  2277. const toolInfo = toolData.tool_info || {{}};
  2278. const toolName = toolInfo.name || "未知工具";
  2279. const eval = toolData.evaluation || {{}};
  2280. const matchLevel = eval.match_level || "未评估";
  2281. const matchLevelColor = matchLevel === "完全匹配" ? "#5ba85f" :
  2282. matchLevel === "部分匹配" ? "#ff9800" : "#999";
  2283. // 添加可点击的标题栏(显示关键信息)
  2284. const headerDiv = document.createElement('div');
  2285. headerDiv.className = 'tool-block-header';
  2286. headerDiv.style.cursor = 'pointer';
  2287. headerDiv.style.userSelect = 'none';
  2288. headerDiv.style.fontWeight = 'bold';
  2289. headerDiv.style.fontSize = '14px';
  2290. headerDiv.style.padding = '10px 12px';
  2291. headerDiv.style.background = '#f5f5f5';
  2292. headerDiv.style.borderRadius = '6px';
  2293. headerDiv.style.borderLeft = '4px solid #2196F3';
  2294. headerDiv.style.marginBottom = '8px';
  2295. headerDiv.style.transition = 'background 0.2s';
  2296. headerDiv.addEventListener('mouseenter', function() {{
  2297. this.style.background = '#e8f4f8';
  2298. }});
  2299. headerDiv.addEventListener('mouseleave', function() {{
  2300. this.style.background = '#f5f5f5';
  2301. }});
  2302. const toggleSpan = document.createElement('span');
  2303. toggleSpan.className = 'tool-toggle';
  2304. toggleSpan.style.marginRight = '8px';
  2305. toggleSpan.style.display = 'inline-block';
  2306. toggleSpan.style.width = '16px';
  2307. toggleSpan.style.color = '#2196F3';
  2308. toggleSpan.textContent = '▼';
  2309. headerDiv.appendChild(toggleSpan);
  2310. const headerContent = document.createElement('span');
  2311. const toolNumSpan = document.createElement('span');
  2312. toolNumSpan.style.color = '#333';
  2313. toolNumSpan.textContent = '工具 ' + (idx + 1) + ' / ' + toolDataList.length + ': ';
  2314. headerContent.appendChild(toolNumSpan);
  2315. const toolNameSpan = document.createElement('span');
  2316. toolNameSpan.style.color = '#2196F3';
  2317. toolNameSpan.style.fontWeight = 'bold';
  2318. toolNameSpan.textContent = toolName;
  2319. headerContent.appendChild(toolNameSpan);
  2320. headerDiv.appendChild(headerContent);
  2321. // 添加关键信息摘要
  2322. const summaryDiv = document.createElement('div');
  2323. summaryDiv.style.fontSize = '13px';
  2324. summaryDiv.style.color = '#666';
  2325. summaryDiv.style.fontWeight = 'normal';
  2326. summaryDiv.style.marginTop = '6px';
  2327. summaryDiv.style.paddingLeft = '24px';
  2328. if (matchLevel && matchLevel !== "未评估") {{
  2329. const matchLevelSpan = document.createElement('span');
  2330. matchLevelSpan.style.marginRight = '15px';
  2331. matchLevelSpan.innerHTML = '匹配级别: <strong style="color:' + matchLevelColor + '">' + escapeHtml(matchLevel) + '</strong>';
  2332. summaryDiv.appendChild(matchLevelSpan);
  2333. }}
  2334. headerDiv.appendChild(summaryDiv);
  2335. headerDiv.addEventListener('click', function() {{
  2336. const isExpanded = toolBody.style.display !== 'none';
  2337. toolBody.style.display = isExpanded ? 'none' : 'block';
  2338. toggleSpan.textContent = isExpanded ? '▶' : '▼';
  2339. }});
  2340. toolBlock.insertBefore(headerDiv, toolBlock.firstChild);
  2341. toolBlock.appendChild(toolBody);
  2342. const toolSection = document.createElement('div');
  2343. toolSection.className = 'detail-item';
  2344. toolSection.style.paddingTop = '10px';
  2345. toolBody.appendChild(toolSection);
  2346. // 1. 工具信息
  2347. if (Object.keys(toolInfo).length > 0) {{
  2348. const toolInfoSection = document.createElement('div');
  2349. toolInfoSection.style.marginBottom = '15px';
  2350. const toolInfoTitle = document.createElement('div');
  2351. toolInfoTitle.style.fontSize = '15px';
  2352. toolInfoTitle.style.fontWeight = 'bold';
  2353. toolInfoTitle.style.color = '#333';
  2354. toolInfoTitle.style.marginBottom = '12px';
  2355. toolInfoTitle.textContent = '工具信息';
  2356. toolInfoSection.appendChild(toolInfoTitle);
  2357. const toolInfoTable = document.createElement('table');
  2358. toolInfoTable.style.width = '100%';
  2359. toolInfoTable.style.borderCollapse = 'collapse';
  2360. toolInfoTable.style.fontSize = '13px';
  2361. toolInfoTable.style.background = '#fafafa';
  2362. toolInfoTable.style.borderRadius = '6px';
  2363. toolInfoTable.style.overflow = 'hidden';
  2364. const toolInfoFields = [
  2365. {{ key: "name", label: "工具名称" }},
  2366. {{ key: "tool_description", label: "工具描述" }}
  2367. ];
  2368. toolInfoFields.forEach(field => {{
  2369. const value = toolInfo[field.key];
  2370. if (value !== undefined && value !== null && value !== "") {{
  2371. const row = document.createElement('tr');
  2372. const labelTd = document.createElement('td');
  2373. labelTd.textContent = field.label + ':';
  2374. labelTd.style.padding = '8px 12px';
  2375. labelTd.style.fontWeight = 'bold';
  2376. labelTd.style.color = '#555';
  2377. labelTd.style.width = '120px';
  2378. labelTd.style.verticalAlign = 'top';
  2379. labelTd.style.background = '#f0f0f0';
  2380. row.appendChild(labelTd);
  2381. const valueTd = document.createElement('td');
  2382. valueTd.textContent = String(value);
  2383. valueTd.style.padding = '8px 12px';
  2384. valueTd.style.color = '#666';
  2385. valueTd.style.whiteSpace = 'pre-wrap';
  2386. valueTd.style.wordBreak = 'break-word';
  2387. row.appendChild(valueTd);
  2388. toolInfoTable.appendChild(row);
  2389. }}
  2390. }});
  2391. toolInfoSection.appendChild(toolInfoTable);
  2392. toolSection.appendChild(toolInfoSection);
  2393. }}
  2394. // 2. 工具参数
  2395. if (toolData.params && toolData.params.prompt) {{
  2396. const paramsSection = document.createElement('div');
  2397. paramsSection.style.marginBottom = '15px';
  2398. const paramsTitle = document.createElement('div');
  2399. paramsTitle.style.fontSize = '15px';
  2400. paramsTitle.style.fontWeight = 'bold';
  2401. paramsTitle.style.color = '#333';
  2402. paramsTitle.style.marginBottom = '8px';
  2403. paramsTitle.textContent = '工具参数';
  2404. paramsSection.appendChild(paramsTitle);
  2405. const paramsDiv = document.createElement('div');
  2406. paramsDiv.className = 'detail-val';
  2407. paramsDiv.style.background = '#f0f7ff';
  2408. paramsDiv.style.padding = '12px';
  2409. paramsDiv.style.borderRadius = '6px';
  2410. paramsDiv.style.borderLeft = '3px solid #2196F3';
  2411. paramsDiv.style.whiteSpace = 'pre-wrap';
  2412. paramsDiv.style.wordBreak = 'break-word';
  2413. paramsDiv.style.fontSize = '14px';
  2414. paramsDiv.style.lineHeight = '1.6';
  2415. paramsDiv.style.color = '#333';
  2416. paramsDiv.textContent = toolData.params.prompt;
  2417. paramsSection.appendChild(paramsDiv);
  2418. toolSection.appendChild(paramsSection);
  2419. }}
  2420. // 3. 工具内容
  2421. if (toolData.content) {{
  2422. const contentSection = document.createElement('div');
  2423. contentSection.style.marginBottom = '15px';
  2424. const contentTitle = document.createElement('div');
  2425. contentTitle.style.fontSize = '16px';
  2426. contentTitle.style.fontWeight = 'bold';
  2427. contentTitle.style.color = '#333';
  2428. contentTitle.style.marginBottom = '8px';
  2429. contentTitle.textContent = '工具返回内容';
  2430. contentSection.appendChild(contentTitle);
  2431. const contentDiv = document.createElement('div');
  2432. contentDiv.className = 'detail-val';
  2433. contentDiv.style.background = '#f9f9f9';
  2434. contentDiv.style.padding = '12px';
  2435. contentDiv.style.borderRadius = '6px';
  2436. contentDiv.style.borderLeft = '3px solid #2196F3';
  2437. contentDiv.style.whiteSpace = 'pre-wrap';
  2438. contentDiv.style.wordBreak = 'break-word';
  2439. contentDiv.style.fontSize = '14px';
  2440. contentDiv.style.lineHeight = '1.6';
  2441. contentDiv.style.color = '#333';
  2442. contentDiv.style.maxHeight = '400px';
  2443. contentDiv.style.overflowY = 'auto';
  2444. contentDiv.textContent = toolData.content;
  2445. contentSection.appendChild(contentDiv);
  2446. toolSection.appendChild(contentSection);
  2447. }}
  2448. // 4. 评估结果
  2449. if (toolData.evaluation) {{
  2450. const evalSection = document.createElement('div');
  2451. const evalTitle = document.createElement('div');
  2452. evalTitle.style.fontSize = '16px';
  2453. evalTitle.style.fontWeight = 'bold';
  2454. evalTitle.style.color = '#333';
  2455. evalTitle.style.marginBottom = '8px';
  2456. evalTitle.textContent = '评估结果';
  2457. evalSection.appendChild(evalTitle);
  2458. const evalDiv = document.createElement('div');
  2459. evalDiv.style.background = '#fff';
  2460. evalDiv.style.padding = '12px';
  2461. evalDiv.style.borderRadius = '6px';
  2462. evalDiv.style.borderLeft = '3px solid #2196F3';
  2463. evalDiv.style.marginTop = '8px';
  2464. const eval = toolData.evaluation;
  2465. // 匹配级别
  2466. if (eval.match_level) {{
  2467. const matchLevelColor = eval.match_level === "完全匹配" ? "#5ba85f" :
  2468. eval.match_level === "部分匹配" ? "#ff9800" : "#999";
  2469. const matchLevelDiv = document.createElement('div');
  2470. matchLevelDiv.style.fontSize = '14px';
  2471. matchLevelDiv.style.marginBottom = '8px';
  2472. matchLevelDiv.innerHTML = '<strong>匹配级别:</strong> <strong style="color:' + matchLevelColor + '">' + escapeHtml(eval.match_level) + '</strong>';
  2473. evalDiv.appendChild(matchLevelDiv);
  2474. }}
  2475. // 核心主体
  2476. if (eval.core_subject) {{
  2477. const coreSubjectDiv = document.createElement('div');
  2478. coreSubjectDiv.style.fontSize = '14px';
  2479. coreSubjectDiv.style.marginBottom = '8px';
  2480. coreSubjectDiv.innerHTML = '<strong>核心主体:</strong> ' + escapeHtml(eval.core_subject);
  2481. evalDiv.appendChild(coreSubjectDiv);
  2482. }}
  2483. // 核心事件
  2484. if (eval.core_event) {{
  2485. const coreEventDiv = document.createElement('div');
  2486. coreEventDiv.style.fontSize = '14px';
  2487. coreEventDiv.style.marginBottom = '8px';
  2488. coreEventDiv.innerHTML = '<strong>核心事件:</strong> ' + escapeHtml(eval.core_event);
  2489. evalDiv.appendChild(coreEventDiv);
  2490. }}
  2491. // 原因说明
  2492. if (eval.reason) {{
  2493. const reasonDiv = document.createElement('div');
  2494. reasonDiv.style.fontSize = '14px';
  2495. reasonDiv.style.marginTop = '8px';
  2496. reasonDiv.style.paddingTop = '8px';
  2497. reasonDiv.style.borderTop = '1px solid #eee';
  2498. reasonDiv.innerHTML = '<strong>原因说明:</strong> ' + escapeHtml(eval.reason);
  2499. evalDiv.appendChild(reasonDiv);
  2500. }}
  2501. evalSection.appendChild(evalDiv);
  2502. toolSection.appendChild(evalSection);
  2503. }}
  2504. // 工具统计信息
  2505. if (detail.tools_count !== undefined || detail.successful_tools_count !== undefined) {{
  2506. const statsSection = document.createElement('div');
  2507. statsSection.style.marginTop = '12px';
  2508. statsSection.style.paddingTop = '12px';
  2509. statsSection.style.borderTop = '1px solid #eee';
  2510. statsSection.style.fontSize = '13px';
  2511. statsSection.style.color = '#666';
  2512. if (detail.tools_count !== undefined) {{
  2513. const toolsCountSpan = document.createElement('span');
  2514. toolsCountSpan.textContent = '工具总数: ' + detail.tools_count;
  2515. toolsCountSpan.style.marginRight = '15px';
  2516. statsSection.appendChild(toolsCountSpan);
  2517. }}
  2518. if (detail.successful_tools_count !== undefined) {{
  2519. const successfulToolsCountSpan = document.createElement('span');
  2520. successfulToolsCountSpan.textContent = '成功工具数: ' + detail.successful_tools_count;
  2521. statsSection.appendChild(successfulToolsCountSpan);
  2522. }}
  2523. toolSection.appendChild(statsSection);
  2524. }}
  2525. container.appendChild(toolBlock);
  2526. }});
  2527. }}
  2528. function renderRootDetail(detail, container) {{
  2529. if (!detail) return;
  2530. // 1. 帖子详情
  2531. const postSection = document.createElement('div');
  2532. postSection.className = 'root-detail-section';
  2533. const postTitle = document.createElement('div');
  2534. postTitle.className = 'root-detail-title';
  2535. postTitle.textContent = '1. 帖子详情';
  2536. postSection.appendChild(postTitle);
  2537. // 显示帖子 ID(尝试多个可能的字段名)
  2538. const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
  2539. if (postId) {{
  2540. const postIdDiv = document.createElement('div');
  2541. postIdDiv.style.marginBottom = '10px';
  2542. postIdDiv.style.fontSize = '14px';
  2543. postIdDiv.style.color = '#666';
  2544. const postIdLabel = document.createElement('span');
  2545. postIdLabel.style.fontWeight = 'bold';
  2546. postIdLabel.textContent = '帖子 ID: ';
  2547. postIdDiv.appendChild(postIdLabel);
  2548. const postIdValue = document.createElement('span');
  2549. postIdValue.textContent = postId;
  2550. postIdDiv.appendChild(postIdValue);
  2551. postSection.appendChild(postIdDiv);
  2552. }}
  2553. if (detail.title) {{
  2554. const titleDiv = document.createElement('div');
  2555. titleDiv.className = 'post-title';
  2556. titleDiv.textContent = detail.title;
  2557. postSection.appendChild(titleDiv);
  2558. }}
  2559. if (detail.body_text) {{
  2560. const bodyDiv = document.createElement('div');
  2561. bodyDiv.className = 'post-body';
  2562. bodyDiv.textContent = detail.body_text;
  2563. postSection.appendChild(bodyDiv);
  2564. }}
  2565. const stats = document.createElement('div');
  2566. stats.className = 'post-stats';
  2567. if (detail.like_count !== null && detail.like_count !== undefined) {{
  2568. const likeSpan = document.createElement('span');
  2569. likeSpan.textContent = `❤️ ${{detail.like_count}}`;
  2570. stats.appendChild(likeSpan);
  2571. }}
  2572. if (detail.collect_count !== null && detail.collect_count !== undefined) {{
  2573. const collectSpan = document.createElement('span');
  2574. collectSpan.textContent = `⭐ ${{detail.collect_count}}`;
  2575. stats.appendChild(collectSpan);
  2576. }}
  2577. postSection.appendChild(stats);
  2578. if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
  2579. const gallery = document.createElement('div');
  2580. gallery.className = 'image-gallery';
  2581. detail.images.forEach(imgUrl => {{
  2582. const img = document.createElement('img');
  2583. img.className = 'image-item';
  2584. img.src = imgUrl;
  2585. img.addEventListener('click', function() {{
  2586. // 可以在这里添加图片查看功能
  2587. }});
  2588. gallery.appendChild(img);
  2589. }});
  2590. postSection.appendChild(gallery);
  2591. }}
  2592. container.appendChild(postSection);
  2593. // 2. 选题结果
  2594. const topicSection = document.createElement('div');
  2595. topicSection.className = 'root-detail-section';
  2596. const topicTitle = document.createElement('div');
  2597. topicTitle.className = 'root-detail-title';
  2598. topicTitle.textContent = '2. 选题结果';
  2599. topicSection.appendChild(topicTitle);
  2600. const topicLink = document.createElement('a');
  2601. topicLink.className = 'jump-link';
  2602. topicLink.href = `${{accountName}}_标签匹配可视化.html`;
  2603. topicLink.target = '_blank';
  2604. topicLink.textContent = '选题匹配结果';
  2605. topicSection.appendChild(topicLink);
  2606. container.appendChild(topicSection);
  2607. // 3. 选题点拆解(选题点: list[dict])
  2608. const selectionPoints = detail["选题点"];
  2609. if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
  2610. const selectionSection = document.createElement('div');
  2611. selectionSection.className = 'root-detail-section';
  2612. const selectionTitle = document.createElement('div');
  2613. selectionTitle.className = 'root-detail-title';
  2614. selectionTitle.textContent = '3. 选题点拆解';
  2615. selectionSection.appendChild(selectionTitle);
  2616. const table = document.createElement('table');
  2617. table.style.width = '100%';
  2618. table.style.borderCollapse = 'collapse';
  2619. table.style.fontSize = '13px';
  2620. table.style.marginTop = '8px';
  2621. // 表头
  2622. const thead = document.createElement('thead');
  2623. const headerRow = document.createElement('tr');
  2624. const headerStyle = (th) => {{
  2625. th.style.borderBottom = '1px solid #eee';
  2626. th.style.padding = '6px 8px';
  2627. th.style.textAlign = 'left';
  2628. th.style.color = '#555';
  2629. th.style.background = '#fafafa';
  2630. }};
  2631. const thType = document.createElement('th');
  2632. thType.textContent = '类型';
  2633. headerStyle(thType);
  2634. headerRow.appendChild(thType);
  2635. const thTopic = document.createElement('th');
  2636. thTopic.textContent = '选题点';
  2637. headerStyle(thTopic);
  2638. headerRow.appendChild(thTopic);
  2639. const thSubstantial = document.createElement('th');
  2640. thSubstantial.textContent = '实质';
  2641. headerStyle(thSubstantial);
  2642. headerRow.appendChild(thSubstantial);
  2643. const thForm = document.createElement('th');
  2644. thForm.textContent = '形式';
  2645. headerStyle(thForm);
  2646. headerRow.appendChild(thForm);
  2647. const thIntent = document.createElement('th');
  2648. thIntent.textContent = '意图';
  2649. headerStyle(thIntent);
  2650. headerRow.appendChild(thIntent);
  2651. thead.appendChild(headerRow);
  2652. table.appendChild(thead);
  2653. // 表体
  2654. const tbody = document.createElement('tbody');
  2655. selectionPoints.forEach((item, idx) => {{
  2656. if (!item || typeof item !== "object") return;
  2657. const row = document.createElement('tr');
  2658. row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
  2659. const rowCellStyle = (td) => {{
  2660. td.style.padding = '6px 8px';
  2661. td.style.verticalAlign = 'top';
  2662. td.style.color = '#666';
  2663. }};
  2664. const toJoined = (v) => {{
  2665. if (Array.isArray(v)) return v.join('、');
  2666. if (v === null || v === undefined) return "";
  2667. return String(v);
  2668. }};
  2669. const tdType = document.createElement('td');
  2670. tdType.textContent = item["类型"] || "";
  2671. rowCellStyle(tdType);
  2672. row.appendChild(tdType);
  2673. const tdTopic = document.createElement('td');
  2674. tdTopic.textContent = item["选题点"] || "";
  2675. rowCellStyle(tdTopic);
  2676. row.appendChild(tdTopic);
  2677. const tdSubstantial = document.createElement('td');
  2678. tdSubstantial.textContent = toJoined(item["实质"]);
  2679. rowCellStyle(tdSubstantial);
  2680. row.appendChild(tdSubstantial);
  2681. const tdForm = document.createElement('td');
  2682. tdForm.textContent = toJoined(item["形式"]);
  2683. rowCellStyle(tdForm);
  2684. row.appendChild(tdForm);
  2685. const tdIntent = document.createElement('td');
  2686. tdIntent.textContent = toJoined(item["意图"]);
  2687. rowCellStyle(tdIntent);
  2688. row.appendChild(tdIntent);
  2689. tbody.appendChild(row);
  2690. }});
  2691. table.appendChild(tbody);
  2692. selectionSection.appendChild(table);
  2693. container.appendChild(selectionSection);
  2694. }}
  2695. }}
  2696. // 更新画布宽度以适应侧边栏
  2697. function updateCanvasWidth() {{
  2698. const sidebar = document.getElementById('sidebar');
  2699. const appContainer = document.getElementById('app-container');
  2700. const sidebarResizer = document.getElementById('sidebar-resizer');
  2701. if (sidebar.classList.contains('active')) {{
  2702. const sidebarWidth = sidebar.offsetWidth;
  2703. appContainer.style.right = sidebarWidth + 'px';
  2704. appContainer.style.width = `calc(100% - ${{sidebarWidth}}px)`;
  2705. sidebarResizer.style.right = sidebarWidth + 'px';
  2706. sidebarResizer.classList.add('active');
  2707. }} else {{
  2708. appContainer.style.right = '';
  2709. appContainer.style.width = '';
  2710. sidebarResizer.style.right = '';
  2711. sidebarResizer.classList.remove('active');
  2712. }}
  2713. }}
  2714. function closeSidebar() {{
  2715. const sidebar = document.getElementById('sidebar');
  2716. const appContainer = document.getElementById('app-container');
  2717. sidebar.classList.remove('active');
  2718. appContainer.classList.remove('sidebar-open');
  2719. updateCanvasWidth();
  2720. }}
  2721. // 侧边栏拖拽调整宽度
  2722. const sidebarResizer = document.getElementById('sidebar-resizer');
  2723. const sidebar = document.getElementById('sidebar');
  2724. let isSidebarResizing = false;
  2725. let sidebarStartX = 0;
  2726. let sidebarStartWidth = 0;
  2727. sidebarResizer.addEventListener('mousedown', function(e) {{
  2728. if (!sidebar.classList.contains('active')) return;
  2729. isSidebarResizing = true;
  2730. sidebarStartX = e.clientX;
  2731. sidebarStartWidth = sidebar.offsetWidth;
  2732. document.body.classList.add('resizing');
  2733. e.preventDefault();
  2734. }});
  2735. document.addEventListener('mousemove', function(e) {{
  2736. if (!isSidebarResizing) return;
  2737. const deltaX = sidebarStartX - e.clientX; // 向左拖拽增加宽度
  2738. const newWidth = sidebarStartWidth + deltaX;
  2739. const minWidth = 250;
  2740. const maxWidth = window.innerWidth * 0.6;
  2741. if (newWidth >= minWidth && newWidth <= maxWidth) {{
  2742. sidebar.style.width = newWidth + 'px';
  2743. updateCanvasWidth();
  2744. }}
  2745. }});
  2746. document.addEventListener('mouseup', function() {{
  2747. if (isSidebarResizing) {{
  2748. isSidebarResizing = false;
  2749. document.body.classList.remove('resizing');
  2750. }}
  2751. }});
  2752. function openPostDetailModal() {{
  2753. const detail = postDetailMap[currentPostKey] || null;
  2754. const container = document.getElementById('post-detail-modal-content');
  2755. container.innerHTML = '';
  2756. if (!detail) {{
  2757. const empty = document.createElement('div');
  2758. empty.className = 'derivation-empty';
  2759. empty.textContent = '暂无当前帖子的详情数据';
  2760. container.appendChild(empty);
  2761. }} else {{
  2762. renderPostDetailForModal(detail, container);
  2763. }}
  2764. document.getElementById('post-detail-modal').classList.add('active');
  2765. }}
  2766. function closePostDetailModal() {{
  2767. document.getElementById('post-detail-modal').classList.remove('active');
  2768. }}
  2769. function renderPostDetailForModal(detail, container) {{
  2770. if (!detail) return;
  2771. const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
  2772. if (postId) {{
  2773. const div = document.createElement('div');
  2774. div.style.marginBottom = '10px'; div.style.fontSize = '14px'; div.style.color = '#666';
  2775. div.innerHTML = '<span style="font-weight:bold;">帖子 ID: </span>' + escapeHtml(postId);
  2776. container.appendChild(div);
  2777. }}
  2778. if (detail.title) {{
  2779. const titleDiv = document.createElement('div');
  2780. titleDiv.className = 'post-title';
  2781. titleDiv.textContent = detail.title;
  2782. container.appendChild(titleDiv);
  2783. }}
  2784. if (detail.body_text) {{
  2785. const bodyDiv = document.createElement('div');
  2786. bodyDiv.className = 'post-body';
  2787. bodyDiv.textContent = detail.body_text;
  2788. container.appendChild(bodyDiv);
  2789. }}
  2790. const stats = document.createElement('div');
  2791. stats.className = 'post-stats';
  2792. if (detail.like_count != null) {{ const s = document.createElement('span'); s.textContent = '❤️ ' + detail.like_count; stats.appendChild(s); }}
  2793. if (detail.collect_count != null) {{ const s = document.createElement('span'); s.textContent = '⭐ ' + detail.collect_count; stats.appendChild(s); }}
  2794. if (stats.childNodes.length) container.appendChild(stats);
  2795. if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
  2796. const gallery = document.createElement('div');
  2797. gallery.className = 'image-gallery';
  2798. detail.images.forEach((imgUrl, idx) => {{
  2799. const img = document.createElement('img');
  2800. img.className = 'image-item';
  2801. img.src = imgUrl;
  2802. img.addEventListener('click', function() {{ openImageLightbox(detail.images, idx); }});
  2803. gallery.appendChild(img);
  2804. }});
  2805. container.appendChild(gallery);
  2806. }}
  2807. const selectionPoints = detail["选题点"];
  2808. if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
  2809. const sectionTitle = document.createElement('div');
  2810. sectionTitle.className = 'root-detail-title';
  2811. sectionTitle.textContent = '帖子选题表';
  2812. sectionTitle.style.marginTop = '16px';
  2813. container.appendChild(sectionTitle);
  2814. const table = document.createElement('table');
  2815. table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.marginTop = '8px';
  2816. const thead = document.createElement('thead');
  2817. const headerRow = document.createElement('tr');
  2818. ['类型','选题点','实质','形式','意图'].forEach(txt => {{
  2819. const th = document.createElement('th');
  2820. th.textContent = txt;
  2821. th.style.borderBottom = '1px solid #eee'; th.style.padding = '6px 8px'; th.style.textAlign = 'left'; th.style.background = '#fafafa';
  2822. headerRow.appendChild(th);
  2823. }});
  2824. thead.appendChild(headerRow); table.appendChild(thead);
  2825. const tbody = document.createElement('tbody');
  2826. const toJoined = (v) => Array.isArray(v) ? v.join('、') : (v == null || v === undefined ? '' : String(v));
  2827. selectionPoints.forEach((item, idx) => {{
  2828. if (!item || typeof item !== 'object') return;
  2829. const row = document.createElement('tr');
  2830. row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
  2831. const cellStyle = td => {{ td.style.padding = '6px 8px'; td.style.verticalAlign = 'top'; td.style.color = '#666'; }};
  2832. [item["类型"]||"", item["选题点"]||"", toJoined(item["实质"]), toJoined(item["形式"]), toJoined(item["意图"])].forEach(text => {{
  2833. const td = document.createElement('td');
  2834. td.textContent = text;
  2835. cellStyle(td);
  2836. row.appendChild(td);
  2837. }});
  2838. tbody.appendChild(row);
  2839. }});
  2840. table.appendChild(tbody);
  2841. container.appendChild(table);
  2842. }}
  2843. }}
  2844. document.getElementById('btn-pending-decode-post').addEventListener('click', openPostDetailModal);
  2845. document.getElementById('post-detail-modal').addEventListener('click', function(e) {{
  2846. if (e.target === this) closePostDetailModal();
  2847. }});
  2848. // --- 图集大图灯箱(左右切换)---
  2849. let currentLightboxImages = [];
  2850. let currentLightboxIndex = 0;
  2851. function openImageLightbox(images, index) {{
  2852. if (!images || !images.length) return;
  2853. currentLightboxImages = images;
  2854. currentLightboxIndex = (index >= 0 && index < images.length) ? index : 0;
  2855. updateLightboxImage();
  2856. document.getElementById('image-lightbox').classList.add('active');
  2857. document.addEventListener('keydown', lightboxKeydown);
  2858. }}
  2859. function closeImageLightbox() {{
  2860. document.getElementById('image-lightbox').classList.remove('active');
  2861. document.removeEventListener('keydown', lightboxKeydown);
  2862. }}
  2863. function updateLightboxImage() {{
  2864. const img = document.getElementById('lightbox-img');
  2865. const counter = document.getElementById('lightbox-counter');
  2866. if (!currentLightboxImages.length) return;
  2867. const idx = ((currentLightboxIndex % currentLightboxImages.length) + currentLightboxImages.length) % currentLightboxImages.length;
  2868. currentLightboxIndex = idx;
  2869. img.src = currentLightboxImages[idx];
  2870. counter.textContent = (idx + 1) + ' / ' + currentLightboxImages.length;
  2871. }}
  2872. function lightboxPrev() {{
  2873. if (!currentLightboxImages.length) return;
  2874. currentLightboxIndex = (currentLightboxIndex - 1 + currentLightboxImages.length) % currentLightboxImages.length;
  2875. updateLightboxImage();
  2876. }}
  2877. function lightboxNext() {{
  2878. if (!currentLightboxImages.length) return;
  2879. currentLightboxIndex = (currentLightboxIndex + 1) % currentLightboxImages.length;
  2880. updateLightboxImage();
  2881. }}
  2882. function lightboxKeydown(e) {{
  2883. if (e.key === 'Escape') {{ closeImageLightbox(); return; }}
  2884. if (e.key === 'ArrowLeft') {{ lightboxPrev(); e.preventDefault(); return; }}
  2885. if (e.key === 'ArrowRight') {{ lightboxNext(); e.preventDefault(); return; }}
  2886. }}
  2887. document.getElementById('image-lightbox').addEventListener('click', function(e) {{
  2888. if (e.target === this) closeImageLightbox();
  2889. }});
  2890. document.querySelector('#image-lightbox .lightbox-img-wrap').addEventListener('click', function(e) {{ e.stopPropagation(); }});
  2891. function switchPost(val) {{
  2892. currentPostKey = val;
  2893. parseData(val);
  2894. calculateLayout();
  2895. renderNodes();
  2896. renderEdges();
  2897. updateTransform();
  2898. resetView();
  2899. renderDerivationProgress(val);
  2900. }}
  2901. function closeDimensionPatternsModal() {{
  2902. const modal = document.getElementById('dimension-patterns-modal');
  2903. if (modal) modal.classList.remove('active');
  2904. }}
  2905. function showDimensionPatternsModal(postId, roundNum) {{
  2906. const modal = document.getElementById('dimension-patterns-modal');
  2907. const body = document.getElementById('dimension-patterns-modal-body');
  2908. const titleEl = document.getElementById('dimension-patterns-modal-title');
  2909. if (!modal || !body) return;
  2910. const doc = dimensionAnalyzeData[postId];
  2911. if (!doc || !doc.rounds) {{
  2912. if (titleEl) titleEl.textContent = '维度 patterns';
  2913. body.innerHTML = '<p style="color:#64748b;">暂无整体推导维度分析数据</p>';
  2914. modal.classList.add('active');
  2915. return;
  2916. }}
  2917. const r = doc.rounds.find(function(x) {{ return x.round === roundNum; }});
  2918. const label = (roundNum === 0) ? '选起点' : ('第' + roundNum + '轮');
  2919. if (titleEl) titleEl.textContent = '维度patterns · ' + label;
  2920. if (!r || !r.patterns || !r.patterns.length) {{
  2921. body.innerHTML = '<p style="color:#64748b;">该轮暂无 patterns 数据</p>';
  2922. modal.classList.add('active');
  2923. return;
  2924. }}
  2925. let parts = [];
  2926. parts.push('<div class="dimension-patterns-title">共 ' + r.patterns.length + ' 条 pattern(is_derived 已高亮)</div>');
  2927. r.patterns.forEach(function(pat) {{
  2928. const items = pat.items || [];
  2929. const segs = items.map(function(it) {{
  2930. const nm = escapeHtml(it.name || '');
  2931. return it.is_derived ? '<span class="pattern-item-derived">' + nm + '</span>' : nm;
  2932. }});
  2933. parts.push('<div class="pattern-line">' + segs.join('<span class="pattern-plus"> + </span>') + '</div>');
  2934. }});
  2935. body.innerHTML = parts.join('');
  2936. modal.classList.add('active');
  2937. }}
  2938. // 渲染推导进度
  2939. function renderDerivationProgress(fileKey) {{
  2940. const container = document.getElementById('derivation-progress-content');
  2941. // 从文件名中提取文件ID(去掉.json扩展名)
  2942. const fileId = fileKey.replace(/\.json$/, '');
  2943. const rounds = derivationData[fileId] || derivationData[fileKey];
  2944. if (!rounds || !Array.isArray(rounds) || rounds.length === 0) {{
  2945. container.innerHTML = '<div class="derivation-empty">暂无推导进度数据</div>';
  2946. return;
  2947. }}
  2948. // 收集所有已推导成功的选题点名称(用于判断是否为之前已点亮)
  2949. const allDerivedNames = new Set();
  2950. rounds.forEach(round => {{
  2951. const derived = round.推导成功的选题点 || [];
  2952. derived.forEach(p => {{
  2953. if (p.name) allDerivedNames.add(p.name);
  2954. }});
  2955. }});
  2956. let html = '<div class="derivation-timeline">';
  2957. for (let ri = 0; ri < rounds.length; ri++) {{
  2958. const round = rounds[ri];
  2959. // 推导结果数据中轮次从 1 开始(第一轮=1);轮次 0 表示选起点
  2960. const roundLabel = (round.轮次 === 0) ? "选起点" : ("第" + round.轮次 + "轮");
  2961. const derived = round.推导成功的选题点 || [];
  2962. const underived = round.未推导成功的选题点 || [];
  2963. // 获取当前轮次新推导成功的选题点名称
  2964. const newInRoundRaw = round.本次新推导成功的选题点 || [];
  2965. const newInRoundNames = new Set(newInRoundRaw.map(p => p.name));
  2966. // 如果是第一轮(轮次0),所有推导成功的都是新点亮的
  2967. if (ri === 0) {{
  2968. derived.forEach(p => {{ if (p.name) newInRoundNames.add(p.name); }});
  2969. }} else if (newInRoundNames.size === 0) {{
  2970. // 如果没有本次新推导成功的,则找出在当前轮次首次出现的
  2971. const prevDerivedNames = new Set();
  2972. for (let i = 0; i < ri; i++) {{
  2973. const prevDerived = rounds[i].推导成功的选题点 || [];
  2974. prevDerived.forEach(p => {{ if (p.name) prevDerivedNames.add(p.name); }});
  2975. }}
  2976. derived.forEach(p => {{
  2977. if (p.name && !prevDerivedNames.has(p.name)) {{
  2978. newInRoundNames.add(p.name);
  2979. }}
  2980. }});
  2981. }}
  2982. // 收集所有root_source
  2983. const allRootSources = new Set();
  2984. derived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
  2985. underived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
  2986. const pointsByRoot = {{}};
  2987. const dimDataByRoot = {{}};
  2988. // 处理推导成功的选题点
  2989. derived.forEach(p => {{
  2990. if (!p.root_source) return;
  2991. if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
  2992. if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
  2993. const dim = p.dimension || "实质";
  2994. // 判断颜色:当前轮次新点亮的为黄色,之前已点亮的为绿色
  2995. const cls = newInRoundNames.has(p.name) ? "derivation-topic-new" : "derivation-topic-derived";
  2996. dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: cls, derivation_type: p.derivation_type || "", is_fully_derived: p.is_fully_derived }});
  2997. }});
  2998. // 处理未推导成功的选题点(黑色)
  2999. underived.forEach(p => {{
  3000. if (!p.root_source) return;
  3001. if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
  3002. if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
  3003. const dim = p.dimension || "实质";
  3004. dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: "derivation-topic-underedived", derivation_type: p.derivation_type || "" }});
  3005. }});
  3006. // 按point类型排序
  3007. const pointOrder = {{ "灵感点": 0, "目的点": 1, "关键点": 2 }};
  3008. let rootSourceList = Array.from(allRootSources).sort((a, b) => {{
  3009. const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
  3010. const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
  3011. if (pa !== pb) return pa - pb;
  3012. return (a || "").localeCompare(b || "");
  3013. }});
  3014. // 整体推导结果里若未写入选题点(或解析不到分词),用待解构帖子详情中的选题表回填,避免表格无行
  3015. if (rootSourceList.length === 0) {{
  3016. const pd = postDetailMap[fileKey];
  3017. const rows = (pd && Array.isArray(pd["选题点"])) ? pd["选题点"] : [];
  3018. for (let ri = 0; ri < rows.length; ri++) {{
  3019. const row = rows[ri];
  3020. if (!row || typeof row !== "object") continue;
  3021. const rs = String(row["选题点"] || "").trim();
  3022. if (!rs) continue;
  3023. const pt = row["类型"] || "";
  3024. pointsByRoot[rs] = pt;
  3025. if (!dimDataByRoot[rs]) dimDataByRoot[rs] = {{ 实质: [], 形式: [], 意图: [] }};
  3026. ["实质", "形式", "意图"].forEach(function(dim) {{
  3027. const arr = row[dim];
  3028. const list = Array.isArray(arr) ? arr : [];
  3029. list.forEach(function(nm) {{
  3030. const s = (typeof nm === "string") ? nm.trim() : String(nm || "").trim();
  3031. if (!s) return;
  3032. dimDataByRoot[rs][dim].push({{
  3033. name: s,
  3034. cls: "derivation-topic-baseline",
  3035. derivation_type: "",
  3036. is_fully_derived: undefined
  3037. }});
  3038. }});
  3039. }});
  3040. }}
  3041. rootSourceList = Object.keys(pointsByRoot).sort((a, b) => {{
  3042. const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
  3043. const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
  3044. if (pa !== pb) return pa - pb;
  3045. return (a || "").localeCompare(b || "");
  3046. }});
  3047. }}
  3048. html += '<div class="derivation-round-block">';
  3049. html += '<div class="derivation-round-title">' + escapeHtml(roundLabel) + '</div>';
  3050. html += '<table class="derivation-table"><thead><tr>';
  3051. html += '<th class="col-type">类型</th><th class="col-source">选题点</th>';
  3052. html += '<th class="col-dim">实质</th><th class="col-dim">形式</th><th class="col-dim">意图</th>';
  3053. html += '</tr></thead><tbody>';
  3054. rootSourceList.forEach(rs => {{
  3055. const point = pointsByRoot[rs] || "";
  3056. const dimData = dimDataByRoot[rs] || {{ 实质: [], 形式: [], 意图: [] }};
  3057. html += '<tr>';
  3058. html += '<td class="col-type">' + escapeHtml(point) + '</td>';
  3059. html += '<td class="col-source">' + escapeHtml(rs) + '</td>';
  3060. for (const dim of ["实质", "形式", "意图"]) {{
  3061. const items = (dimData[dim] || []).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
  3062. html += '<td class="col-dim">';
  3063. items.forEach(it => {{
  3064. const searchIcon = (it.derivation_type === "search") ? ' <span class="derivation-topic-search-icon" title="外部寻找">🔍</span>' : '';
  3065. const toolIcon = (it.derivation_type === "tool") ? ' <span class="derivation-topic-tool-icon" title="工具调用">🔧</span>' : '';
  3066. // 只有推导成功的选题点可以点击(黄色和绿色),未推导成功的(黑色)不可点击
  3067. const isClickable = it.cls === "derivation-topic-derived" || it.cls === "derivation-topic-new";
  3068. const dataAttr = isClickable ? ' data-topic-name="' + (it.name || "").replace(/&/g,'&amp;').replace(/"/g,'&quot;').replace(/</g,'&lt;').replace(/>/g,'&gt;') + '"' : '';
  3069. const notFullyClass = (it.is_fully_derived === false) ? ' derivation-topic-not-fully-derived' : '';
  3070. html += '<span class="derivation-topic-item ' + it.cls + notFullyClass + '"' + dataAttr + '>' + escapeHtml(it.name) + searchIcon + toolIcon + '</span>';
  3071. }});
  3072. if (items.length === 0) html += '<span style="color:#999;">-</span>';
  3073. html += '</td>';
  3074. }}
  3075. html += '</tr>';
  3076. }});
  3077. if (rootSourceList.length === 0) {{
  3078. html += '<tr><td colspan="5" style="color:#94a3b8;text-align:center;padding:12px;">暂无选题表数据(请检查整体推导结果与 input 解构内容)</td></tr>';
  3079. }}
  3080. html += '</tbody></table>';
  3081. const _dimDoc = (dimensionAnalyzeData && dimensionAnalyzeData[fileId]) ? dimensionAnalyzeData[fileId] : null;
  3082. const _dimRounds = (_dimDoc && _dimDoc.rounds) ? _dimDoc.rounds : [];
  3083. const _dimForRound = _dimRounds.find(function(dr) {{ return dr.round === round.轮次; }}) || null;
  3084. html += '<div class="derivation-dimension-extra">';
  3085. if (_dimForRound) {{
  3086. const _dd = (_dimForRound.derived_dims || []).map(function(d) {{
  3087. if (d && typeof d === 'object') {{
  3088. const tn = d.tree_node_name || '';
  3089. const dim = d.dimension || '';
  3090. const mp = d.matched_point || '';
  3091. let s = tn;
  3092. if (dim) {{
  3093. s += '->' + dim;
  3094. }}
  3095. if (mp) {{
  3096. s += '(' + mp + ')';
  3097. }}
  3098. return escapeHtml(s);
  3099. }}
  3100. return escapeHtml(String(d));
  3101. }}).join('、');
  3102. const _ud = (_dimForRound.underived_dims || []).map(function(d) {{
  3103. if (d && typeof d === 'object') {{
  3104. const tn = d.tree_node_name || '';
  3105. const dim = d.dimension || '';
  3106. const mp = d.matched_point || '';
  3107. let s = tn;
  3108. if (dim) {{
  3109. s += '->' + dim;
  3110. }}
  3111. if (mp) {{
  3112. s += '(' + mp + ')';
  3113. }}
  3114. return escapeHtml(s);
  3115. }}
  3116. return escapeHtml(String(d));
  3117. }}).join('、');
  3118. html += '<div class="derivation-dim-line"><span class="derivation-dim-label">已推导维度</span> <span class="derivation-dim-val dim-derived">' + (_dd || '—') + '</span></div>';
  3119. html += '<div class="derivation-dim-line"><span class="derivation-dim-label">未推导维度</span> <span class="derivation-dim-val dim-underived">' + (_ud || '—') + '</span></div>';
  3120. }} else {{
  3121. html += '<div class="derivation-dim-line dim-muted">暂无与本轮对应的整体推导维度分析</div>';
  3122. }}
  3123. const _pidAttr = String(fileId).replace(/&/g, '&amp;').replace(/"/g, '&quot;');
  3124. html += '<button type="button" class="btn-dimension-patterns" data-post-id="' + _pidAttr + '" data-round="' + String(round.轮次) + '">维度patterns</button>';
  3125. html += '</div>';
  3126. html += '</div>';
  3127. }}
  3128. html += '</div>';
  3129. container.innerHTML = html;
  3130. container.querySelectorAll('.btn-dimension-patterns').forEach(function(el) {{
  3131. el.addEventListener('click', function() {{
  3132. const pid = this.getAttribute('data-post-id');
  3133. const rn = parseInt(this.getAttribute('data-round'), 10);
  3134. showDimensionPatternsModal(pid, rn);
  3135. }});
  3136. }});
  3137. // 添加点击事件:点击已推导成功的选题点,在画布中定位(只关联node_list中的节点)
  3138. container.querySelectorAll('.derivation-topic-item[data-topic-name]').forEach(el => {{
  3139. el.addEventListener('click', function() {{
  3140. const topicName = this.getAttribute('data-topic-name');
  3141. if (topicName) {{
  3142. focusOnNodeByName(topicName);
  3143. }}
  3144. }});
  3145. }});
  3146. }}
  3147. // 根据选题点名称定位节点(只关联node_list中的节点,不关联all_used_tree_nodes)
  3148. function focusOnNodeByName(topicName) {{
  3149. // 只在node_list中查找,排除level -1的节点(all_used_tree_nodes)
  3150. let node = null;
  3151. for (let level in flatData.nodesByLevel) {{
  3152. const levelNum = parseInt(level);
  3153. if (levelNum !== -1) {{ // 排除level -1的节点
  3154. const found = flatData.nodesByLevel[levelNum].find(n => n.name === topicName);
  3155. if (found) {{
  3156. node = found;
  3157. break;
  3158. }}
  3159. }}
  3160. }}
  3161. if (node) {{
  3162. focusOnNode(node);
  3163. highlightDirectSources(node);
  3164. }} else {{
  3165. // 如果在当前数据中找不到,尝试搜索
  3166. const searchInput = document.getElementById('search-input');
  3167. if (searchInput) {{
  3168. searchInput.value = topicName;
  3169. // 再次搜索,排除level -1
  3170. for (let level in flatData.nodesByLevel) {{
  3171. const levelNum = parseInt(level);
  3172. if (levelNum !== -1) {{
  3173. const match = flatData.nodesByLevel[levelNum].find(n => n.name.toLowerCase().includes(topicName.toLowerCase()));
  3174. if (match) {{
  3175. focusOnNode(match);
  3176. highlightDirectSources(match);
  3177. return;
  3178. }}
  3179. }}
  3180. }}
  3181. }}
  3182. }}
  3183. }}
  3184. // 切换推导进度显示
  3185. function toggleDerivationProgress() {{
  3186. const section = document.getElementById('derivation-progress-section');
  3187. const appContainer = document.getElementById('app-container');
  3188. const btn = document.querySelector('.derivation-progress-toggle');
  3189. if (section.classList.contains('active')) {{
  3190. section.classList.remove('active');
  3191. appContainer.classList.remove('derivation-open');
  3192. appContainer.style.bottom = '';
  3193. btn.textContent = '展开';
  3194. }} else {{
  3195. section.classList.add('active');
  3196. appContainer.classList.add('derivation-open');
  3197. // 设置画布底部边距为推导进度面板的高度
  3198. const sectionHeight = section.offsetHeight;
  3199. appContainer.style.bottom = sectionHeight + 'px';
  3200. btn.textContent = '收起';
  3201. }}
  3202. }}
  3203. // 推导进度面板高度拖拽调整
  3204. (function() {{
  3205. const resizer = document.getElementById('derivation-resizer');
  3206. const section = document.getElementById('derivation-progress-section');
  3207. const appContainer = document.getElementById('app-container');
  3208. let isResizing = false;
  3209. let startY = 0;
  3210. let startHeight = 0;
  3211. resizer.addEventListener('mousedown', function(e) {{
  3212. isResizing = true;
  3213. startY = e.clientY;
  3214. startHeight = section.offsetHeight;
  3215. resizer.classList.add('active');
  3216. document.body.classList.add('resizing');
  3217. section.style.transition = 'none';
  3218. appContainer.style.transition = 'none';
  3219. e.preventDefault();
  3220. }});
  3221. document.addEventListener('mousemove', function(e) {{
  3222. if (!isResizing) return;
  3223. const delta = startY - e.clientY;
  3224. const minH = 200;
  3225. const maxH = Math.floor(window.innerHeight * 0.8);
  3226. const newHeight = Math.min(maxH, Math.max(minH, startHeight + delta));
  3227. section.style.height = newHeight + 'px';
  3228. if (section.classList.contains('active')) {{
  3229. appContainer.style.bottom = newHeight + 'px';
  3230. }}
  3231. }});
  3232. document.addEventListener('mouseup', function() {{
  3233. if (isResizing) {{
  3234. isResizing = false;
  3235. resizer.classList.remove('active');
  3236. document.body.classList.remove('resizing');
  3237. section.style.transition = '';
  3238. appContainer.style.transition = '';
  3239. }}
  3240. }});
  3241. }})();
  3242. // 初始化
  3243. parseData(currentPostKey);
  3244. calculateLayout();
  3245. renderNodes();
  3246. renderEdges();
  3247. updateTransform();
  3248. renderDerivationProgress(currentPostKey);
  3249. </script>
  3250. </body>
  3251. </html>
  3252. '''
  3253. with open(output_path, 'w', encoding='utf-8') as f:
  3254. f.write(html_content)
  3255. print(f"最终结果可视化已生成: {output_path}")
  3256. def main(account_name) -> None:
  3257. name = account_name
  3258. base = Path(__file__).resolve().parent
  3259. output_base = base / "output" / name
  3260. data_dir = output_base / "整体推导路径可视化"
  3261. if not data_dir.exists():
  3262. print(f"错误: 找不到数据目录 {data_dir}")
  3263. return
  3264. json_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".json"))
  3265. if not json_files:
  3266. print(f"在目录 {data_dir} 中未找到 .json 文件。")
  3267. return
  3268. data_map: Dict[str, dict] = {}
  3269. print("\n" + "=" * 50)
  3270. print(f"账号: {name}")
  3271. print(f"数据目录: {data_dir}")
  3272. print(f"正在读取 {len(json_files)} 个帖子数据...")
  3273. for filename in json_files:
  3274. json_path = data_dir / filename
  3275. try:
  3276. with open(json_path, "r", encoding="utf-8") as f:
  3277. data_map[filename] = json.load(f)
  3278. print(f" -> 已读取: {filename}")
  3279. except Exception as e:
  3280. print(f" [错误] 读取 {filename} 时出错: {e}")
  3281. if not data_map:
  3282. print("没有成功读取到任何数据。")
  3283. return
  3284. post_detail_map: Dict[str, dict] = {}
  3285. for filename in data_map.keys():
  3286. post_id = Path(filename).stem
  3287. try:
  3288. detail = load_post_detail_for_visualization(name, post_id)
  3289. if detail is not None:
  3290. post_detail_map[filename] = detail
  3291. except Exception as e:
  3292. print(f" [警告] 加载帖子详情 {filename} 时出错: {e}")
  3293. derivation_dir = output_base / "整体推导结果"
  3294. derivation_data: Dict[str, list] = {}
  3295. if derivation_dir.exists():
  3296. print("\n正在读取推导进度数据...")
  3297. for json_file in derivation_dir.glob("*.json"):
  3298. try:
  3299. with open(json_file, "r", encoding="utf-8") as f:
  3300. derivation_data[json_file.stem] = json.load(f)
  3301. print(f" -> 已加载推导进度: {json_file.name}")
  3302. except Exception as e:
  3303. print(f" [警告] 读取推导进度 {json_file.name} 时出错: {e}")
  3304. else:
  3305. print(f" [提示] 推导结果目录不存在: {derivation_dir}")
  3306. dimension_analyze_dir = output_base / "整体推导维度分析"
  3307. dimension_analyze_map: Dict[str, dict] = {}
  3308. if dimension_analyze_dir.exists():
  3309. print("\n正在读取整体推导维度分析...")
  3310. suf = "_pattern_dimension_analyze"
  3311. for json_file in sorted(dimension_analyze_dir.glob(f"*{suf}.json")):
  3312. stem = json_file.stem
  3313. if not stem.endswith(suf):
  3314. continue
  3315. post_id_key = stem[: -len(suf)]
  3316. try:
  3317. with open(json_file, "r", encoding="utf-8") as f:
  3318. dimension_analyze_map[post_id_key] = json.load(f)
  3319. print(f" -> 已加载维度分析: {json_file.name}")
  3320. except Exception as e:
  3321. print(f" [警告] 读取维度分析 {json_file.name} 时出错: {e}")
  3322. else:
  3323. print(f" [提示] 整体推导维度分析目录不存在: {dimension_analyze_dir}")
  3324. output_base.mkdir(parents=True, exist_ok=True)
  3325. ts = datetime.now().strftime("%Y%m%d%H%M%S")
  3326. output_path = output_base / f"{name}_how推导可视化_{ts}.html"
  3327. generate_all_in_one_visualization(
  3328. data_map,
  3329. str(output_path),
  3330. name,
  3331. derivation_data=derivation_data,
  3332. post_detail_map=post_detail_map,
  3333. dimension_analyze_map=dimension_analyze_map,
  3334. )
  3335. print("\n" + "=" * 50)
  3336. print("处理完成!")
  3337. print(f"输出文件: {output_path}")
  3338. print("=" * 50 + "\n")
  3339. if __name__ == "__main__":
  3340. main(account_name="空间点阵设计研究室")