visualize_paths.py 184 KB

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