visualize_paths.py 187 KB

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