| 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664 |
- import json
- import os
- from datetime import datetime
- from pathlib import Path
- from typing import Dict, List, Optional
- def _build_selection_points_from_decode(decode_data: Dict) -> List[Dict]:
- """将 examples 解构内容 JSON 转为可视化「帖子选题表」所需的选题点行。"""
- result: List[Dict] = []
- point_types = ["灵感点", "目的点", "关键点"]
- for point_type in point_types:
- points = decode_data.get(point_type, [])
- if not isinstance(points, list):
- continue
- for item in points:
- if not isinstance(item, dict):
- continue
- title = item.get("选题点") or item.get("点") or ""
- essence: List[str] = []
- form: List[str] = []
- intent: List[str] = []
- for el in item.get("选题点元素") or []:
- if not isinstance(el, dict):
- continue
- name = el.get("元素名称")
- if not name:
- continue
- et = el.get("元素类型") or ""
- if et == "实质":
- essence.append(name)
- elif et == "形式":
- form.append(name)
- elif et == "意图":
- intent.append(name)
- else:
- essence.append(name)
- result.append(
- {
- "类型": point_type,
- "选题点": title,
- "实质": essence,
- "形式": form,
- "意图": intent,
- }
- )
- return result
- def load_post_detail_for_visualization(account_name: str, post_id: str) -> Optional[Dict]:
- """
- 从 Agent 示例目录读取原始帖子与解构内容,供「待解构帖子详情」弹窗与侧边栏使用。
- - post_data: input/{account}/原始数据/post_data/{post_id}.json
- - 解构: input/{account}/原始数据/解构内容/{post_id}.json
- """
- base = Path(__file__).resolve().parent
- post_path = base / "input" / account_name / "原始数据" / "post_data" / f"{post_id}.json"
- decode_path = base / "input" / account_name / "原始数据" / "解构内容" / f"{post_id}.json"
- try:
- with open(post_path, "r", encoding="utf-8") as f:
- post_data = json.load(f)
- except Exception:
- return None
- decode_data: Dict = {}
- try:
- with open(decode_path, "r", encoding="utf-8") as f:
- decode_data = json.load(f)
- except Exception:
- pass
- out = dict(post_data)
- out["选题点"] = _build_selection_points_from_decode(decode_data) if decode_data else []
- pid = out.get("channel_content_id") or decode_data.get("帖子ID")
- if pid and not out.get("id"):
- out["id"] = pid
- return out
- def generate_all_in_one_visualization(
- data_map: Dict[str, dict],
- output_path: str,
- account_name: str,
- derivation_data: Dict[str, list] = None,
- post_detail_map: Dict[str, dict] = None,
- dimension_analyze_map: Dict[str, dict] = None,
- ):
- """
- 将所有帖子的数据整合到一个 HTML 中,支持动态切换
- data_map: { "文件名": json_data, ... }
- derivation_data: { "文件名": 推导结果列表, ... }
- post_detail_map: { "文件名": 帖子详情(含选题点),来自 load_post_detail_for_visualization }
- dimension_analyze_map: { post_id: 整体推导维度分析 JSON(含 rounds.derived_dims 等)}
- """
- # 提取第一个帖子的数据作为默认展示
- first_key = list(data_map.keys())[0]
-
- # 将整个 data_map 转换为 JS 对象
- json_data_js = json.dumps(data_map, ensure_ascii=False)
-
- # 将推导数据转换为 JS 对象
- if derivation_data is None:
- derivation_data = {}
- derivation_data_js = json.dumps(derivation_data, ensure_ascii=False)
-
- # 将帖子详情数据转换为 JS 对象(供「待解构帖子」弹窗使用)
- if post_detail_map is None:
- post_detail_map = {}
- post_detail_map_js = json.dumps(post_detail_map, ensure_ascii=False)
- if dimension_analyze_map is None:
- dimension_analyze_map = {}
- dimension_analyze_data_js = json.dumps(dimension_analyze_map, ensure_ascii=False)
- account_name_js = json.dumps(account_name, ensure_ascii=False)
- html_content = rf'''<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <title>多源数据流可视化 - 完整全景版</title>
- <style>
- * {{ margin: 0; padding: 0; box-sizing: border-box; }}
- body {{
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
- background: #f8fafc;
- overflow: hidden;
- user-select: none;
- }}
- /* 顶部工具栏 */
- #top-bar {{
- position: fixed; top: 0; left: 0; right: 0; height: 60px;
- background: white; border-bottom: 1px solid #e2e8f0;
- display: flex; align-items: center; justify-content: space-between;
- padding: 0 24px; z-index: 100;
- box-shadow: 0 1px 2px rgba(0,0,0,0.05);
- }}
- .controls {{ display: flex; gap: 16px; align-items: center; }}
- .controls input {{
- padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
- font-size: 14px; width: 220px; transition: border 0.2s;
- }}
- .controls input:focus {{ border-color: #3b82f6; outline: none; }}
- .controls select {{
- padding: 8px 12px; border: 1px solid #cbd5e1; border-radius: 6px;
- font-size: 13px; width: 320px; transition: border 0.2s;
- }}
- .controls select:focus {{ border-color: #3b82f6; outline: none; }}
- .controls button {{
- padding: 8px 16px; background: #3b82f6; color: white; border: none;
- border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
- transition: background 0.2s;
- }}
- .controls button:hover {{ background: #2563eb; }}
- /* 画布区域 */
- #app-container {{
- position: fixed; top: 60px; left: 0; right: 0; bottom: 0;
- overflow: hidden; cursor: grab; background: #f8fafc;
- /* 移除 transition,让画布缩放瞬间完成 */
- z-index: 1;
- transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
- }}
- #app-container.grabbing {{ cursor: grabbing; }}
- /* 当侧边栏显示时,画布缩小并向右移动(宽度通过 JavaScript 动态设置) */
- #app-container.sidebar-open {{
- /* right 和 width 通过 JavaScript 动态设置 */
- }}
- #canvas {{
- position: absolute;
- transform-origin: 0 0;
- transition: transform 0.1s linear;
- }}
- #canvas.animating {{ transition: transform 0.6s cubic-bezier(0.25, 1, 0.5, 1); }}
- /* 列标题 */
- .column-header {{
- position: absolute;
- height: 36px; line-height: 36px;
- font-size: 14px; font-weight: 600; color: #64748b;
- background: #f1f5f9; border-radius: 18px;
- text-align: center; padding: 0 20px;
- z-index: 2; white-space: nowrap;
- box-shadow: 0 1px 2px rgba(0,0,0,0.05);
- }}
- /* 卡片样式(实线框颜色略深) */
- .constant-card, .node-card {{
- position: absolute; background: white;
- border: 1px solid #64748b; border-radius: 10px;
- padding: 16px; box-shadow: 0 2px 4px rgba(0,0,0,0.05);
- cursor: pointer; transition: all 0.2s ease-out; z-index: 10;
- }}
- /* 当侧边栏打开时,提高节点卡片的 z-index */
- #app-container.sidebar-open .constant-card,
- #app-container.sidebar-open .node-card {{
- z-index: 150;
- }}
- .constant-card {{ width: 280px; border-left: 5px solid #8b5cf6; }}
- .node-card {{ width: 320px; }}
- .constant-card:hover, .node-card:hover {{
- transform: translateY(-2px);
- box-shadow: 0 10px 20px -5px rgba(0,0,0,0.1);
- border-color: #475569;
- }}
- /* 高亮样式(实线蓝框) */
- .highlight {{
- border: 2px solid #2563eb !important;
- background: #eff6ff !important;
- box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
- z-index: 20;
- }}
- /* 虚线框节点高亮时保持虚线蓝框(用渐变虚线,去掉实线 border) */
- .node-card.not-fully-derived.highlight {{
- border: none !important;
- background-color: #eff6ff !important;
- box-shadow: 0 0 0 4px rgba(37,99,235,0.15) !important;
- }}
- /* 变暗样式 */
- .dimmed {{ opacity: 0.1; filter: grayscale(100%); pointer-events: none; }}
- /* 未完全推导的节点:虚线框(用渐变模拟较疏虚线,颜色略深) */
- .node-card.not-fully-derived {{
- border: none;
- border-radius: 10px;
- background-color: white;
- background-image:
- linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
- linear-gradient(90deg, #475569 0 8px, transparent 8px 20px),
- linear-gradient(0deg, #475569 0 8px, transparent 8px 20px),
- linear-gradient(0deg, #475569 0 8px, transparent 8px 20px);
- background-size: 28px 2px, 28px 2px, 2px 28px, 2px 28px;
- background-position: left top, left bottom, left top, right top;
- background-repeat: repeat-x, repeat-x, repeat-y, repeat-y;
- }}
- .node-card.not-fully-derived.highlight {{
- background-image:
- linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
- linear-gradient(90deg, #2563eb 0 8px, transparent 8px 20px),
- linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px),
- linear-gradient(0deg, #2563eb 0 8px, transparent 8px 20px);
- }}
- .edge-path.dimmed {{
- opacity: 0.05;
- marker-end: none !important;
- }}
- .edge-label-text.dimmed {{ opacity: 0; }}
- .edge-label-sub.dimmed {{ opacity: 0; }}
- .connector-dot.dimmed {{ opacity: 0; }}
- /* 内容排版 */
- .node-header {{ font-weight: 700; font-size: 15px; margin-bottom: 12px; color: #0f172a; }}
- .constant-name {{ font-weight: 700; font-size: 14px; color: #1e293b; margin-bottom: 6px; }}
- .constant-value {{ font-size: 13px; color: #64748b; }}
- .row {{ display: flex; margin-bottom: 6px; font-size: 12px; line-height: 1.5; align-items: baseline; }}
- .key {{ color: #94a3b8; width: 50px; flex-shrink: 0; text-align: right; margin-right: 12px; white-space: nowrap; }}
- .val {{ color: #334155; font-weight: 500; }}
- .row-root-source .key {{ width: 80px; }}
- .row-root-source .val {{ white-space: nowrap; overflow: hidden; text-overflow: ellipsis; flex: 1; min-width: 0; }}
- .row-post-topic {{ margin-top: 6px; }}
- .row-post-topic .key {{ margin-right: 20px; }}
- /* SVG */
- .edge-layer {{ position: absolute; top: 0; left: 0; pointer-events: none; z-index: 1; }}
- .edge-layer g {{ pointer-events: all; }}
- .edge-path {{
- fill: none; stroke: #cbd5e1; stroke-width: 1.5px;
- stroke-linejoin: round; transition: stroke 0.3s, opacity 0.3s;
- pointer-events: stroke; cursor: pointer;
- }}
- .edge-path.highlight {{ stroke: #2563eb; stroke-width: 2.5px; opacity: 1; }}
- /* 主标签样式 */
- .edge-label-text {{
- font-size: 12px; fill: #475569; text-anchor: middle;
- font-family: monospace; paint-order: stroke;
- stroke: #f8fafc; stroke-width: 4px;
- transition: opacity 0.3s;
- pointer-events: all; cursor: pointer;
- }}
- /* 副标签样式(概率) */
- .edge-label-sub {{
- font-size: 10px; fill: #94a3b8; text-anchor: middle;
- font-family: -apple-system, BlinkMacSystemFont, sans-serif;
- paint-order: stroke; stroke: #f8fafc; stroke-width: 3px;
- transition: opacity 0.3s;
- pointer-events: all; cursor: pointer;
- }}
- .edge-label-text.highlight {{ fill: #2563eb; font-weight: 700; opacity: 1; }}
- .edge-label-sub.highlight {{ fill: #2563eb; font-weight: 600; opacity: 1; }}
- .connector-dot {{ fill: #cbd5e1; transition: fill 0.3s; pointer-events: all; cursor: pointer; }}
- .connector-dot.highlight {{ fill: #2563eb; }}
- /* 侧边栏 */
- #sidebar {{
- position: fixed; top: 60px; right: 0; width: 380px; min-width: 250px; max-width: 60vw;
- height: calc(100vh - 60px);
- background: white; border-left: 1px solid #e2e8f0; box-shadow: -4px 0 15px rgba(0,0,0,0.05);
- transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
- z-index: 100; display: flex; flex-direction: column;
- }}
- #sidebar.active {{ transform: translateX(0); }}
-
- /* 侧边栏拉伸器 */
- #sidebar-resizer {{
- position: fixed; top: 60px; right: 0; width: 8px; height: calc(100vh - 60px);
- background: #e0e0e0; cursor: col-resize; z-index: 101;
- transform: translateX(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
- display: flex; align-items: center; justify-content: center;
- }}
- #sidebar-resizer.active {{ transform: translateX(0); }}
- #sidebar-resizer:hover {{ background: #3b82f6; }}
- #sidebar-resizer::before {{
- content: ""; position: absolute; left: 50%; top: 0; bottom: 0;
- width: 2px; background: #999; transform: translateX(-50%);
- }}
- #sidebar-resizer:hover::before {{ background: #3b82f6; }}
- body.resizing {{ user-select: none; }}
- body.resizing #sidebar-resizer {{ background: #3b82f6; }}
- .sidebar-header {{ padding: 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc; }}
- .sidebar-content {{ padding: 20px; overflow-y: auto; flex: 1; }}
- .detail-item {{ margin-bottom: 20px; }}
- .detail-item label {{ display: block; font-size: 11px; font-weight: 600; color: #94a3b8; margin-bottom: 6px; text-transform: uppercase; letter-spacing: 0.5px; }}
- .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; }}
- .detail-empty {{ color: #999; font-style: italic; text-align: center; padding: 40px 20px; }}
- .query-block-header {{ cursor: pointer; padding: 8px 0; user-select: none; }}
- .query-block-header:hover {{ color: #3b82f6; }}
- .query-block-body {{ margin-top: 8px; }}
- .external-post-card {{ border: 1px solid #eee; border-radius: 8px; padding: 12px; margin-top: 12px; background: #fafafa; }}
- .root-detail-section {{ margin-bottom: 25px; padding-bottom: 15px; border-bottom: 1px solid #eee; }}
- .root-detail-section:last-child {{ border-bottom: none; }}
- .root-detail-title {{ font-size: 18px; font-weight: bold; color: #333; margin-bottom: 15px; display: flex; align-items: center; gap: 8px; }}
- .root-detail-title::before {{ content: ""; display: inline-block; width: 4px; height: 18px; background: #3b82f6; border-radius: 2px; }}
- .post-title {{ font-size: 16px; font-weight: bold; margin-bottom: 10px; color: #444; }}
- .post-body {{ font-size: 14px; white-space: pre-wrap; color: #666; background: #f9f9f9; padding: 12px; border-radius: 6px; margin-bottom: 15px; }}
- .post-stats {{ display: flex; gap: 20px; margin-bottom: 15px; font-size: 14px; color: #888; }}
- .post-stats span {{ display: flex; align-items: center; gap: 4px; }}
- .image-gallery {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(80px, 1fr)); gap: 8px; margin-top: 10px; }}
- .image-item {{ width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: 4px; cursor: pointer; transition: transform 0.2s; border: 1px solid #ddd; }}
- .image-item:hover {{ transform: scale(1.05); border-color: #3b82f6; }}
- .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; }}
- .jump-link:hover {{ background: #e1e9f0; color: #2563eb; }}
- .jump-link::after {{ content: "→"; font-size: 18px; }}
-
- /* 推导进度区域 - 底部面板 */
- #derivation-progress-section {{
- position: fixed; bottom: 0; left: 0; right: 0;
- height: 600px; max-height: 80vh; min-height: 200px;
- background: white; border-top: 1px solid #e2e8f0; box-shadow: 0 -2px 15px rgba(0,0,0,0.05);
- transform: translateY(100%); transition: transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
- z-index: 99; display: flex; flex-direction: column;
- }}
- #derivation-resizer {{
- position: absolute; top: 0; left: 0; right: 0; height: 6px;
- cursor: row-resize; z-index: 100;
- background: transparent;
- }}
- #derivation-resizer:hover, #derivation-resizer.active {{
- background: #3b82f6;
- }}
- #derivation-progress-section.active {{ transform: translateY(0); }}
- .derivation-progress-title {{
- padding: 15px 20px; border-bottom: 1px solid #f1f5f9; display: flex; justify-content: space-between; align-items: center; background: #f8fafc;
- flex-shrink: 0;
- }}
- .derivation-progress-title span {{
- font-weight: 700; color: #334155; font-size: 16px;
- }}
- .derivation-color-legend {{
- display: flex; gap: 15px; align-items: center; margin-left: 20px; font-size: 12px;
- }}
- .derivation-color-legend-item {{
- display: flex; align-items: center; gap: 6px;
- }}
- .derivation-color-legend-color {{
- width: 16px; height: 16px; border-radius: 4px; border: 1px solid #d1d5db;
- }}
- .legend-black {{ background: #f3f4f6; border-color: #d1d5db; }}
- .legend-yellow {{ background: #fef3c7; border-color: #fcd34d; }}
- .legend-green {{ background: #d1fae5; border-color: #6ee7b7; }}
- .derivation-progress-toggle {{
- padding: 6px 12px; background: #3b82f6; color: white; border: none;
- border-radius: 6px; cursor: pointer; font-size: 12px; font-weight: 500;
- transition: background 0.2s;
- }}
- .derivation-progress-toggle:hover {{ background: #2563eb; }}
- #derivation-progress-content {{
- padding: 20px; overflow-x: auto; overflow-y: auto; flex: 1;
- }}
-
- /* 当推导进度面板打开时,调整画布底部边距 */
- #app-container.derivation-open {{
- bottom: 600px;
- transition: bottom 0.3s cubic-bezier(0.16, 1, 0.3, 1);
- }}
-
- .derivation-empty {{
- color: #999; font-style: italic; text-align: center; padding: 40px 20px;
- }}
- .derivation-timeline {{
- display: flex; flex-direction: row; gap: 20px; align-items: flex-start;
- min-width: max-content;
- }}
- .derivation-round-block {{
- border: 1px solid #e2e8f0; border-radius: 8px; padding: 15px; background: #fafafa;
- min-width: 350px; max-width: 450px; flex-shrink: 0;
- display: flex; flex-direction: column;
- }}
- .derivation-round-title {{
- font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 12px; padding-bottom: 8px; border-bottom: 2px solid #3b82f6;
- flex-shrink: 0;
- }}
- .derivation-table {{
- width: 100%; border-collapse: collapse; font-size: 11px;
- }}
- .derivation-table th {{
- background: #f1f5f9; padding: 6px 4px; text-align: left; font-weight: 600; color: #475569; border: 1px solid #e2e8f0;
- font-size: 10px;
- }}
- .derivation-table td {{
- padding: 4px; border: 1px solid #e2e8f0; vertical-align: top;
- font-size: 10px;
- }}
- .derivation-table .col-type {{ width: 50px; }}
- .derivation-table .col-source {{ width: 100px; }}
- .derivation-table .col-dim {{ width: auto; min-width: 80px; }}
- .derivation-topic-item {{
- display: inline-block; margin: 2px 4px 2px 0; padding: 2px 6px; border-radius: 4px; font-size: 11px;
- cursor: pointer; transition: all 0.2s;
- }}
- /* 未点亮的词 - 黑色 */
- .derivation-topic-underedived {{
- background: #f3f4f6; color: #000000; border: 1px solid #d1d5db;
- }}
- .derivation-dimension-extra {{
- margin-top: 12px; padding-top: 10px; border-top: 1px dashed #e2e8f0;
- font-size: 11px; line-height: 1.5;
- }}
- .derivation-dim-line {{ margin-bottom: 6px; word-break: break-all; }}
- .derivation-dim-label {{
- display: inline-block; min-width: 72px; font-weight: 600; color: #64748b;
- }}
- .derivation-dim-val.dim-derived {{ color: #15803d; }}
- .derivation-dim-val.dim-underived {{ color: #b45309; }}
- .derivation-dim-line.dim-muted {{ color: #94a3b8; font-style: italic; }}
- .btn-dimension-patterns {{
- margin-top: 8px; padding: 5px 12px; font-size: 11px;
- background: #6366f1; color: white; border: none; border-radius: 6px;
- cursor: pointer; font-weight: 500;
- }}
- .btn-dimension-patterns:hover {{ background: #4f46e5; }}
- #dimension-patterns-modal {{
- display: none; position: fixed; inset: 0; z-index: 200;
- background: rgba(15, 23, 42, 0.45); align-items: center; justify-content: center;
- padding: 24px;
- }}
- #dimension-patterns-modal.active {{ display: flex; }}
- .dimension-patterns-dialog {{
- background: white; border-radius: 12px; max-width: 900px; width: 100%;
- max-height: 80vh; display: flex; flex-direction: column;
- box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
- }}
- .dimension-patterns-head {{
- padding: 14px 18px; border-bottom: 1px solid #e2e8f0;
- display: flex; justify-content: space-between; align-items: center;
- flex-shrink: 0;
- }}
- .dimension-patterns-head span {{ font-weight: 700; color: #1e293b; font-size: 15px; }}
- .dimension-patterns-close {{
- padding: 6px 14px; background: #f1f5f9; border: none; border-radius: 6px;
- cursor: pointer; font-size: 13px;
- }}
- .dimension-patterns-close:hover {{ background: #e2e8f0; }}
- .dimension-patterns-body {{
- padding: 16px 18px; overflow-y: auto; font-size: 12px; line-height: 1.6;
- }}
- .dimension-patterns-title {{ font-weight: 600; color: #475569; margin-bottom: 12px; }}
- .pattern-line {{
- padding: 8px 10px; margin-bottom: 6px; background: #f8fafc;
- border-radius: 6px; border: 1px solid #e2e8f0; word-break: break-all;
- }}
- .pattern-plus {{ color: #94a3b8; font-weight: 600; margin: 0 4px; }}
- .pattern-item-derived {{
- color: #15803d; font-weight: 700; background: #dcfce7;
- padding: 1px 4px; border-radius: 4px;
- }}
- /* 当前轮次点亮的点 - 黄色 */
- .derivation-topic-new {{
- background: #fef3c7; color: #92400e; border: 1px solid #fcd34d; font-weight: 600;
- }}
- /* 未完全推导的选题点:虚线框 */
- .derivation-topic-item.derivation-topic-not-fully-derived {{
- border: 1px dashed #475569 !important;
- }}
- /* 之前已经点亮过的点 - 绿色 */
- .derivation-topic-derived {{
- background: #d1fae5; color: #065f46; border: 1px solid #6ee7b7;
- }}
- /* 推导结果为空时,由解构内容回填的基准选题(不可点击定位) */
- .derivation-topic-baseline {{
- background: #e0e7ff; color: #312e81; border: 1px solid #a5b4fc; cursor: default;
- }}
- .derivation-topic-item.derivation-topic-baseline:hover {{
- transform: none; box-shadow: none;
- }}
- .derivation-topic-item:hover {{
- transform: translateY(-1px); box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- }}
- .derivation-topic-search-icon {{ color: #2196F3; margin-left: 2px; }}
- .derivation-topic-tool-icon {{ color: #ff9800; margin-left: 2px; }}
- /* 待解构帖子数据 入口 */
- .top-bar-left {{ display: flex; align-items: center; gap: 16px; }}
- #btn-pending-decode-post {{
- padding: 8px 16px; background: #8b5cf6; color: white; border: none;
- border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;
- transition: background 0.2s;
- }}
- #btn-pending-decode-post:hover {{ background: #7c3aed; }}
- .modal-overlay {{
- position: fixed; top: 0; left: 0; right: 0; bottom: 0;
- background: rgba(0,0,0,0.4); z-index: 1000; display: none;
- align-items: center; justify-content: center;
- }}
- .modal-overlay.active {{ display: flex; }}
- .modal-box {{
- background: white; border-radius: 12px; box-shadow: 0 10px 40px rgba(0,0,0,0.15);
- max-width: 480px; width: 90%; max-height: 85vh; overflow: hidden;
- display: flex; flex-direction: column;
- }}
- .modal-box.post-detail-modal {{ max-width: 720px; }}
- .modal-header {{
- padding: 16px 20px; border-bottom: 1px solid #e2e8f0; display: flex;
- justify-content: space-between; align-items: center; background: #f8fafc;
- }}
- .modal-header span {{ font-weight: 700; font-size: 16px; color: #334155; }}
- .modal-close {{ background: none; border: none; font-size: 24px; cursor: pointer; color: #94a3b8; line-height: 1; }}
- .modal-close:hover {{ color: #64748b; }}
- .modal-body {{ padding: 20px; overflow-y: auto; flex: 1; }}
- /* 图集大图查看(灯箱) */
- #image-lightbox {{
- position: fixed; top: 0; left: 0; right: 0; bottom: 0;
- background: rgba(0,0,0,0.9); z-index: 2000; display: none;
- align-items: center; justify-content: center;
- }}
- #image-lightbox.active {{ display: flex; }}
- #image-lightbox .lightbox-close {{
- position: absolute; top: 16px; right: 20px;
- background: none; border: none; color: #fff; font-size: 32px;
- cursor: pointer; line-height: 1; opacity: 0.8;
- }}
- #image-lightbox .lightbox-close:hover {{ opacity: 1; }}
- #image-lightbox .lightbox-prev,
- #image-lightbox .lightbox-next {{
- position: absolute; top: 50%; transform: translateY(-50%);
- width: 48px; height: 48px; border: none; border-radius: 50%;
- background: rgba(255,255,255,0.2); color: #fff; font-size: 24px;
- cursor: pointer; display: flex; align-items: center; justify-content: center;
- transition: background 0.2s;
- }}
- #image-lightbox .lightbox-prev:hover,
- #image-lightbox .lightbox-next:hover {{ background: rgba(255,255,255,0.35); }}
- #image-lightbox .lightbox-prev {{ left: 20px; }}
- #image-lightbox .lightbox-next {{ right: 20px; }}
- #image-lightbox .lightbox-img-wrap {{
- max-width: 90vw; max-height: 85vh; display: flex; align-items: center; justify-content: center;
- }}
- #image-lightbox .lightbox-img-wrap img {{
- max-width: 100%; max-height: 85vh; object-fit: contain;
- }}
- #image-lightbox .lightbox-counter {{
- position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%);
- color: rgba(255,255,255,0.9); font-size: 14px;
- }}
- </style>
- </head>
- <body>
- <div id="top-bar">
- <div class="top-bar-left">
- <button type="button" id="btn-pending-decode-post">待解构帖子数据</button>
- <h2 style="font-size:18px; color:#1e293b; font-weight:600;">多源数据流可视化 - 完整全景版</h2>
- </div>
- <div class="controls">
- <select id="postSelector" onchange="switchPost(this.value)">
- {"".join([f'<option value="{k}">{k}</option>' for k in data_map.keys()])}
- </select>
- <input type="text" id="search-input" placeholder="输入关键字 并回车定位..." />
- <button onclick="resetView()">重置视图</button>
- <button onclick="toggleDerivationProgress()">推导进度</button>
- </div>
- </div>
- <div id="sidebar">
- <div class="sidebar-header">
- <div id="sidebar-title" style="font-weight:700; color:#334155;">节点详情</div>
- <button onclick="closeSidebar()" style="background:none; border:none; font-size:24px; cursor:pointer; color:#94a3b8;">×</button>
- </div>
- <div class="sidebar-content" id="sidebar-content"></div>
- </div>
- <div id="sidebar-resizer"></div>
- <div id="app-container">
- <div id="canvas"></div>
- </div>
-
- <div id="derivation-progress-section">
- <div id="derivation-resizer"></div>
- <div class="derivation-progress-title">
- <div style="display: flex; align-items: center;">
- <span>推导进度</span>
- <div class="derivation-color-legend">
- <div class="derivation-color-legend-item">
- <div class="derivation-color-legend-color legend-black"></div>
- <span>未点亮</span>
- </div>
- <div class="derivation-color-legend-item">
- <div class="derivation-color-legend-color legend-yellow"></div>
- <span>当前轮次点亮</span>
- </div>
- <div class="derivation-color-legend-item">
- <div class="derivation-color-legend-color legend-green"></div>
- <span>之前已点亮</span>
- </div>
- </div>
- </div>
- <button class="derivation-progress-toggle" onclick="toggleDerivationProgress()">收起</button>
- </div>
- <div id="derivation-progress-content"></div>
- </div>
- <!-- 待解构帖子详情弹窗 -->
- <div id="post-detail-modal" class="modal-overlay">
- <div class="modal-box post-detail-modal">
- <div class="modal-header">
- <span>待解构帖子详情</span>
- <button type="button" class="modal-close" onclick="closePostDetailModal()">×</button>
- </div>
- <div class="modal-body" id="post-detail-modal-content"></div>
- </div>
- </div>
- <!-- 图集大图灯箱 -->
- <div id="image-lightbox">
- <button type="button" class="lightbox-close" onclick="closeImageLightbox()">×</button>
- <button type="button" class="lightbox-prev" onclick="lightboxPrev()">❮</button>
- <div class="lightbox-img-wrap">
- <img id="lightbox-img" src="" alt="" />
- </div>
- <button type="button" class="lightbox-next" onclick="lightboxNext()">❯</button>
- <div class="lightbox-counter" id="lightbox-counter"></div>
- </div>
- <div id="dimension-patterns-modal" onclick="if(event.target===this) closeDimensionPatternsModal()">
- <div class="dimension-patterns-dialog" onclick="event.stopPropagation()">
- <div class="dimension-patterns-head">
- <span id="dimension-patterns-modal-title">维度 patterns</span>
- <button type="button" class="dimension-patterns-close" onclick="closeDimensionPatternsModal()">关闭</button>
- </div>
- <div class="dimension-patterns-body" id="dimension-patterns-modal-body"></div>
- </div>
- </div>
- <script>
- const allData = {json_data_js};
- const derivationData = {derivation_data_js};
- const dimensionAnalyzeData = {dimension_analyze_data_js};
- const postDetailMap = {post_detail_map_js};
- const accountName = {account_name_js};
- const CONFIG = {{
- cardWidth: 320,
- constWidth: 280,
- colSpacing: 900,
- rowSpacing: 30,
- paddingX: 80,
- paddingY: 100,
- busOffset: 450,
- forkOffset: 40
- }};
- const canvas = document.getElementById('canvas');
- let flatData = {{ nodesByLevel: {{}}, map: {{}} }};
- let edgeGroups = {{}};
- let currentPostKey = "{first_key}";
- // 1. 数据解析 - 适配 node_list 和 edge_list 格式
- function parseData(postKey) {{
- flatData = {{ nodesByLevel: {{}}, map: {{}} }};
- edgeGroups = {{}};
-
- const data = allData[postKey];
- const nodesData = data.node_list || [];
- const edgesData = data.edge_list || [];
- const allUsedTreeNodes = data.all_used_tree_nodes || [];
- // 创建节点映射
- const nodeMap = {{}};
- nodesData.forEach(node => {{
- nodeMap[node.name] = node;
- }});
- // 处理人设/全局常量节点(放在 level -1,第一轮推导左侧)
- // 注意:所有节点都需要展示,不受 is_constant 和 is_local_constant 字段影响
- const constantLevel = -1;
- if (!flatData.nodesByLevel[constantLevel]) flatData.nodesByLevel[constantLevel] = [];
-
- // 遍历所有节点,全部添加到列表中(不进行任何过滤,全部展示)
- allUsedTreeNodes.forEach((constantNode, index) => {{
- // 使用索引确保即使名称重复也能区分
- const uniqueId = constantNode.name + '_const_' + index;
- const item = {{
- id: uniqueId,
- name: constantNode.name,
- data: {{
- ...constantNode,
- type: constantNode.type || '',
- is_constant: constantNode.is_constant || false,
- is_local_constant: constantNode.is_local_constant || false
- }},
- type: 'node',
- level: constantLevel,
- sources: [],
- edgeName: '',
- edgeScore: 0
- }};
-
- // 添加到数组和映射中(数组用于渲染,确保所有节点都显示)
- flatData.nodesByLevel[constantLevel].push(item);
- flatData.map[item.id] = item;
- // 名称映射用于查找(如果有重复名称,最后一个会覆盖,但不影响数组中的显示)
- flatData.map[item.name] = item;
- }});
- // 按 level 分组节点(同名节点可能出现在多轮,如「居家生活场景」level 1 与 level 2 各有一个)
- nodesData.forEach(node => {{
- const level = node.level || 0;
- if (!flatData.nodesByLevel[level]) flatData.nodesByLevel[level] = [];
- const uniqueId = node.name + '__L' + level;
- const item = {{
- id: node.name,
- uid: uniqueId,
- name: node.name,
- data: node,
- type: 'node',
- level: level,
- sources: [],
- edgeName: '',
- edgeScore: 0
- }};
- flatData.nodesByLevel[level].push(item);
- flatData.map[uniqueId] = item;
- flatData.map[item.name] = item;
- }});
- // 处理边,建立连接关系
- // 边对象有 level 字段,表示轮次;边只能连接同轮次的 output 输出节点
- // output_nodes 为对象列表,每项有 name 字段表示输出节点名称
- edgesData.forEach(edge => {{
- const outputNodes = edge.output_nodes || [];
- const inputPostNodes = edge.input_post_nodes || [];
- const usedTreeNodes = edge.used_tree_nodes || edge.input_tree_nodes || [];
- const edgeLevel = edge.level;
- // 处理 input_post_nodes 作为输入节点(这些节点在推导过程中,不在 level -1)
- const inputPostNames = inputPostNodes.map(n => n.name || n).filter(name => name);
- // 处理 used_tree_nodes / input_tree_nodes,匹配到 all_used_tree_nodes 中的节点(这些节点在 level -1)
- const usedTreeNames = [];
- usedTreeNodes.forEach(usedNode => {{
- const usedName = usedNode.name || usedNode;
- // 在 all_used_tree_nodes 中查找匹配的节点(通过 name 匹配)
- const matchedNode = allUsedTreeNodes.find(n => n.name === usedName);
- if (matchedNode) {{
- usedTreeNames.push(usedName);
- }}
- }});
- // 合并所有输入节点名称(但需要区分来源)
- // 保存输入节点的来源信息,用于后续查找正确的节点
- const allInputNames = [];
- const inputSourceMap = {{}}; // 记录每个输入节点的来源:'post' 或 'tree'
-
- inputPostNames.forEach(name => {{
- allInputNames.push(name);
- inputSourceMap[name] = 'post'; // 来自推导节点
- }});
-
- usedTreeNames.forEach(name => {{
- allInputNames.push(name);
- inputSourceMap[name] = 'tree'; // 来自人设/全局常量节点(level -1)
- }});
- // 先收集所有有效的输出节点:仅同轮次(边只能连接 edge.level 对应的 output 节点)
- // 同名节点可能出现在多轮,需按 name + level 查找
- const validOutputItems = [];
- outputNodes.forEach(outputNode => {{
- const outputName = (typeof outputNode === 'object' && outputNode !== null && outputNode.name != null) ? outputNode.name : outputNode;
- let outputItem = null;
- if (edgeLevel != null && flatData.nodesByLevel[edgeLevel]) {{
- outputItem = flatData.nodesByLevel[edgeLevel].find(n => n.name === outputName) || null;
- }}
- if (!outputItem) outputItem = flatData.map[outputName] || null;
- if (outputItem && (edgeLevel == null || outputItem.level === edgeLevel)) {{
- validOutputItems.push(outputItem);
- }}
- }});
- // 如果没有有效的输出节点,跳过
- if (validOutputItems.length === 0) return;
- // 为整个边创建一个边组(所有输出节点共享同一个边组)
- const edgeName = edge.name || '';
- const edgeScore = edge.score || 0;
- // 收集输出节点名称用于生成唯一的 edgeKey
- const outputNames = validOutputItems.map(item => item.name).sort();
- // edgeKey 需要包含输入节点、输出节点和边名称,确保每条边都有唯一的 key
- const inputKey = allInputNames.length > 0
- ? allInputNames.slice().sort().join('|')
- : 'empty';
- const outputKey = outputNames.join('|');
- const edgeKey = inputKey + '||' + outputKey + '||' + edgeName;
-
- if (!edgeGroups[edgeKey]) {{
- edgeGroups[edgeKey] = {{
- key: edgeKey,
- targets: [],
- sources: allInputNames,
- sourceMap: inputSourceMap, // 保存输入节点的来源映射
- edgeName: edgeName,
- edgeScore: edgeScore,
- edgeData: edge // 保存完整的边数据
- }};
- }}
- // 将所有输出节点添加到同一个边组,并设置相同的边信息
- validOutputItems.forEach(outputItem => {{
- // 更新输出节点的边信息
- outputItem.sources = allInputNames;
- outputItem.edgeName = edgeName;
- outputItem.edgeScore = edgeScore;
- outputItem.edgeGroupKey = edgeKey;
-
- // 添加到边组
- edgeGroups[edgeKey].targets.push(outputItem);
- }});
- }});
- }}
- // 2. 布局计算(按 level 排序,但 x 用列索引排列,空缺的 level 不占位)
- function calculateLayout() {{
- const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
- levels.forEach((level, colIndex) => {{
- const nodes = flatData.nodesByLevel[level];
- // level -1 放第一列;其余列按 colIndex 紧密排列,不因 level 空缺留白
- const x = CONFIG.paddingX + colIndex * CONFIG.colSpacing;
- let y = CONFIG.paddingY;
- createHeader(level, x);
- nodes.forEach(node => {{
- const h = estimateHeight(node);
- node.x = x;
- node.y = y;
- node.width = node.type === 'constant' ? CONFIG.constWidth : CONFIG.cardWidth;
- node.height = h;
- node.inputPoint = {{ x: node.x, y: node.y + h/2 }};
- node.outputPoint = {{ x: node.x + node.width, y: node.y + h/2 }};
- y += h + CONFIG.rowSpacing;
- }});
- }});
- }}
- function estimateHeight(node) {{
- if (node.type === 'constant') return 80;
- // level -1 的常量节点,只显示 name 和 type,固定高度
- if (node.level === -1) {{
- return 60 + 22; // name + type
- }}
- // 为了保证不同节点类型高度一致,这里统一按 point/dimension/root_source 的存在情况估算行数
- let lines = 1; // node-header
- if (node.data && node.data.point) lines++;
- if (node.data && node.data.dimension) lines++;
- if (node.data && node.data.root_source) lines++;
- return 60 + lines * 22;
- }}
- function createHeader(level, x) {{
- const existing = document.querySelector(`.column-header[data-level="${{level}}"]`);
- if (existing) existing.remove();
-
- const el = document.createElement('div');
- el.className = 'column-header';
- el.dataset.level = level;
- el.style.left = x + 'px';
- el.style.top = '40px';
- el.style.width = CONFIG.cardWidth + 'px';
-
- if (level === -1) {{
- el.textContent = '人设/全局常量';
- }} else {{
- const nums = ['一','二','三','四','五','六','七','八','九','十'];
- el.textContent = `第${{nums[level-1] || level}}轮推导`;
- }}
- canvas.appendChild(el);
- }}
- function renderNodes() {{
- // 清空现有节点
- document.querySelectorAll('.node-card, .constant-card').forEach(el => el.remove());
- const levels = Object.keys(flatData.nodesByLevel).map(Number).sort((a,b)=>a-b);
- levels.forEach(level => {{
- const nodes = flatData.nodesByLevel[level] || [];
- nodes.forEach(node => {{
- const el = document.createElement('div');
- el.dataset.id = node.uid != null ? node.uid : node.id;
- el.style.left = node.x + 'px';
- el.style.top = node.y + 'px';
- el.style.width = node.width + 'px';
- if (node.type === 'constant') {{
- el.className = 'constant-card';
- el.style.height = (node.height || estimateHeight(node)) + 'px';
- el.innerHTML = `
- <div class="constant-name">${{node.name}}</div>
- <div class="constant-value">${{node.data.value || ''}}</div>
- `;
- }} else if (node.level === -1) {{
- // level -1 的常量节点(人设/全局常量),只显示 name 和 type
- el.className = 'node-card';
- el.style.height = (node.height || estimateHeight(node)) + 'px';
- let html = `<div class="node-header">${{node.name}}</div>`;
- if (node.data.type) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.type}}</span></div>`;
- el.innerHTML = html;
- }} else {{
- // node_list 节点:is_fully_derived=false 时用虚线框,名称显示 derivation_output_point,只显示帖子选题点
- el.className = 'node-card' + (node.data.is_fully_derived === false ? ' not-fully-derived' : '');
- el.style.height = (node.height || estimateHeight(node)) + 'px';
- const displayName = (node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
- ? node.data.derivation_output_point : node.name;
- let html = `<div class="node-header">${{displayName}}</div>`;
- if (node.data.is_fully_derived === false) {{
- // 未完全推导:只显示「帖子选题点」,值为原 node_list.name
- 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>`;
- }} else {{
- // 常规节点:显示 类型(原关键点)、维度、所属选题点
- if (node.data.point) html += `<div class="row"><span class="key">类型</span><span class="val">${{node.data.point}}</span></div>`;
- if (node.data.dimension) html += `<div class="row"><span class="key">维度</span><span class="val">${{node.data.dimension}}</span></div>`;
- 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>`;
- }}
- el.innerHTML = html;
- }}
- el.onclick = (e) => {{
- e.stopPropagation();
- // 保存当前选中的节点
- currentSelectedNode = node;
- currentSelectedEdgeGroup = null; // 清除边组选中状态
- // 不自动缩放,保持当前视图大小和位置
- // 立即高亮和显示侧边栏(无延迟)
- highlightDirectSources(node);
- const sidebarTitle = (node.data && node.data.is_fully_derived === false && node.data.derivation_output_point != null && node.data.derivation_output_point !== '')
- ? `节点: ${{node.data.derivation_output_point}}` : `节点: ${{node.name}}`;
- showSidebar(node.data, sidebarTitle, node, 'node');
- }};
- canvas.appendChild(el);
- node.el = el;
- }});
- }});
- }}
- // 3. 渲染连线 - 按组渲染
- function renderEdges() {{
- // 移除旧的 SVG
- const oldSvg = document.querySelector('.edge-layer');
- if (oldSvg) oldSvg.remove();
- const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
- svg.classList.add('edge-layer');
- svg.setAttribute('width', '10000');
- svg.setAttribute('height', '8000');
- // 定义箭头
- const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
- const createMarker = (id, color) => {{
- const m = document.createElementNS('http://www.w3.org/2000/svg', 'marker');
- m.setAttribute('id', id);
- m.setAttribute('markerWidth', '10'); m.setAttribute('markerHeight', '7');
- m.setAttribute('refX', '9'); m.setAttribute('refY', '3.5');
- m.setAttribute('orient', 'auto');
- const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- p.setAttribute('d', 'M0,0 L0,7 L9,3.5 z');
- p.setAttribute('fill', color);
- m.appendChild(p);
- return m;
- }};
- defs.appendChild(createMarker('arrow-head', '#cbd5e1'));
- defs.appendChild(createMarker('arrow-head-highlight', '#2563eb'));
- svg.appendChild(defs);
- Object.values(edgeGroups).forEach(group => {{
- const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
- g.dataset.edgeGroup = group.key;
- const targets = group.targets;
- const sourceNames = group.sources;
-
- // 如果没有目标节点,跳过
- if (!targets.length) return;
- const targetX = targets[0].inputPoint.x;
- const busX = targetX - CONFIG.busOffset;
- const forkX = targetX - CONFIG.forkOffset;
- // 获取源节点(同名多轮时取低于目标层级的源)
- const sourceNodes = [];
- const sourceMap = group.sourceMap || {{}};
- const minTargetLevel = targets.length ? Math.min(...targets.map(t => t.level)) : 0;
-
- sourceNames.forEach(name => {{
- const sourceType = sourceMap[name];
- let node = null;
- if (sourceType === 'tree') {{
- const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
- node = levelMinusOneNodes.find(n => n.name === name);
- }} else {{
- for (let l = minTargetLevel - 1; l >= 0; l--) {{
- const found = (flatData.nodesByLevel[l] || []).find(n => n.name === name);
- if (found) {{ node = found; break; }}
- }}
- if (!node) {{
- const candidate = flatData.map[name];
- if (candidate && candidate.level !== -1) node = candidate;
- }}
- }}
- if (node) sourceNodes.push(node);
- }});
- targets.sort((a,b) => a.y - b.y);
- const tMinY = targets[0].inputPoint.y;
- const tMaxY = targets[targets.length - 1].inputPoint.y;
- // 核心连线的 Y 坐标(使用第一个目标节点的 Y 坐标,与参考文件保持一致)
- const mainY = tMinY;
- // 创建点击事件处理函数
- const handleGroupClick = (e) => {{
- e.stopPropagation();
- handleEdgeClick(group);
- }};
- // 如果有源节点,渲染左侧部分
- if (sourceNodes.length > 0) {{
- let sMinY = Infinity, sMaxY = -Infinity;
- sourceNodes.forEach(s => {{
- sMinY = Math.min(sMinY, s.outputPoint.y);
- sMaxY = Math.max(sMaxY, s.outputPoint.y);
- }});
- // A. 左侧源头馈线
- sourceNodes.forEach(s => {{
- const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- p.setAttribute('d', `M ${{s.outputPoint.x}} ${{s.outputPoint.y}} L ${{busX}} ${{s.outputPoint.y}}`);
- p.classList.add('edge-path', 'feeder');
- p.style.cursor = 'pointer';
- p.addEventListener('click', handleGroupClick);
- g.appendChild(p);
- const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- dot.setAttribute('cx', s.outputPoint.x); dot.setAttribute('cy', s.outputPoint.y);
- dot.setAttribute('r', 3); dot.classList.add('connector-dot');
- dot.style.cursor = 'pointer';
- dot.addEventListener('click', handleGroupClick);
- g.appendChild(dot);
- }});
- // B. 左侧主干
- if (sMinY !== sMaxY) {{
- const trunk = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- trunk.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{sMaxY}}`);
- trunk.classList.add('edge-path', 'trunk');
- trunk.style.cursor = 'pointer';
- trunk.addEventListener('click', handleGroupClick);
- g.appendChild(trunk);
- }}
- // C. 长连接线 (连接源头区域到主线高度)
- if (mainY < sMinY) {{
- const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- link.setAttribute('d', `M ${{busX}} ${{sMinY}} L ${{busX}} ${{mainY}}`);
- link.classList.add('edge-path', 'trunk');
- link.style.cursor = 'pointer';
- link.addEventListener('click', handleGroupClick);
- g.appendChild(link);
- }} else if (mainY > sMaxY) {{
- const link = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- link.setAttribute('d', `M ${{busX}} ${{sMaxY}} L ${{busX}} ${{mainY}}`);
- link.classList.add('edge-path', 'trunk');
- link.style.cursor = 'pointer';
- link.addEventListener('click', handleGroupClick);
- g.appendChild(link);
- }}
- }}
- // 核心连线(无论是否有源节点都要渲染)
- const mainLine = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- mainLine.setAttribute('d', `M ${{busX}} ${{mainY}} L ${{forkX}} ${{mainY}}`);
- mainLine.classList.add('edge-path', 'main-flow');
- mainLine.style.cursor = 'pointer';
- mainLine.addEventListener('click', handleGroupClick);
- g.appendChild(mainLine);
- // D. 右侧分叉主干
- if (targets.length > 1) {{
- const fork = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- fork.setAttribute('d', `M ${{forkX}} ${{mainY}} L ${{forkX}} ${{tMaxY}}`);
- fork.classList.add('edge-path', 'trunk');
- fork.style.cursor = 'pointer';
- fork.addEventListener('click', handleGroupClick);
- g.appendChild(fork);
- }}
- // E. 目标接入线
- targets.forEach(t => {{
- const p = document.createElementNS('http://www.w3.org/2000/svg', 'path');
- p.setAttribute('d', `M ${{forkX}} ${{t.inputPoint.y}} L ${{t.inputPoint.x}} ${{t.inputPoint.y}}`);
- p.classList.add('edge-path', 'entry');
- p.setAttribute('marker-end', 'url(#arrow-head)');
- p.style.cursor = 'pointer';
- p.addEventListener('click', handleGroupClick);
- g.appendChild(p);
-
- if (targets.length > 1) {{
- const dot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- dot.setAttribute('cx', forkX); dot.setAttribute('cy', t.inputPoint.y);
- dot.setAttribute('r', 2); dot.classList.add('connector-dot');
- dot.style.cursor = 'pointer';
- dot.addEventListener('click', handleGroupClick);
- g.appendChild(dot);
- }}
- }});
- // 连接点标记(只在有源节点时显示)
- if (sourceNodes.length > 0) {{
- const busDot = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
- busDot.setAttribute('cx', busX); busDot.setAttribute('cy', mainY);
- busDot.setAttribute('r', 3); busDot.classList.add('connector-dot');
- busDot.style.cursor = 'pointer';
- busDot.addEventListener('click', handleGroupClick);
- g.appendChild(busDot);
- }}
- // F. 文字标签
- if (group.edgeName) {{
- const textX = (busX + forkX) / 2;
- const textY = mainY - 5;
- const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- text.setAttribute('x', textX); text.setAttribute('y', textY);
- text.classList.add('edge-label-text');
- text.textContent = group.edgeName;
- text.style.cursor = 'pointer';
- text.addEventListener('click', handleGroupClick);
- g.appendChild(text);
- // 仅当边数据中有 score 字段时才在连线下方显示条件概率
- const hasScore = group.edgeData && group.edgeData.score !== undefined && group.edgeData.score !== null;
- if (hasScore) {{
- const subText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
- subText.setAttribute('x', textX);
- subText.setAttribute('y', mainY + 14);
- subText.classList.add('edge-label-sub');
- let labelPrefix = "条件概率";
- if (group.edgeName && group.edgeName.startsWith("外部搜索")) {{
- labelPrefix = "搜索出现概率";
- }}
- subText.textContent = `${{labelPrefix}}:${{group.edgeScore}}`;
- subText.style.cursor = 'pointer';
- subText.addEventListener('click', handleGroupClick);
- g.appendChild(subText);
- }}
- }}
- svg.appendChild(g);
- }});
- canvas.insertBefore(svg, canvas.firstChild);
- }}
- // 处理边的点击事件
- function handleEdgeClick(group) {{
- // 保存当前选中的边组
- currentSelectedEdgeGroup = group;
- currentSelectedNode = null; // 清除节点选中状态
-
- // 获取边的详细信息
- const edgeData = group.edgeData || {{}};
- const edgeDetail = edgeData.detail || {{}};
- const edgeName = group.edgeName || '';
- const edgeScore = group.edgeScore || 0;
-
- // 获取目标节点名称(用于外部边和工具边的展示)
- const targetNames = group.targets.map(t => t.name) || [];
- const targetNodeName = targetNames.length > 0 ? targetNames[0] : '';
-
- // 构建边的完整数据对象
- const fullEdgeData = {{
- name: targetNodeName, // 用于外部边和工具边的展示
- edgeName: edgeName,
- edgeScore: edgeScore,
- sources: group.sources || [],
- targets: targetNames,
- type: edgeName.includes('外部搜索') || edgeName.includes('外部寻找') ? '外部边' :
- edgeName.includes('工具') ? '工具边' : '普通边',
- ...edgeData
- }};
-
- // 高亮相关的节点和边
- highlightEdgeGroup(group);
-
- // 显示边的详情(先打开侧边栏)
- const sourceNames = group.sources.join('、');
- const targetNamesStr = targetNames.join('、');
- const title = `连线: ${{sourceNames}} → ${{targetNamesStr}}`;
- showSidebar(edgeDetail, title, fullEdgeData, 'edge');
- }}
- // 计算边组相关节点的边界框并缩放显示
- function fitEdgeGroupToView(group, useAnimation = true) {{
- if (!group) return;
- // 收集所有相关节点(源节点和目标节点)
- const relatedNodes = new Set();
-
- // 添加所有源节点(需要根据来源区分查找)
- const sourceMap = group.sourceMap || {{}};
- group.sources.forEach(sName => {{
- const sourceType = sourceMap[sName];
- let sNode = null;
-
- if (sourceType === 'tree') {{
- // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
- const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
- sNode = levelMinusOneNodes.find(n => n.name === sName);
- }} else {{
- // input_post_nodes:从推导节点中查找(排除 level -1)
- const candidate = flatData.map[sName];
- if (candidate && candidate.level !== -1) {{
- sNode = candidate;
- }} else {{
- // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
- for (let level in flatData.nodesByLevel) {{
- const levelNum = parseInt(level);
- if (levelNum !== -1) {{
- const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
- if (found) {{
- sNode = found;
- break;
- }}
- }}
- }}
- }}
- }}
-
- if (sNode && sNode.x !== undefined) {{
- relatedNodes.add(sNode);
- }}
- }});
-
- // 添加所有目标节点
- group.targets.forEach(t => {{
- if (t && t.x !== undefined) {{
- relatedNodes.add(t);
- }}
- }});
- // 如果没有任何节点,直接返回
- if (relatedNodes.size === 0) return;
- // 计算边界框(包括节点和连线可能延伸的区域)
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- relatedNodes.forEach(node => {{
- if (node.x !== undefined && node.y !== undefined) {{
- // 节点本身的边界
- minX = Math.min(minX, node.x);
- minY = Math.min(minY, node.y);
- maxX = Math.max(maxX, node.x + (node.width || 0));
- maxY = Math.max(maxY, node.y + (node.height || 0));
-
- // 考虑连线可能延伸到左侧(busOffset)
- if (node.inputPoint) {{
- const leftExtend = node.inputPoint.x - CONFIG.busOffset;
- minX = Math.min(minX, leftExtend);
- }}
- if (node.outputPoint) {{
- const rightExtend = node.outputPoint.x + CONFIG.busOffset;
- maxX = Math.max(maxX, rightExtend);
- }}
- }}
- }});
- // 如果边界框无效,直接返回
- if (minX === Infinity) return;
- // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
- const padding = 40;
- const contentWidth = maxX - minX + padding * 2;
- const contentHeight = maxY - minY + padding * 2;
- const contentCenterX = (minX + maxX) / 2;
- const contentCenterY = (minY + maxY) / 2;
- // 获取视口大小(考虑侧边栏是否打开)
- const sidebar = document.getElementById('sidebar');
- const isSidebarOpen = sidebar && sidebar.classList.contains('active');
- // 当侧边栏打开时,画布宽度会缩小(减去侧边栏实际宽度)
- const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
- const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
- const viewH = container.offsetHeight;
- // 计算缩放比例,确保内容能完全显示
- const scaleX = (viewW - padding * 2) / contentWidth;
- const scaleY = (viewH - padding * 2) / contentHeight;
- // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
- scale = Math.min(scaleX, scaleY, 2.0);
- // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
- const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
- translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
- translateY = (viewH / 2) - (contentCenterY * scale);
- // 根据参数决定是否使用动画
- if (useAnimation) {{
- canvas.classList.add('animating');
- updateTransform();
- setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
- }} else {{
- // 移除动画类,确保瞬间完成
- canvas.classList.remove('animating');
- updateTransform();
- }}
- }}
- // 高亮边组
- function highlightEdgeGroup(group) {{
- // Reset
- document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
- el.classList.remove('highlight');
- el.classList.add('dimmed');
- }});
- document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
- el.classList.remove('highlight');
- el.classList.add('dimmed');
- }});
- document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
- document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
-
- // 高亮源节点(根据来源区分查找)
- const sourceMap = group.sourceMap || {{}};
- group.sources.forEach(sourceName => {{
- const sourceType = sourceMap[sourceName];
- let sourceNode = null;
-
- if (sourceType === 'tree') {{
- // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
- const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
- sourceNode = levelMinusOneNodes.find(n => n.name === sourceName);
- }} else {{
- // input_post_nodes:从推导节点中查找(排除 level -1)
- const candidate = flatData.map[sourceName];
- if (candidate && candidate.level !== -1) {{
- sourceNode = candidate;
- }} else {{
- // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
- for (let level in flatData.nodesByLevel) {{
- const levelNum = parseInt(level);
- if (levelNum !== -1) {{
- const found = flatData.nodesByLevel[levelNum].find(n => n.name === sourceName);
- if (found) {{
- sourceNode = found;
- break;
- }}
- }}
- }}
- }}
- }}
-
- if (sourceNode && sourceNode.el) {{
- sourceNode.el.classList.remove('dimmed');
- sourceNode.el.classList.add('highlight');
- }}
- }});
-
- // 高亮目标节点
- group.targets.forEach(target => {{
- if (target.el) {{
- target.el.classList.remove('dimmed');
- target.el.classList.add('highlight');
- }}
- }});
-
- // 高亮边组
- const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
- if (edgeGroupEl) {{
- Array.from(edgeGroupEl.children).forEach(child => {{
- child.classList.remove('dimmed');
- child.classList.add('highlight');
- if (child.classList.contains('entry')) {{
- child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
- }}
- }});
- }}
- }}
- // 4. 交互:高亮组
- function highlightDirectSources(targetNode) {{
- // Reset
- document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
- el.classList.remove('highlight');
- el.classList.add('dimmed');
- }});
- document.querySelectorAll('.edge-path, .connector-dot, .edge-label-text, .edge-label-sub').forEach(el => {{
- el.classList.remove('highlight');
- el.classList.add('dimmed');
- }});
- document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
- document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
- let nodesToHighlight = [targetNode];
- if (targetNode.edgeGroupKey) {{
- const group = edgeGroups[targetNode.edgeGroupKey];
- if (group) {{
- nodesToHighlight = group.targets;
- const edgeGroupEl = document.querySelector(`g[data-edge-group="${{group.key}}"]`);
- if (edgeGroupEl) {{
- Array.from(edgeGroupEl.children).forEach(child => {{
- child.classList.remove('dimmed');
- child.classList.add('highlight');
- if(child.classList.contains('entry')) {{
- child.setAttribute('marker-end', 'url(#arrow-head-highlight)');
- }}
- }});
- }}
- // 高亮源节点(根据来源区分查找;同名多轮时取作为“源”的那一轮)
- const sourceMap = group.sourceMap || {{}};
- const minTargetLevel = group.targets.length ? Math.min(...group.targets.map(t => t.level)) : 0;
- group.sources.forEach(sName => {{
- const sourceType = sourceMap[sName];
- let sNode = null;
-
- if (sourceType === 'tree') {{
- const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
- sNode = levelMinusOneNodes.find(n => n.name === sName);
- }} else {{
- // input_post_nodes:取层级低于目标且同名的节点(从最接近目标的一轮开始找)
- for (let l = minTargetLevel - 1; l >= 0; l--) {{
- const found = (flatData.nodesByLevel[l] || []).find(n => n.name === sName);
- if (found) {{ sNode = found; break; }}
- }}
- if (!sNode) {{
- const candidate = flatData.map[sName];
- if (candidate && candidate.level !== -1) sNode = candidate;
- }}
- }}
-
- if (sNode && sNode.el) {{
- sNode.el.classList.remove('dimmed');
- sNode.el.classList.add('highlight');
- }}
- }});
- }}
- }}
- nodesToHighlight.forEach(n => {{
- if (n.el) {{
- n.el.classList.remove('dimmed');
- n.el.classList.add('highlight');
- }}
- }});
- }}
- // --- 视图控制 ---
- let scale = 0.8, translateX = 50, translateY = 50;
- let isDragging = false, startClientX, startClientY, startTranslateX, startTranslateY;
- const DRAG_SENSITIVITY = 1.35; // 拖拽灵敏度,>1 更跟手
- let currentSelectedNode = null; // 跟踪当前选中的节点
- let currentSelectedEdgeGroup = null; // 跟踪当前选中的边组
- const container = document.getElementById('app-container');
- function updateTransform() {{
- // 采用先缩放再平移的顺序,使平移量与缩放无关,便于以视图中心进行缩放和平移
- canvas.style.transform = `scale(${{scale}}) translate(${{translateX}}px, ${{translateY}}px)`;
- }}
- // 计算相关节点的边界框并缩放显示(当前节点及其连线上的节点)
- function fitRelatedNodesToView(targetNode, useAnimation = true) {{
- if (!targetNode || targetNode.x === undefined) return;
- // 收集所有相关节点
- const relatedNodes = new Set();
- relatedNodes.add(targetNode);
- // 如果节点有边组,添加同组的其他目标节点
- if (targetNode.edgeGroupKey) {{
- const group = edgeGroups[targetNode.edgeGroupKey];
- if (group) {{
- // 添加同组的所有目标节点
- group.targets.forEach(t => {{
- if (t && t.x !== undefined) relatedNodes.add(t);
- }});
-
- // 添加所有源节点(需要根据来源区分查找)
- const sourceMap = group.sourceMap || {{}};
- group.sources.forEach(sName => {{
- const sourceType = sourceMap[sName];
- let sNode = null;
-
- if (sourceType === 'tree') {{
- // used_tree_nodes:从 level -1 的人设/全局常量节点中查找
- const levelMinusOneNodes = flatData.nodesByLevel[-1] || [];
- sNode = levelMinusOneNodes.find(n => n.name === sName);
- }} else {{
- // input_post_nodes:从推导节点中查找(排除 level -1)
- const candidate = flatData.map[sName];
- if (candidate && candidate.level !== -1) {{
- sNode = candidate;
- }} else {{
- // 如果 map 中找到的是 level -1 的节点,需要从其他 level 中查找
- for (let level in flatData.nodesByLevel) {{
- const levelNum = parseInt(level);
- if (levelNum !== -1) {{
- const found = flatData.nodesByLevel[levelNum].find(n => n.name === sName);
- if (found) {{
- sNode = found;
- break;
- }}
- }}
- }}
- }}
- }}
-
- if (sNode && sNode.x !== undefined) {{
- relatedNodes.add(sNode);
- }}
- }});
- }}
- }}
- const sidebar = document.getElementById('sidebar');
- const isSidebarOpen = sidebar && sidebar.classList.contains('active');
- const sidebarWidth = isSidebarOpen ? sidebar.offsetWidth : 0;
- const viewW = isSidebarOpen ? (container.offsetWidth - sidebarWidth) : container.offsetWidth;
- const viewH = container.offsetHeight;
- // 仅当节点没有连线(只有一个相关节点)时:只平移使节点居中,不改变缩放,避免视图被放大超出
- if (relatedNodes.size === 1) {{
- const nodeCenterX = targetNode.x + (targetNode.width || 0) / 2;
- const nodeCenterY = targetNode.y + (targetNode.height || 0) / 2;
- const offsetX = isSidebarOpen ? 0 : 0;
- translateX = (viewW / 2) - (nodeCenterX * scale) + offsetX;
- translateY = (viewH / 2) - (nodeCenterY * scale);
- if (useAnimation) {{
- canvas.classList.add('animating');
- updateTransform();
- setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
- }} else {{
- canvas.classList.remove('animating');
- updateTransform();
- }}
- return;
- }}
- // 计算边界框(包括节点和连线可能延伸的区域)
- let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
- relatedNodes.forEach(node => {{
- if (node.x !== undefined && node.y !== undefined) {{
- // 节点本身的边界
- minX = Math.min(minX, node.x);
- minY = Math.min(minY, node.y);
- maxX = Math.max(maxX, node.x + (node.width || 0));
- maxY = Math.max(maxY, node.y + (node.height || 0));
-
- // 考虑连线可能延伸到左侧(busOffset)
- if (node.inputPoint) {{
- const leftExtend = node.inputPoint.x - CONFIG.busOffset;
- minX = Math.min(minX, leftExtend);
- }}
- }}
- }});
- // 如果边界框无效,直接返回
- if (minX === Infinity) return;
- // 添加一些边距,确保内容不会贴边(减少边距让内容显示更大)
- const padding = 40;
- const contentWidth = maxX - minX + padding * 2;
- const contentHeight = maxY - minY + padding * 2;
- const contentCenterX = (minX + maxX) / 2;
- const contentCenterY = (minY + maxY) / 2;
- // 计算缩放比例,确保内容能完全显示
- const scaleX = (viewW - padding * 2) / contentWidth;
- const scaleY = (viewH - padding * 2) / contentHeight;
- // 允许放大到2.0,让节点尽可能大,但不超过2.0避免过大
- scale = Math.min(scaleX, scaleY, 2.0);
- // 计算偏移,使内容居中(考虑侧边栏打开时的偏移)
- const offsetX = isSidebarOpen ? 0 : 0; // 侧边栏打开时,画布已经通过CSS向右移动了
- translateX = (viewW / 2) - (contentCenterX * scale) + offsetX;
- translateY = (viewH / 2) - (contentCenterY * scale);
- // 根据参数决定是否使用动画
- if (useAnimation) {{
- canvas.classList.add('animating');
- updateTransform();
- setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
- }} else {{
- // 移除动画类,确保瞬间完成
- canvas.classList.remove('animating');
- updateTransform();
- }}
- }}
- let hasDragged = false; // 标记是否发生了拖动
-
- container.addEventListener('click', e => {{
- // 只有在没有拖动的情况下才重置视图
- if (!hasDragged && (e.target.id === 'app-container' || e.target.id === 'canvas' || e.target.classList.contains('edge-layer'))) {{
- resetView();
- }}
- // 处理完点击事件后重置标志
- hasDragged = false;
- }});
- function resetView() {{
- document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
- document.querySelectorAll('.dimmed').forEach(el => el.classList.remove('dimmed'));
- document.querySelectorAll('.edge-path').forEach(p => p.removeAttribute('marker-end'));
- document.querySelectorAll('.edge-path.entry').forEach(p => p.setAttribute('marker-end', 'url(#arrow-head)'));
- document.getElementById('search-input').value = '';
- currentSelectedNode = null; // 清除当前选中的节点
- currentSelectedEdgeGroup = null; // 清除当前选中的边组
- closeSidebar();
- }}
- const searchInput = document.getElementById('search-input');
- searchInput.addEventListener('input', (e) => {{
- const val = e.target.value.toLowerCase();
- document.querySelectorAll('.node-card, .constant-card').forEach(el => {{
- const name = el.innerText.toLowerCase();
- if(!val) el.classList.remove('dimmed');
- else if (name.includes(val)) el.classList.remove('dimmed');
- else el.classList.add('dimmed');
- }});
- }});
- searchInput.addEventListener('keydown', (e) => {{
- if (e.key === 'Enter') {{
- const val = searchInput.value.toLowerCase();
- if (!val) return;
- const match = Object.values(flatData.map).find(n => n.name.toLowerCase().includes(val));
- if (match) focusOnNode(match);
- }}
- }});
- function focusOnNode(node) {{
- const nodeCenterX = node.x + node.width / 2;
- const nodeCenterY = node.y + node.height / 2;
- const viewW = container.offsetWidth;
- const viewH = container.offsetHeight;
- translateX = (viewW / 2) - (nodeCenterX * scale);
- translateY = (viewH / 2) - (nodeCenterY * scale);
- canvas.classList.add('animating');
- updateTransform();
- setTimeout(() => {{ canvas.classList.remove('animating'); }}, 600);
- highlightDirectSources(node);
- }}
- container.addEventListener('mousedown', e => {{
- if (e.target === container || e.target.id === 'canvas' || e.target.classList.contains('edge-layer')) {{
- isDragging = true;
- hasDragged = false; // 重置拖动标志
- startClientX = e.clientX;
- startClientY = e.clientY;
- startTranslateX = translateX;
- startTranslateY = translateY;
- container.classList.add('grabbing');
- }}
- }});
- window.addEventListener('mousemove', e => {{
- if (isDragging) {{
- e.preventDefault();
- translateX = startTranslateX + (e.clientX - startClientX) * DRAG_SENSITIVITY;
- translateY = startTranslateY + (e.clientY - startClientY) * DRAG_SENSITIVITY;
- updateTransform();
- hasDragged = true; // 标记发生了拖动
- }}
- }});
- window.addEventListener('mouseup', () => {{
- isDragging = false;
- container.classList.remove('grabbing');
- }});
- container.addEventListener('wheel', e => {{
- e.preventDefault();
-
- // 以当前视图中心为缩放中心,保证缩放时画面不会“往左上角跑”
- const viewW = container.offsetWidth;
- const viewH = container.offsetHeight;
- const centerScreenX = viewW / 2;
- const centerScreenY = viewH / 2;
- // 当前视图中心对应的画布坐标(world coords)
- const centerWorldX = (centerScreenX - translateX) / scale;
- const centerWorldY = (centerScreenY - translateY) / scale;
- // 更小的缩放步长,让滚轮缩放更平滑
- const zoomStep = 0.05; // 每次滚轮约 5% 的缩放变化
- const zoomFactor = e.deltaY > 0 ? (1 - zoomStep) : (1 + zoomStep);
- const newScale = Math.max(0.1, Math.min(3, scale * zoomFactor));
- // 根据新的缩放比例,调整平移量,使视图中心保持不动
- translateX = centerScreenX - centerWorldX * newScale;
- translateY = centerScreenY - centerWorldY * newScale;
- scale = newScale;
- updateTransform();
- }}, {{ passive: false }});
- function escapeHtml(text) {{
- if (!text) return "";
- const div = document.createElement("div");
- div.textContent = text;
- return div.innerHTML;
- }}
- function showSidebar(detail, title, fullData, sidebarType) {{
- const container = document.getElementById('sidebar-content');
- const sidebar = document.getElementById('sidebar');
- const appContainer = document.getElementById('app-container');
- const titleEl = document.getElementById('sidebar-title');
- if (titleEl) titleEl.textContent = (sidebarType === 'edge') ? '边详情' : '节点详情';
- container.innerHTML = '';
- if (fullData && (fullData.id === "root" || fullData.name === "root")) {{
- renderRootDetail(detail, container);
- sidebar.classList.add('active');
- appContainer.classList.add('sidebar-open');
- updateCanvasWidth();
- return;
- }}
- // 添加标题
- const titleDiv = document.createElement('div');
- titleDiv.className = 'detail-item';
- const titleLabel = document.createElement('label');
- titleLabel.textContent = title || '节点详情';
- titleDiv.appendChild(titleLabel);
- container.appendChild(titleDiv);
- // 检查是否是外部边或工具边(通过 edgeName 判断)
- const edgeName = fullData?.edgeName || '';
- const isExternalEdge = edgeName && (edgeName.includes('外部搜索') || edgeName.includes('外部寻找'));
- const isToolEdge = edgeName && edgeName.includes('工具');
- // 外部边:特殊展示逻辑
- if (isExternalEdge) {{
- renderExternalEdgeDetail(detail, container, fullData?.name || '');
- if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
- const scoreItem = document.createElement('div');
- scoreItem.className = 'detail-item';
- const scoreLabel = document.createElement('label');
- scoreLabel.textContent = 'Score:';
- scoreItem.appendChild(scoreLabel);
- const scoreVal = document.createElement('div');
- scoreVal.className = 'detail-val';
- scoreVal.textContent = fullData.edgeScore.toFixed(4);
- scoreItem.appendChild(scoreVal);
- container.appendChild(scoreItem);
- }}
- sidebar.classList.add('active');
- appContainer.classList.add('sidebar-open');
- updateCanvasWidth();
- return;
- }}
- // 工具边:特殊展示逻辑
- if (isToolEdge) {{
- renderToolEdgeDetail(detail, container, fullData?.name || '');
- if (fullData && fullData.edgeScore !== undefined && fullData.edgeScore !== null) {{
- const scoreItem = document.createElement('div');
- scoreItem.className = 'detail-item';
- const scoreLabel = document.createElement('label');
- scoreLabel.textContent = 'Score:';
- scoreItem.appendChild(scoreLabel);
- const scoreVal = document.createElement('div');
- scoreVal.className = 'detail-val';
- scoreVal.textContent = fullData.edgeScore.toFixed(4);
- scoreItem.appendChild(scoreVal);
- container.appendChild(scoreItem);
- }}
- sidebar.classList.add('active');
- appContainer.classList.add('sidebar-open');
- updateCanvasWidth();
- return;
- }}
- // 过滤掉路径相关的字段
- const pathFields = ['source', 'target', 'id', 'originalData', 'internal', 'external', 'internal_edge', 'external_edge', 'children', 'parent_edge', 'sources', 'edgeName', 'edgeScore', 'edgeGroupKey'];
- // 过滤detail中的路径字段
- const filteredDetail = {{}};
- if (detail && typeof detail === "object") {{
- Object.entries(detail).forEach(([key, value]) => {{
- if (!pathFields.includes(key)) {{
- filteredDetail[key] = value;
- }}
- }});
- }}
- // 如果没有detail内容
- if (!filteredDetail || Object.keys(filteredDetail).length === 0) {{
- const emptyDiv = document.createElement('div');
- emptyDiv.className = 'detail-empty';
- emptyDiv.textContent = '暂无详情信息';
- container.appendChild(emptyDiv);
- sidebar.classList.add('active');
- appContainer.classList.add('sidebar-open');
- updateCanvasWidth();
- return;
- }}
- // 显示detail内容
- Object.entries(filteredDetail).forEach(([key, value]) => {{
- // 跳过空值
- if (value === null || value === undefined || value === "") return;
- const item = document.createElement('div');
- item.className = 'detail-item';
- const label = document.createElement('label');
- label.textContent = key + ':';
- item.appendChild(label);
- if (typeof value === "object" && value !== null && !Array.isArray(value)) {{
- // 对象结构,展示 KV 列表
- const subContainer = document.createElement('div');
- subContainer.className = 'detail-val';
- subContainer.style.paddingLeft = '15px';
- subContainer.style.borderLeft = '3px solid #eee';
- subContainer.style.marginTop = '10px';
- subContainer.style.fontSize = '14px';
-
- Object.entries(value).forEach(([subKey, subValue]) => {{
- if (subValue === null || subValue === undefined || subValue === "") return;
- const subItem = document.createElement('div');
- subItem.style.marginBottom = '8px';
- const subKeySpan = document.createElement('span');
- subKeySpan.style.color = '#666';
- subKeySpan.textContent = subKey + ': ';
- subItem.appendChild(subKeySpan);
- const subValSpan = document.createElement('span');
- subValSpan.textContent = typeof subValue === 'object' ? JSON.stringify(subValue) : subValue;
- subItem.appendChild(subValSpan);
- subContainer.appendChild(subItem);
- }});
- item.appendChild(subContainer);
- }} else {{
- const valueContainer = document.createElement('div');
- valueContainer.className = 'detail-val';
- if (Array.isArray(value)) {{
- if (value.length === 0) return;
-
- // 检查数组元素是否为对象
- const isArrayOfObjects = value.length > 0 && typeof value[0] === 'object' && value[0] !== null;
-
- if (isArrayOfObjects) {{
- // 数组元素为对象时,使用表格展示
- const table = document.createElement('table');
- table.style.width = '100%';
- table.style.borderCollapse = 'collapse';
- table.style.fontSize = '13px';
- table.style.marginTop = '5px';
- // 统计所有列名(字段)
- const columnsSet = new Set();
- value.forEach(v => {{
- if (v && typeof v === 'object') {{
- Object.keys(v).forEach(k => columnsSet.add(k));
- }}
- }});
- const columns = Array.from(columnsSet);
- // 表头
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- const thIndex = document.createElement('th');
- thIndex.textContent = '#';
- thIndex.style.borderBottom = '1px solid #eee';
- thIndex.style.padding = '4px 6px';
- thIndex.style.textAlign = 'left';
- thIndex.style.color = '#888';
- headerRow.appendChild(thIndex);
- columns.forEach(col => {{
- const th = document.createElement('th');
- th.textContent = col;
- th.style.borderBottom = '1px solid #eee';
- th.style.padding = '4px 6px';
- th.style.textAlign = 'left';
- th.style.color = '#555';
- headerRow.appendChild(th);
- }});
- thead.appendChild(headerRow);
- table.appendChild(thead);
- // 表体
- const tbody = document.createElement('tbody');
- value.forEach((v, i) => {{
- const row = document.createElement('tr');
- row.style.borderBottom = '1px dashed #f0f0f0';
- const tdIndex = document.createElement('td');
- tdIndex.textContent = i + 1;
- tdIndex.style.padding = '4px 6px';
- tdIndex.style.color = '#999';
- row.appendChild(tdIndex);
- columns.forEach(col => {{
- const cellVal = v && typeof v === 'object' ? v[col] : undefined;
- const td = document.createElement('td');
- td.textContent = cellVal === undefined || cellVal === null
- ? ""
- : (typeof cellVal === 'object' ? JSON.stringify(cellVal) : cellVal);
- td.style.padding = '4px 6px';
- td.style.color = '#666';
- td.style.verticalAlign = 'top';
- row.appendChild(td);
- }});
- tbody.appendChild(row);
- }});
- table.appendChild(tbody);
- valueContainer.appendChild(table);
- }} else {{
- // 普通数组:每个元素一行的单列表格
- const table = document.createElement('table');
- table.style.width = '100%';
- table.style.borderCollapse = 'collapse';
- table.style.fontSize = '13px';
- table.style.marginTop = '5px';
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- const thIndex = document.createElement('th');
- thIndex.textContent = '#';
- thIndex.style.borderBottom = '1px solid #eee';
- thIndex.style.padding = '4px 6px';
- thIndex.style.textAlign = 'left';
- thIndex.style.color = '#888';
- headerRow.appendChild(thIndex);
- const thValue = document.createElement('th');
- thValue.textContent = 'value';
- thValue.style.borderBottom = '1px solid #eee';
- thValue.style.padding = '4px 6px';
- thValue.style.textAlign = 'left';
- thValue.style.color = '#555';
- headerRow.appendChild(thValue);
- thead.appendChild(headerRow);
- table.appendChild(thead);
- const tbody = document.createElement('tbody');
- value.forEach((v, i) => {{
- const row = document.createElement('tr');
- row.style.borderBottom = '1px dashed #f0f0f0';
- const tdIndex = document.createElement('td');
- tdIndex.textContent = i + 1;
- tdIndex.style.padding = '4px 6px';
- tdIndex.style.color = '#999';
- row.appendChild(tdIndex);
- const tdValue = document.createElement('td');
- tdValue.textContent = typeof v === 'object' ? JSON.stringify(v) : v;
- tdValue.style.padding = '4px 6px';
- tdValue.style.color = '#666';
- tdValue.style.verticalAlign = 'top';
- row.appendChild(tdValue);
- tbody.appendChild(row);
- }});
- table.appendChild(tbody);
- valueContainer.appendChild(table);
- }}
- }} else {{
- let displayValue = value;
- if (typeof value === "string" && value.length > 500) {{
- displayValue = value.substring(0, 500) + "...";
- }}
- valueContainer.textContent = displayValue;
- }}
- item.appendChild(valueContainer);
- }}
- container.appendChild(item);
- }});
- // 如果有score,也显示
- if (fullData && fullData.score !== undefined && fullData.score !== null) {{
- const scoreItem = document.createElement('div');
- scoreItem.className = 'detail-item';
- const scoreLabel = document.createElement('label');
- scoreLabel.textContent = 'Score:';
- scoreItem.appendChild(scoreLabel);
- const scoreVal = document.createElement('div');
- scoreVal.className = 'detail-val';
- scoreVal.textContent = fullData.score.toFixed(4);
- scoreItem.appendChild(scoreVal);
- container.appendChild(scoreItem);
- }}
- sidebar.classList.add('active');
- appContainer.classList.add('sidebar-open');
- updateCanvasWidth();
- }}
- function renderExternalEdgeDetail(detail, container, targetNodeName) {{
- if (!detail) return;
- const targetName = targetNodeName || "";
- // 1. 全局常量
- const globalConstants = detail["全局常量"] || [];
- if (Array.isArray(globalConstants) && globalConstants.length > 0) {{
- const globalSection = document.createElement('div');
- globalSection.className = 'detail-item';
- const globalLabel = document.createElement('label');
- globalLabel.textContent = '全局常量:';
- globalSection.appendChild(globalLabel);
- const globalValue = document.createElement('div');
- globalValue.className = 'detail-val';
- globalValue.style.marginTop = '8px';
- globalValue.textContent = globalConstants.join('、');
- globalSection.appendChild(globalValue);
- container.appendChild(globalSection);
- }}
- // 2. 局部常量
- const localConstants = detail["局部常量"] || [];
- if (Array.isArray(localConstants) && localConstants.length > 0) {{
- const localSection = document.createElement('div');
- localSection.className = 'detail-item';
- const localLabel = document.createElement('label');
- localLabel.textContent = '局部常量:';
- localSection.appendChild(localLabel);
- const localValue = document.createElement('div');
- localValue.className = 'detail-val';
- localValue.style.marginTop = '8px';
- localValue.textContent = localConstants.join('、');
- localSection.appendChild(localValue);
- container.appendChild(localSection);
- }}
- // 3. 匹配到的模式
- const matchedPatterns = detail["匹配到的模式"] || [];
- if (Array.isArray(matchedPatterns) && matchedPatterns.length > 0) {{
- const patternSection = document.createElement('div');
- patternSection.className = 'detail-item';
- const patternLabel = document.createElement('label');
- patternLabel.textContent = '匹配到的模式:';
- patternSection.appendChild(patternLabel);
- const patternValue = document.createElement('div');
- patternValue.className = 'detail-val';
- patternValue.style.marginTop = '8px';
- container.appendChild(patternSection);
- patternSection.appendChild(patternValue);
- matchedPatterns.forEach((pattern, idx) => {{
- const patternBlock = document.createElement('div');
- patternBlock.style.marginBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
- patternBlock.style.paddingBottom = idx < matchedPatterns.length - 1 ? '12px' : '0';
- patternBlock.style.borderBottom = idx < matchedPatterns.length - 1 ? '1px solid #eee' : 'none';
- // 显示 id 和 support
- const patternHeader = document.createElement('div');
- patternHeader.style.fontWeight = 'bold';
- patternHeader.style.marginBottom = '6px';
- patternHeader.style.fontSize = '13px';
- patternHeader.textContent = '模式 ID: ' + (pattern.id || "") + ', Support: ' + (pattern.support !== undefined ? pattern.support.toFixed(4) : "");
- patternBlock.appendChild(patternHeader);
- // items 展示成表格
- const items = pattern.items || [];
- if (items.length > 0) {{
- const itemsTable = document.createElement('table');
- itemsTable.style.width = '100%';
- itemsTable.style.borderCollapse = 'collapse';
- itemsTable.style.fontSize = '13px';
- itemsTable.style.marginTop = '6px';
- itemsTable.style.marginBottom = '6px';
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- ["name", "point", "dimension", "type"].forEach(col => {{
- const th = document.createElement('th');
- th.textContent = col;
- th.style.border = '1px solid #ddd';
- th.style.padding = '6px 8px';
- th.style.textAlign = 'left';
- th.style.background = '#f5f5f5';
- headerRow.appendChild(th);
- }});
- thead.appendChild(headerRow);
- itemsTable.appendChild(thead);
- const tbody = document.createElement('tbody');
- items.forEach(item => {{
- const tr = document.createElement('tr');
- ["name", "point", "dimension", "type"].forEach(col => {{
- const td = document.createElement('td');
- td.textContent = item[col] || "";
- td.style.border = '1px solid #ddd';
- td.style.padding = '6px 8px';
- tr.appendChild(td);
- }});
- tbody.appendChild(tr);
- }});
- itemsTable.appendChild(tbody);
- patternBlock.appendChild(itemsTable);
- }}
- // match_points
- const matchPoints = pattern.match_points || [];
- if (matchPoints.length > 0) {{
- const matchPointsDiv = document.createElement('div');
- matchPointsDiv.style.fontSize = '13px';
- matchPointsDiv.style.color = '#666';
- matchPointsDiv.style.marginTop = '4px';
- matchPointsDiv.textContent = '匹配点: ' + matchPoints.join('、');
- patternBlock.appendChild(matchPointsDiv);
- }}
- patternValue.appendChild(patternBlock);
- }});
- }}
- // 4. 动态常量
- const dynamicConstants = detail["动态常量"] || [];
- if (Array.isArray(dynamicConstants) && dynamicConstants.length > 0) {{
- const dynamicSection = document.createElement('div');
- dynamicSection.className = 'detail-item';
- const dynamicLabel = document.createElement('label');
- dynamicLabel.textContent = '动态常量:';
- dynamicSection.appendChild(dynamicLabel);
- const dynamicValue = document.createElement('div');
- dynamicValue.className = 'detail-val';
- dynamicValue.style.marginTop = '8px';
- container.appendChild(dynamicSection);
- dynamicSection.appendChild(dynamicValue);
- const dynamicTable = document.createElement('table');
- dynamicTable.style.width = '100%';
- dynamicTable.style.borderCollapse = 'collapse';
- dynamicTable.style.fontSize = '13px';
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- const columns = ["point", "tree_parent_node", "match_score", "tree_child_node", "relative_ratio"];
- columns.forEach(col => {{
- const th = document.createElement('th');
- th.textContent = col;
- th.style.border = '1px solid #ddd';
- th.style.padding = '6px 8px';
- th.style.textAlign = 'left';
- th.style.background = '#f5f5f5';
- headerRow.appendChild(th);
- }});
- thead.appendChild(headerRow);
- dynamicTable.appendChild(thead);
- // 计算每列的合并信息
- const getValue = (dc, col) => {{
- if (col === "match_score") return dc.match_score !== undefined ? dc.match_score.toFixed(4) : "";
- if (col === "relative_ratio") return dc.relative_ratio !== undefined ? dc.relative_ratio.toFixed(4) : "";
- return dc[col] || "";
- }};
- const getRowspan = (colIdx, rowIdx) => {{
- const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
- let span = 1;
- for (let i = rowIdx + 1; i < dynamicConstants.length; i++) {{
- const nextValue = getValue(dynamicConstants[i], columns[colIdx]);
- if (nextValue === currentValue) {{
- span++;
- }} else {{
- break;
- }}
- }}
- return span;
- }};
- const shouldSkipCell = (colIdx, rowIdx) => {{
- if (rowIdx === 0) return false;
- const currentValue = getValue(dynamicConstants[rowIdx], columns[colIdx]);
- const prevValue = getValue(dynamicConstants[rowIdx - 1], columns[colIdx]);
- return currentValue === prevValue;
- }};
- const tbody = document.createElement('tbody');
- dynamicConstants.forEach((dc, rowIdx) => {{
- const tr = document.createElement('tr');
- columns.forEach((col, colIdx) => {{
- if (shouldSkipCell(colIdx, rowIdx)) {{
- // 跳过,因为会被上一行的 rowspan 覆盖
- return;
- }}
- const cellValue = getValue(dc, col);
- const span = getRowspan(colIdx, rowIdx);
- const td = document.createElement('td');
- td.textContent = cellValue;
- td.style.border = '1px solid #ddd';
- td.style.padding = '6px 8px';
- if (span > 1) {{
- td.setAttribute('rowspan', span);
- }}
- tr.appendChild(td);
- }});
- tbody.appendChild(tr);
- }});
- dynamicTable.appendChild(tbody);
- dynamicValue.appendChild(dynamicTable);
- }}
- // 4.5. 推导成功的选题点
- const successfulPointsRaw = detail["推导成功的选题点"] || [];
- if (Array.isArray(successfulPointsRaw) && successfulPointsRaw.length > 0) {{
- // 处理字符串数组或对象数组(对象需有 name 字段)
- const successfulPoints = successfulPointsRaw.map(item => {{
- if (typeof item === "string") {{
- return item;
- }} else if (item && typeof item === "object" && item.name) {{
- return item.name;
- }}
- return "";
- }}).filter(Boolean);
-
- if (successfulPoints.length > 0) {{
- const successfulSection = document.createElement('div');
- successfulSection.className = 'detail-item';
- const successfulLabel = document.createElement('label');
- successfulLabel.textContent = '推导成功的选题点:';
- successfulSection.appendChild(successfulLabel);
- const successfulValue = document.createElement('div');
- successfulValue.className = 'detail-val';
- successfulValue.style.marginTop = '8px';
- successfulValue.textContent = successfulPoints.join('、');
- successfulSection.appendChild(successfulValue);
- container.appendChild(successfulSection);
- }}
- }}
- // 5. Query列表
- const querySection = document.createElement('div');
- querySection.className = 'detail-item';
- const queryLabelRow = document.createElement('div');
- queryLabelRow.style.display = 'flex';
- queryLabelRow.style.justifyContent = 'space-between';
- queryLabelRow.style.alignItems = 'center';
- queryLabelRow.style.marginBottom = '8px';
- const queryLabel = document.createElement('label');
- queryLabel.style.margin = '0';
- queryLabel.textContent = 'Query列表:';
- queryLabelRow.appendChild(queryLabel);
- querySection.appendChild(queryLabelRow);
- const queryList = detail.query_list || [];
- const queryStrs = queryList.map(q => q && q.query_str ? q.query_str : (typeof q === "string" ? q : "")).filter(Boolean);
- const queryValueDiv = document.createElement('div');
- queryValueDiv.className = 'detail-val';
- queryValueDiv.style.marginTop = '4px';
- queryValueDiv.style.whiteSpace = 'pre-wrap';
- if (queryStrs.length) {{
- queryValueDiv.textContent = queryStrs.join('\\n');
- }} else {{
- queryValueDiv.textContent = '暂无';
- }}
- querySection.appendChild(queryValueDiv);
- container.appendChild(querySection);
- // 6. 外部寻找结果 (match_result 为数组,按匹配率倒序显示)
- const matchSection = document.createElement('div');
- matchSection.className = 'detail-item';
- const matchLabelRow = document.createElement('div');
- matchLabelRow.style.display = 'flex';
- matchLabelRow.style.justifyContent = 'space-between';
- matchLabelRow.style.alignItems = 'center';
- matchLabelRow.style.marginBottom = '8px';
- const matchLabel = document.createElement('label');
- matchLabel.style.margin = '0';
- matchLabel.textContent = '外部寻找结果:';
- matchLabelRow.appendChild(matchLabel);
- matchSection.appendChild(matchLabelRow);
- const matchResultArr = detail.match_result || [];
- const matchWithStats = matchResultArr.map(mr => {{
- const nodeList = mr.node_list || [];
- const searchCount = nodeList.length;
- const matchCount = nodeList.filter(n => n.eval_result && n.eval_result.匹配类型 === "完全匹配").length;
- const matchRate = searchCount > 0 ? (matchCount / searchCount * 100) : 0;
- return {{ ...mr, searchCount, matchCount, matchRate }};
- }});
- const sortedMatchResult = [...matchWithStats].sort((a, b) => b.matchRate - a.matchRate);
- const matchValue = document.createElement('div');
- matchValue.className = 'detail-val';
- matchValue.style.marginTop = '8px';
- matchSection.appendChild(matchValue);
- container.appendChild(matchSection);
- sortedMatchResult.forEach((matchResult, mrIdx) => {{
- const nodeList = matchResult.node_list || [];
- const sortedNodes = [...nodeList].sort((a, b) => {{
- const scoreA = a.eval_result && a.eval_result.综合得分 !== undefined ? a.eval_result.综合得分 : -1;
- const scoreB = b.eval_result && b.eval_result.综合得分 !== undefined ? b.eval_result.综合得分 : -1;
- return scoreB - scoreA;
- }});
- const queryBlock = document.createElement('div');
- queryBlock.className = 'query-block';
- queryBlock.style.marginBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
- queryBlock.style.paddingBottom = mrIdx < sortedMatchResult.length - 1 ? '12px' : '0';
- queryBlock.style.borderBottom = mrIdx < sortedMatchResult.length - 1 ? '1px solid #eee' : 'none';
- const queryBody = document.createElement('div');
- queryBody.className = 'query-block-body';
- const headerDiv = document.createElement('div');
- headerDiv.className = 'query-block-header';
- headerDiv.style.fontWeight = 'bold';
- headerDiv.style.fontSize = '14px';
- headerDiv.style.cursor = 'pointer';
- const toggleSpan = document.createElement('span');
- toggleSpan.className = 'query-toggle';
- toggleSpan.style.marginRight = '6px';
- toggleSpan.style.display = 'inline-block';
- toggleSpan.style.width = '16px';
- toggleSpan.textContent = '▼';
- headerDiv.appendChild(toggleSpan);
- const querySpan = document.createElement('span');
- querySpan.textContent = 'Query: ' + (matchResult.query_str || "暂无");
- headerDiv.appendChild(querySpan);
- const statsDiv = document.createElement('div');
- statsDiv.style.fontSize = '13px';
- statsDiv.style.color = '#666';
- statsDiv.style.fontWeight = 'normal';
- statsDiv.style.marginTop = '4px';
- statsDiv.textContent = '搜索帖子数: ' + matchResult.searchCount + ',匹配帖子数: ' + matchResult.matchCount + ',匹配率: ' + matchResult.matchRate.toFixed(1) + '%';
- headerDiv.appendChild(statsDiv);
- let isExpanded = true;
- headerDiv.addEventListener('click', function() {{
- isExpanded = !isExpanded;
- queryBody.style.display = isExpanded ? 'block' : 'none';
- toggleSpan.textContent = isExpanded ? '▼' : '▶';
- }});
- queryBlock.insertBefore(headerDiv, queryBlock.firstChild);
- queryBlock.appendChild(queryBody);
- sortedNodes.forEach((node, i) => {{
- const postCard = document.createElement('div');
- postCard.className = 'external-post-card';
- postCard.style.border = '1px solid #eee';
- postCard.style.borderRadius = '8px';
- postCard.style.padding = '12px';
- postCard.style.marginTop = '12px';
- postCard.style.background = '#fafafa';
- const titleDiv = document.createElement('div');
- titleDiv.style.fontWeight = 'bold';
- titleDiv.style.marginBottom = '6px';
- titleDiv.style.fontSize = '14px';
- titleDiv.textContent = (i + 1) + '. ' + (node.title || "无标题");
- postCard.appendChild(titleDiv);
- const bodyText = node.body_text || "";
- const bodyDiv = document.createElement('div');
- bodyDiv.style.fontSize = '13px';
- bodyDiv.style.color = '#666';
- bodyDiv.style.marginBottom = '8px';
- bodyDiv.style.whiteSpace = 'pre-wrap';
- bodyDiv.style.maxHeight = '100px';
- bodyDiv.style.overflowY = 'auto';
- bodyDiv.textContent = bodyText.length > 200 ? bodyText.substring(0, 200) + "..." : bodyText;
- postCard.appendChild(bodyDiv);
- const imgList = node.image_url_list || [];
- const urlList = imgList.map(img => (img && img.image_url) ? img.image_url : (typeof img === "string" ? img : "")).filter(Boolean);
- if (urlList.length > 0) {{
- const gallery = document.createElement('div');
- gallery.style.display = 'flex';
- gallery.style.flexWrap = 'wrap';
- gallery.style.gap = '6px';
- gallery.style.marginBottom = '8px';
- urlList.forEach((url, idx) => {{
- const thumb = document.createElement('div');
- thumb.style.display = 'block';
- thumb.style.cursor = 'pointer';
- const img = document.createElement('img');
- img.src = url;
- img.alt = '图' + (idx + 1);
- img.setAttribute('data-url', url);
- img.style.width = '60px';
- img.style.height = '60px';
- img.style.objectFit = 'cover';
- img.style.borderRadius = '4px';
- img.style.pointerEvents = 'none';
- thumb.appendChild(img);
- gallery.appendChild(thumb);
- }});
- postCard.appendChild(gallery);
- }}
- const evalResult = node.eval_result || {{}};
- if (evalResult && Object.keys(evalResult).length > 0) {{
- const evalDiv = document.createElement('div');
- evalDiv.style.marginTop = '8px';
- evalDiv.style.padding = '8px';
- evalDiv.style.background = '#fff';
- evalDiv.style.borderRadius = '4px';
- evalDiv.style.borderLeft = '3px solid #2196F3';
- const matchType = evalResult.匹配类型 || "无";
- const matchTypeColor = matchType === "完全匹配" ? "#5ba85f" : "inherit";
- const matchTypeDiv = document.createElement('div');
- matchTypeDiv.style.fontSize = '13px';
- matchTypeDiv.style.marginBottom = '4px';
- matchTypeDiv.innerHTML = '<strong>匹配类型:</strong> <strong style="color:' + matchTypeColor + '">' + escapeHtml(matchType) + '</strong>';
- evalDiv.appendChild(matchTypeDiv);
- const reasonDiv = document.createElement('div');
- reasonDiv.style.fontSize = '13px';
- reasonDiv.style.marginBottom = '4px';
- reasonDiv.innerHTML = '<strong>评分说明:</strong> ' + (evalResult.评分说明 || "无");
- evalDiv.appendChild(reasonDiv);
- const keyPoints = evalResult.关键匹配点;
- if (keyPoints && Array.isArray(keyPoints) && keyPoints.length > 0) {{
- const keyPointsLabel = document.createElement('div');
- keyPointsLabel.style.fontSize = '13px';
- keyPointsLabel.style.marginBottom = '4px';
- keyPointsLabel.style.fontWeight = 'bold';
- keyPointsLabel.textContent = '关键匹配点:';
- evalDiv.appendChild(keyPointsLabel);
- keyPoints.forEach(kp => {{
- const kpDiv = document.createElement('div');
- kpDiv.style.fontSize = '14px';
- kpDiv.style.fontWeight = 'bold';
- kpDiv.style.color = '#555';
- kpDiv.style.marginLeft = '12px';
- kpDiv.textContent = '• ' + kp;
- evalDiv.appendChild(kpDiv);
- }});
- }}
- postCard.appendChild(evalDiv);
- }}
- queryBody.appendChild(postCard);
- }});
- matchValue.appendChild(queryBlock);
- }});
- }}
- function renderToolEdgeDetail(detail, container, targetNodeName) {{
- if (!detail) return;
- const toolDataList = detail.tool_data_list || [];
- if (toolDataList.length === 0) {{
- const emptyDiv = document.createElement('div');
- emptyDiv.className = 'detail-empty';
- emptyDiv.textContent = '暂无工具数据';
- container.appendChild(emptyDiv);
- return;
- }}
- // 添加列表标题
- const listHeader = document.createElement('div');
- listHeader.style.fontSize = '18px';
- listHeader.style.fontWeight = 'bold';
- listHeader.style.color = '#333';
- listHeader.style.marginBottom = '15px';
- listHeader.style.paddingBottom = '10px';
- listHeader.style.borderBottom = '2px solid #2196F3';
- listHeader.textContent = '工具数据列表 (共 ' + toolDataList.length + ' 项)';
- container.appendChild(listHeader);
- // 遍历每个工具数据
- toolDataList.forEach((toolData, idx) => {{
- const toolBlock = document.createElement('div');
- toolBlock.className = 'tool-block';
- toolBlock.style.marginBottom = idx < toolDataList.length - 1 ? '12px' : '0';
- toolBlock.style.paddingBottom = idx < toolDataList.length - 1 ? '12px' : '0';
- toolBlock.style.borderBottom = idx < toolDataList.length - 1 ? '1px solid #eee' : 'none';
- const toolBody = document.createElement('div');
- toolBody.className = 'tool-block-body';
- toolBody.style.display = 'block'; // 默认展开
- // 获取关键信息用于标题显示
- const toolInfo = toolData.tool_info || {{}};
- const toolName = toolInfo.name || "未知工具";
- const eval = toolData.evaluation || {{}};
- const matchLevel = eval.match_level || "未评估";
- const matchLevelColor = matchLevel === "完全匹配" ? "#5ba85f" :
- matchLevel === "部分匹配" ? "#ff9800" : "#999";
- // 添加可点击的标题栏(显示关键信息)
- const headerDiv = document.createElement('div');
- headerDiv.className = 'tool-block-header';
- headerDiv.style.cursor = 'pointer';
- headerDiv.style.userSelect = 'none';
- headerDiv.style.fontWeight = 'bold';
- headerDiv.style.fontSize = '14px';
- headerDiv.style.padding = '10px 12px';
- headerDiv.style.background = '#f5f5f5';
- headerDiv.style.borderRadius = '6px';
- headerDiv.style.borderLeft = '4px solid #2196F3';
- headerDiv.style.marginBottom = '8px';
- headerDiv.style.transition = 'background 0.2s';
- headerDiv.addEventListener('mouseenter', function() {{
- this.style.background = '#e8f4f8';
- }});
- headerDiv.addEventListener('mouseleave', function() {{
- this.style.background = '#f5f5f5';
- }});
- const toggleSpan = document.createElement('span');
- toggleSpan.className = 'tool-toggle';
- toggleSpan.style.marginRight = '8px';
- toggleSpan.style.display = 'inline-block';
- toggleSpan.style.width = '16px';
- toggleSpan.style.color = '#2196F3';
- toggleSpan.textContent = '▼';
- headerDiv.appendChild(toggleSpan);
- const headerContent = document.createElement('span');
- const toolNumSpan = document.createElement('span');
- toolNumSpan.style.color = '#333';
- toolNumSpan.textContent = '工具 ' + (idx + 1) + ' / ' + toolDataList.length + ': ';
- headerContent.appendChild(toolNumSpan);
- const toolNameSpan = document.createElement('span');
- toolNameSpan.style.color = '#2196F3';
- toolNameSpan.style.fontWeight = 'bold';
- toolNameSpan.textContent = toolName;
- headerContent.appendChild(toolNameSpan);
- headerDiv.appendChild(headerContent);
- // 添加关键信息摘要
- const summaryDiv = document.createElement('div');
- summaryDiv.style.fontSize = '13px';
- summaryDiv.style.color = '#666';
- summaryDiv.style.fontWeight = 'normal';
- summaryDiv.style.marginTop = '6px';
- summaryDiv.style.paddingLeft = '24px';
- if (matchLevel && matchLevel !== "未评估") {{
- const matchLevelSpan = document.createElement('span');
- matchLevelSpan.style.marginRight = '15px';
- matchLevelSpan.innerHTML = '匹配级别: <strong style="color:' + matchLevelColor + '">' + escapeHtml(matchLevel) + '</strong>';
- summaryDiv.appendChild(matchLevelSpan);
- }}
- headerDiv.appendChild(summaryDiv);
- headerDiv.addEventListener('click', function() {{
- const isExpanded = toolBody.style.display !== 'none';
- toolBody.style.display = isExpanded ? 'none' : 'block';
- toggleSpan.textContent = isExpanded ? '▶' : '▼';
- }});
- toolBlock.insertBefore(headerDiv, toolBlock.firstChild);
- toolBlock.appendChild(toolBody);
- const toolSection = document.createElement('div');
- toolSection.className = 'detail-item';
- toolSection.style.paddingTop = '10px';
- toolBody.appendChild(toolSection);
- // 1. 工具信息
- if (Object.keys(toolInfo).length > 0) {{
- const toolInfoSection = document.createElement('div');
- toolInfoSection.style.marginBottom = '15px';
- const toolInfoTitle = document.createElement('div');
- toolInfoTitle.style.fontSize = '15px';
- toolInfoTitle.style.fontWeight = 'bold';
- toolInfoTitle.style.color = '#333';
- toolInfoTitle.style.marginBottom = '12px';
- toolInfoTitle.textContent = '工具信息';
- toolInfoSection.appendChild(toolInfoTitle);
- const toolInfoTable = document.createElement('table');
- toolInfoTable.style.width = '100%';
- toolInfoTable.style.borderCollapse = 'collapse';
- toolInfoTable.style.fontSize = '13px';
- toolInfoTable.style.background = '#fafafa';
- toolInfoTable.style.borderRadius = '6px';
- toolInfoTable.style.overflow = 'hidden';
- const toolInfoFields = [
- {{ key: "name", label: "工具名称" }},
- {{ key: "tool_description", label: "工具描述" }}
- ];
- toolInfoFields.forEach(field => {{
- const value = toolInfo[field.key];
- if (value !== undefined && value !== null && value !== "") {{
- const row = document.createElement('tr');
- const labelTd = document.createElement('td');
- labelTd.textContent = field.label + ':';
- labelTd.style.padding = '8px 12px';
- labelTd.style.fontWeight = 'bold';
- labelTd.style.color = '#555';
- labelTd.style.width = '120px';
- labelTd.style.verticalAlign = 'top';
- labelTd.style.background = '#f0f0f0';
- row.appendChild(labelTd);
- const valueTd = document.createElement('td');
- valueTd.textContent = String(value);
- valueTd.style.padding = '8px 12px';
- valueTd.style.color = '#666';
- valueTd.style.whiteSpace = 'pre-wrap';
- valueTd.style.wordBreak = 'break-word';
- row.appendChild(valueTd);
- toolInfoTable.appendChild(row);
- }}
- }});
- toolInfoSection.appendChild(toolInfoTable);
- toolSection.appendChild(toolInfoSection);
- }}
- // 2. 工具参数
- if (toolData.params && toolData.params.prompt) {{
- const paramsSection = document.createElement('div');
- paramsSection.style.marginBottom = '15px';
- const paramsTitle = document.createElement('div');
- paramsTitle.style.fontSize = '15px';
- paramsTitle.style.fontWeight = 'bold';
- paramsTitle.style.color = '#333';
- paramsTitle.style.marginBottom = '8px';
- paramsTitle.textContent = '工具参数';
- paramsSection.appendChild(paramsTitle);
- const paramsDiv = document.createElement('div');
- paramsDiv.className = 'detail-val';
- paramsDiv.style.background = '#f0f7ff';
- paramsDiv.style.padding = '12px';
- paramsDiv.style.borderRadius = '6px';
- paramsDiv.style.borderLeft = '3px solid #2196F3';
- paramsDiv.style.whiteSpace = 'pre-wrap';
- paramsDiv.style.wordBreak = 'break-word';
- paramsDiv.style.fontSize = '14px';
- paramsDiv.style.lineHeight = '1.6';
- paramsDiv.style.color = '#333';
- paramsDiv.textContent = toolData.params.prompt;
- paramsSection.appendChild(paramsDiv);
- toolSection.appendChild(paramsSection);
- }}
- // 3. 工具内容
- if (toolData.content) {{
- const contentSection = document.createElement('div');
- contentSection.style.marginBottom = '15px';
- const contentTitle = document.createElement('div');
- contentTitle.style.fontSize = '16px';
- contentTitle.style.fontWeight = 'bold';
- contentTitle.style.color = '#333';
- contentTitle.style.marginBottom = '8px';
- contentTitle.textContent = '工具返回内容';
- contentSection.appendChild(contentTitle);
- const contentDiv = document.createElement('div');
- contentDiv.className = 'detail-val';
- contentDiv.style.background = '#f9f9f9';
- contentDiv.style.padding = '12px';
- contentDiv.style.borderRadius = '6px';
- contentDiv.style.borderLeft = '3px solid #2196F3';
- contentDiv.style.whiteSpace = 'pre-wrap';
- contentDiv.style.wordBreak = 'break-word';
- contentDiv.style.fontSize = '14px';
- contentDiv.style.lineHeight = '1.6';
- contentDiv.style.color = '#333';
- contentDiv.style.maxHeight = '400px';
- contentDiv.style.overflowY = 'auto';
- contentDiv.textContent = toolData.content;
- contentSection.appendChild(contentDiv);
- toolSection.appendChild(contentSection);
- }}
- // 4. 评估结果
- if (toolData.evaluation) {{
- const evalSection = document.createElement('div');
- const evalTitle = document.createElement('div');
- evalTitle.style.fontSize = '16px';
- evalTitle.style.fontWeight = 'bold';
- evalTitle.style.color = '#333';
- evalTitle.style.marginBottom = '8px';
- evalTitle.textContent = '评估结果';
- evalSection.appendChild(evalTitle);
- const evalDiv = document.createElement('div');
- evalDiv.style.background = '#fff';
- evalDiv.style.padding = '12px';
- evalDiv.style.borderRadius = '6px';
- evalDiv.style.borderLeft = '3px solid #2196F3';
- evalDiv.style.marginTop = '8px';
- const eval = toolData.evaluation;
-
- // 匹配级别
- if (eval.match_level) {{
- const matchLevelColor = eval.match_level === "完全匹配" ? "#5ba85f" :
- eval.match_level === "部分匹配" ? "#ff9800" : "#999";
- const matchLevelDiv = document.createElement('div');
- matchLevelDiv.style.fontSize = '14px';
- matchLevelDiv.style.marginBottom = '8px';
- matchLevelDiv.innerHTML = '<strong>匹配级别:</strong> <strong style="color:' + matchLevelColor + '">' + escapeHtml(eval.match_level) + '</strong>';
- evalDiv.appendChild(matchLevelDiv);
- }}
- // 核心主体
- if (eval.core_subject) {{
- const coreSubjectDiv = document.createElement('div');
- coreSubjectDiv.style.fontSize = '14px';
- coreSubjectDiv.style.marginBottom = '8px';
- coreSubjectDiv.innerHTML = '<strong>核心主体:</strong> ' + escapeHtml(eval.core_subject);
- evalDiv.appendChild(coreSubjectDiv);
- }}
- // 核心事件
- if (eval.core_event) {{
- const coreEventDiv = document.createElement('div');
- coreEventDiv.style.fontSize = '14px';
- coreEventDiv.style.marginBottom = '8px';
- coreEventDiv.innerHTML = '<strong>核心事件:</strong> ' + escapeHtml(eval.core_event);
- evalDiv.appendChild(coreEventDiv);
- }}
- // 原因说明
- if (eval.reason) {{
- const reasonDiv = document.createElement('div');
- reasonDiv.style.fontSize = '14px';
- reasonDiv.style.marginTop = '8px';
- reasonDiv.style.paddingTop = '8px';
- reasonDiv.style.borderTop = '1px solid #eee';
- reasonDiv.innerHTML = '<strong>原因说明:</strong> ' + escapeHtml(eval.reason);
- evalDiv.appendChild(reasonDiv);
- }}
- evalSection.appendChild(evalDiv);
- toolSection.appendChild(evalSection);
- }}
- // 工具统计信息
- if (detail.tools_count !== undefined || detail.successful_tools_count !== undefined) {{
- const statsSection = document.createElement('div');
- statsSection.style.marginTop = '12px';
- statsSection.style.paddingTop = '12px';
- statsSection.style.borderTop = '1px solid #eee';
- statsSection.style.fontSize = '13px';
- statsSection.style.color = '#666';
-
- if (detail.tools_count !== undefined) {{
- const toolsCountSpan = document.createElement('span');
- toolsCountSpan.textContent = '工具总数: ' + detail.tools_count;
- toolsCountSpan.style.marginRight = '15px';
- statsSection.appendChild(toolsCountSpan);
- }}
- if (detail.successful_tools_count !== undefined) {{
- const successfulToolsCountSpan = document.createElement('span');
- successfulToolsCountSpan.textContent = '成功工具数: ' + detail.successful_tools_count;
- statsSection.appendChild(successfulToolsCountSpan);
- }}
- toolSection.appendChild(statsSection);
- }}
- container.appendChild(toolBlock);
- }});
- }}
- function renderRootDetail(detail, container) {{
- if (!detail) return;
- // 1. 帖子详情
- const postSection = document.createElement('div');
- postSection.className = 'root-detail-section';
- const postTitle = document.createElement('div');
- postTitle.className = 'root-detail-title';
- postTitle.textContent = '1. 帖子详情';
- postSection.appendChild(postTitle);
-
- // 显示帖子 ID(尝试多个可能的字段名)
- const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
- if (postId) {{
- const postIdDiv = document.createElement('div');
- postIdDiv.style.marginBottom = '10px';
- postIdDiv.style.fontSize = '14px';
- postIdDiv.style.color = '#666';
- const postIdLabel = document.createElement('span');
- postIdLabel.style.fontWeight = 'bold';
- postIdLabel.textContent = '帖子 ID: ';
- postIdDiv.appendChild(postIdLabel);
- const postIdValue = document.createElement('span');
- postIdValue.textContent = postId;
- postIdDiv.appendChild(postIdValue);
- postSection.appendChild(postIdDiv);
- }}
-
- if (detail.title) {{
- const titleDiv = document.createElement('div');
- titleDiv.className = 'post-title';
- titleDiv.textContent = detail.title;
- postSection.appendChild(titleDiv);
- }}
-
- if (detail.body_text) {{
- const bodyDiv = document.createElement('div');
- bodyDiv.className = 'post-body';
- bodyDiv.textContent = detail.body_text;
- postSection.appendChild(bodyDiv);
- }}
- const stats = document.createElement('div');
- stats.className = 'post-stats';
- if (detail.like_count !== null && detail.like_count !== undefined) {{
- const likeSpan = document.createElement('span');
- likeSpan.textContent = `❤️ ${{detail.like_count}}`;
- stats.appendChild(likeSpan);
- }}
- if (detail.collect_count !== null && detail.collect_count !== undefined) {{
- const collectSpan = document.createElement('span');
- collectSpan.textContent = `⭐ ${{detail.collect_count}}`;
- stats.appendChild(collectSpan);
- }}
- postSection.appendChild(stats);
- if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
- const gallery = document.createElement('div');
- gallery.className = 'image-gallery';
- detail.images.forEach(imgUrl => {{
- const img = document.createElement('img');
- img.className = 'image-item';
- img.src = imgUrl;
- img.addEventListener('click', function() {{
- // 可以在这里添加图片查看功能
- }});
- gallery.appendChild(img);
- }});
- postSection.appendChild(gallery);
- }}
- container.appendChild(postSection);
- // 2. 选题结果
- const topicSection = document.createElement('div');
- topicSection.className = 'root-detail-section';
- const topicTitle = document.createElement('div');
- topicTitle.className = 'root-detail-title';
- topicTitle.textContent = '2. 选题结果';
- topicSection.appendChild(topicTitle);
- const topicLink = document.createElement('a');
- topicLink.className = 'jump-link';
- topicLink.href = `${{accountName}}_标签匹配可视化.html`;
- topicLink.target = '_blank';
- topicLink.textContent = '选题匹配结果';
- topicSection.appendChild(topicLink);
- container.appendChild(topicSection);
-
- // 3. 选题点拆解(选题点: list[dict])
- const selectionPoints = detail["选题点"];
- if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
- const selectionSection = document.createElement('div');
- selectionSection.className = 'root-detail-section';
- const selectionTitle = document.createElement('div');
- selectionTitle.className = 'root-detail-title';
- selectionTitle.textContent = '3. 选题点拆解';
- selectionSection.appendChild(selectionTitle);
- const table = document.createElement('table');
- table.style.width = '100%';
- table.style.borderCollapse = 'collapse';
- table.style.fontSize = '13px';
- table.style.marginTop = '8px';
- // 表头
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- const headerStyle = (th) => {{
- th.style.borderBottom = '1px solid #eee';
- th.style.padding = '6px 8px';
- th.style.textAlign = 'left';
- th.style.color = '#555';
- th.style.background = '#fafafa';
- }};
- const thType = document.createElement('th');
- thType.textContent = '类型';
- headerStyle(thType);
- headerRow.appendChild(thType);
- const thTopic = document.createElement('th');
- thTopic.textContent = '选题点';
- headerStyle(thTopic);
- headerRow.appendChild(thTopic);
- const thSubstantial = document.createElement('th');
- thSubstantial.textContent = '实质';
- headerStyle(thSubstantial);
- headerRow.appendChild(thSubstantial);
- const thForm = document.createElement('th');
- thForm.textContent = '形式';
- headerStyle(thForm);
- headerRow.appendChild(thForm);
- const thIntent = document.createElement('th');
- thIntent.textContent = '意图';
- headerStyle(thIntent);
- headerRow.appendChild(thIntent);
- thead.appendChild(headerRow);
- table.appendChild(thead);
- // 表体
- const tbody = document.createElement('tbody');
- selectionPoints.forEach((item, idx) => {{
- if (!item || typeof item !== "object") return;
- const row = document.createElement('tr');
- row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
- const rowCellStyle = (td) => {{
- td.style.padding = '6px 8px';
- td.style.verticalAlign = 'top';
- td.style.color = '#666';
- }};
- const toJoined = (v) => {{
- if (Array.isArray(v)) return v.join('、');
- if (v === null || v === undefined) return "";
- return String(v);
- }};
- const tdType = document.createElement('td');
- tdType.textContent = item["类型"] || "";
- rowCellStyle(tdType);
- row.appendChild(tdType);
- const tdTopic = document.createElement('td');
- tdTopic.textContent = item["选题点"] || "";
- rowCellStyle(tdTopic);
- row.appendChild(tdTopic);
- const tdSubstantial = document.createElement('td');
- tdSubstantial.textContent = toJoined(item["实质"]);
- rowCellStyle(tdSubstantial);
- row.appendChild(tdSubstantial);
- const tdForm = document.createElement('td');
- tdForm.textContent = toJoined(item["形式"]);
- rowCellStyle(tdForm);
- row.appendChild(tdForm);
- const tdIntent = document.createElement('td');
- tdIntent.textContent = toJoined(item["意图"]);
- rowCellStyle(tdIntent);
- row.appendChild(tdIntent);
- tbody.appendChild(row);
- }});
- table.appendChild(tbody);
- selectionSection.appendChild(table);
- container.appendChild(selectionSection);
- }}
- }}
- // 更新画布宽度以适应侧边栏
- function updateCanvasWidth() {{
- const sidebar = document.getElementById('sidebar');
- const appContainer = document.getElementById('app-container');
- const sidebarResizer = document.getElementById('sidebar-resizer');
-
- if (sidebar.classList.contains('active')) {{
- const sidebarWidth = sidebar.offsetWidth;
- appContainer.style.right = sidebarWidth + 'px';
- appContainer.style.width = `calc(100% - ${{sidebarWidth}}px)`;
- sidebarResizer.style.right = sidebarWidth + 'px';
- sidebarResizer.classList.add('active');
- }} else {{
- appContainer.style.right = '';
- appContainer.style.width = '';
- sidebarResizer.style.right = '';
- sidebarResizer.classList.remove('active');
- }}
- }}
- function closeSidebar() {{
- const sidebar = document.getElementById('sidebar');
- const appContainer = document.getElementById('app-container');
- sidebar.classList.remove('active');
- appContainer.classList.remove('sidebar-open');
- updateCanvasWidth();
- }}
- // 侧边栏拖拽调整宽度
- const sidebarResizer = document.getElementById('sidebar-resizer');
- const sidebar = document.getElementById('sidebar');
- let isSidebarResizing = false;
- let sidebarStartX = 0;
- let sidebarStartWidth = 0;
- sidebarResizer.addEventListener('mousedown', function(e) {{
- if (!sidebar.classList.contains('active')) return;
- isSidebarResizing = true;
- sidebarStartX = e.clientX;
- sidebarStartWidth = sidebar.offsetWidth;
- document.body.classList.add('resizing');
- e.preventDefault();
- }});
- document.addEventListener('mousemove', function(e) {{
- if (!isSidebarResizing) return;
-
- const deltaX = sidebarStartX - e.clientX; // 向左拖拽增加宽度
- const newWidth = sidebarStartWidth + deltaX;
- const minWidth = 250;
- const maxWidth = window.innerWidth * 0.6;
-
- if (newWidth >= minWidth && newWidth <= maxWidth) {{
- sidebar.style.width = newWidth + 'px';
- updateCanvasWidth();
- }}
- }});
- document.addEventListener('mouseup', function() {{
- if (isSidebarResizing) {{
- isSidebarResizing = false;
- document.body.classList.remove('resizing');
- }}
- }});
- function openPostDetailModal() {{
- const detail = postDetailMap[currentPostKey] || null;
- const container = document.getElementById('post-detail-modal-content');
- container.innerHTML = '';
- if (!detail) {{
- const empty = document.createElement('div');
- empty.className = 'derivation-empty';
- empty.textContent = '暂无当前帖子的详情数据';
- container.appendChild(empty);
- }} else {{
- renderPostDetailForModal(detail, container);
- }}
- document.getElementById('post-detail-modal').classList.add('active');
- }}
- function closePostDetailModal() {{
- document.getElementById('post-detail-modal').classList.remove('active');
- }}
- function renderPostDetailForModal(detail, container) {{
- if (!detail) return;
- const postId = detail.id || detail.post_id || detail.帖子id || detail.channel_content_id || "";
- if (postId) {{
- const div = document.createElement('div');
- div.style.marginBottom = '10px'; div.style.fontSize = '14px'; div.style.color = '#666';
- div.innerHTML = '<span style="font-weight:bold;">帖子 ID: </span>' + escapeHtml(postId);
- container.appendChild(div);
- }}
- if (detail.title) {{
- const titleDiv = document.createElement('div');
- titleDiv.className = 'post-title';
- titleDiv.textContent = detail.title;
- container.appendChild(titleDiv);
- }}
- if (detail.body_text) {{
- const bodyDiv = document.createElement('div');
- bodyDiv.className = 'post-body';
- bodyDiv.textContent = detail.body_text;
- container.appendChild(bodyDiv);
- }}
- const stats = document.createElement('div');
- stats.className = 'post-stats';
- if (detail.like_count != null) {{ const s = document.createElement('span'); s.textContent = '❤️ ' + detail.like_count; stats.appendChild(s); }}
- if (detail.collect_count != null) {{ const s = document.createElement('span'); s.textContent = '⭐ ' + detail.collect_count; stats.appendChild(s); }}
- if (stats.childNodes.length) container.appendChild(stats);
- if (detail.images && Array.isArray(detail.images) && detail.images.length > 0) {{
- const gallery = document.createElement('div');
- gallery.className = 'image-gallery';
- detail.images.forEach((imgUrl, idx) => {{
- const img = document.createElement('img');
- img.className = 'image-item';
- img.src = imgUrl;
- img.addEventListener('click', function() {{ openImageLightbox(detail.images, idx); }});
- gallery.appendChild(img);
- }});
- container.appendChild(gallery);
- }}
- const selectionPoints = detail["选题点"];
- if (Array.isArray(selectionPoints) && selectionPoints.length > 0) {{
- const sectionTitle = document.createElement('div');
- sectionTitle.className = 'root-detail-title';
- sectionTitle.textContent = '帖子选题表';
- sectionTitle.style.marginTop = '16px';
- container.appendChild(sectionTitle);
- const table = document.createElement('table');
- table.style.width = '100%'; table.style.borderCollapse = 'collapse'; table.style.fontSize = '13px'; table.style.marginTop = '8px';
- const thead = document.createElement('thead');
- const headerRow = document.createElement('tr');
- ['类型','选题点','实质','形式','意图'].forEach(txt => {{
- const th = document.createElement('th');
- th.textContent = txt;
- th.style.borderBottom = '1px solid #eee'; th.style.padding = '6px 8px'; th.style.textAlign = 'left'; th.style.background = '#fafafa';
- headerRow.appendChild(th);
- }});
- thead.appendChild(headerRow); table.appendChild(thead);
- const tbody = document.createElement('tbody');
- const toJoined = (v) => Array.isArray(v) ? v.join('、') : (v == null || v === undefined ? '' : String(v));
- selectionPoints.forEach((item, idx) => {{
- if (!item || typeof item !== 'object') return;
- const row = document.createElement('tr');
- row.style.borderBottom = idx === selectionPoints.length - 1 ? 'none' : '1px dashed #f0f0f0';
- const cellStyle = td => {{ td.style.padding = '6px 8px'; td.style.verticalAlign = 'top'; td.style.color = '#666'; }};
- [item["类型"]||"", item["选题点"]||"", toJoined(item["实质"]), toJoined(item["形式"]), toJoined(item["意图"])].forEach(text => {{
- const td = document.createElement('td');
- td.textContent = text;
- cellStyle(td);
- row.appendChild(td);
- }});
- tbody.appendChild(row);
- }});
- table.appendChild(tbody);
- container.appendChild(table);
- }}
- }}
- document.getElementById('btn-pending-decode-post').addEventListener('click', openPostDetailModal);
- document.getElementById('post-detail-modal').addEventListener('click', function(e) {{
- if (e.target === this) closePostDetailModal();
- }});
- // --- 图集大图灯箱(左右切换)---
- let currentLightboxImages = [];
- let currentLightboxIndex = 0;
- function openImageLightbox(images, index) {{
- if (!images || !images.length) return;
- currentLightboxImages = images;
- currentLightboxIndex = (index >= 0 && index < images.length) ? index : 0;
- updateLightboxImage();
- document.getElementById('image-lightbox').classList.add('active');
- document.addEventListener('keydown', lightboxKeydown);
- }}
- function closeImageLightbox() {{
- document.getElementById('image-lightbox').classList.remove('active');
- document.removeEventListener('keydown', lightboxKeydown);
- }}
- function updateLightboxImage() {{
- const img = document.getElementById('lightbox-img');
- const counter = document.getElementById('lightbox-counter');
- if (!currentLightboxImages.length) return;
- const idx = ((currentLightboxIndex % currentLightboxImages.length) + currentLightboxImages.length) % currentLightboxImages.length;
- currentLightboxIndex = idx;
- img.src = currentLightboxImages[idx];
- counter.textContent = (idx + 1) + ' / ' + currentLightboxImages.length;
- }}
- function lightboxPrev() {{
- if (!currentLightboxImages.length) return;
- currentLightboxIndex = (currentLightboxIndex - 1 + currentLightboxImages.length) % currentLightboxImages.length;
- updateLightboxImage();
- }}
- function lightboxNext() {{
- if (!currentLightboxImages.length) return;
- currentLightboxIndex = (currentLightboxIndex + 1) % currentLightboxImages.length;
- updateLightboxImage();
- }}
- function lightboxKeydown(e) {{
- if (e.key === 'Escape') {{ closeImageLightbox(); return; }}
- if (e.key === 'ArrowLeft') {{ lightboxPrev(); e.preventDefault(); return; }}
- if (e.key === 'ArrowRight') {{ lightboxNext(); e.preventDefault(); return; }}
- }}
- document.getElementById('image-lightbox').addEventListener('click', function(e) {{
- if (e.target === this) closeImageLightbox();
- }});
- document.querySelector('#image-lightbox .lightbox-img-wrap').addEventListener('click', function(e) {{ e.stopPropagation(); }});
- function switchPost(val) {{
- currentPostKey = val;
- parseData(val);
- calculateLayout();
- renderNodes();
- renderEdges();
- updateTransform();
- resetView();
- renderDerivationProgress(val);
- }}
- function closeDimensionPatternsModal() {{
- const modal = document.getElementById('dimension-patterns-modal');
- if (modal) modal.classList.remove('active');
- }}
- function showDimensionPatternsModal(postId, roundNum) {{
- const modal = document.getElementById('dimension-patterns-modal');
- const body = document.getElementById('dimension-patterns-modal-body');
- const titleEl = document.getElementById('dimension-patterns-modal-title');
- if (!modal || !body) return;
- const doc = dimensionAnalyzeData[postId];
- if (!doc || !doc.rounds) {{
- if (titleEl) titleEl.textContent = '维度 patterns';
- body.innerHTML = '<p style="color:#64748b;">暂无整体推导维度分析数据</p>';
- modal.classList.add('active');
- return;
- }}
- const r = doc.rounds.find(function(x) {{ return x.round === roundNum; }});
- const label = (roundNum === 0) ? '选起点' : ('第' + roundNum + '轮');
- if (titleEl) titleEl.textContent = '维度patterns · ' + label;
- if (!r || !r.patterns || !r.patterns.length) {{
- body.innerHTML = '<p style="color:#64748b;">该轮暂无 patterns 数据</p>';
- modal.classList.add('active');
- return;
- }}
- let parts = [];
- parts.push('<div class="dimension-patterns-title">共 ' + r.patterns.length + ' 条 pattern(is_derived 已高亮)</div>');
- r.patterns.forEach(function(pat) {{
- const items = pat.items || [];
- const segs = items.map(function(it) {{
- const nm = escapeHtml(it.name || '');
- return it.is_derived ? '<span class="pattern-item-derived">' + nm + '</span>' : nm;
- }});
- parts.push('<div class="pattern-line">' + segs.join('<span class="pattern-plus"> + </span>') + '</div>');
- }});
- body.innerHTML = parts.join('');
- modal.classList.add('active');
- }}
-
- // 渲染推导进度
- function renderDerivationProgress(fileKey) {{
- const container = document.getElementById('derivation-progress-content');
- // 从文件名中提取文件ID(去掉.json扩展名)
- const fileId = fileKey.replace(/\.json$/, '');
- const rounds = derivationData[fileId] || derivationData[fileKey];
-
- if (!rounds || !Array.isArray(rounds) || rounds.length === 0) {{
- container.innerHTML = '<div class="derivation-empty">暂无推导进度数据</div>';
- return;
- }}
-
- // 收集所有已推导成功的选题点名称(用于判断是否为之前已点亮)
- const allDerivedNames = new Set();
- rounds.forEach(round => {{
- const derived = round.推导成功的选题点 || [];
- derived.forEach(p => {{
- if (p.name) allDerivedNames.add(p.name);
- }});
- }});
-
- let html = '<div class="derivation-timeline">';
-
- for (let ri = 0; ri < rounds.length; ri++) {{
- const round = rounds[ri];
- // 推导结果数据中轮次从 1 开始(第一轮=1);轮次 0 表示选起点
- const roundLabel = (round.轮次 === 0) ? "选起点" : ("第" + round.轮次 + "轮");
- const derived = round.推导成功的选题点 || [];
- const underived = round.未推导成功的选题点 || [];
-
- // 获取当前轮次新推导成功的选题点名称
- const newInRoundRaw = round.本次新推导成功的选题点 || [];
- const newInRoundNames = new Set(newInRoundRaw.map(p => p.name));
-
- // 如果是第一轮(轮次0),所有推导成功的都是新点亮的
- if (ri === 0) {{
- derived.forEach(p => {{ if (p.name) newInRoundNames.add(p.name); }});
- }} else if (newInRoundNames.size === 0) {{
- // 如果没有本次新推导成功的,则找出在当前轮次首次出现的
- const prevDerivedNames = new Set();
- for (let i = 0; i < ri; i++) {{
- const prevDerived = rounds[i].推导成功的选题点 || [];
- prevDerived.forEach(p => {{ if (p.name) prevDerivedNames.add(p.name); }});
- }}
- derived.forEach(p => {{
- if (p.name && !prevDerivedNames.has(p.name)) {{
- newInRoundNames.add(p.name);
- }}
- }});
- }}
-
- // 收集所有root_source
- const allRootSources = new Set();
- derived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
- underived.forEach(p => {{ if (p.root_source) allRootSources.add(p.root_source); }});
-
- const pointsByRoot = {{}};
- const dimDataByRoot = {{}};
-
- // 处理推导成功的选题点
- derived.forEach(p => {{
- if (!p.root_source) return;
- if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
- if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
- const dim = p.dimension || "实质";
- // 判断颜色:当前轮次新点亮的为黄色,之前已点亮的为绿色
- const cls = newInRoundNames.has(p.name) ? "derivation-topic-new" : "derivation-topic-derived";
- dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: cls, derivation_type: p.derivation_type || "", is_fully_derived: p.is_fully_derived }});
- }});
-
- // 处理未推导成功的选题点(黑色)
- underived.forEach(p => {{
- if (!p.root_source) return;
- if (!pointsByRoot[p.root_source]) pointsByRoot[p.root_source] = p.point || "";
- if (!dimDataByRoot[p.root_source]) dimDataByRoot[p.root_source] = {{ 实质: [], 形式: [], 意图: [] }};
- const dim = p.dimension || "实质";
- dimDataByRoot[p.root_source][dim].push({{ name: p.name, cls: "derivation-topic-underedived", derivation_type: p.derivation_type || "" }});
- }});
-
- // 按point类型排序
- const pointOrder = {{ "灵感点": 0, "目的点": 1, "关键点": 2 }};
- let rootSourceList = Array.from(allRootSources).sort((a, b) => {{
- const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
- const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
- if (pa !== pb) return pa - pb;
- return (a || "").localeCompare(b || "");
- }});
- // 整体推导结果里若未写入选题点(或解析不到分词),用待解构帖子详情中的选题表回填,避免表格无行
- if (rootSourceList.length === 0) {{
- const pd = postDetailMap[fileKey];
- const rows = (pd && Array.isArray(pd["选题点"])) ? pd["选题点"] : [];
- for (let ri = 0; ri < rows.length; ri++) {{
- const row = rows[ri];
- if (!row || typeof row !== "object") continue;
- const rs = String(row["选题点"] || "").trim();
- if (!rs) continue;
- const pt = row["类型"] || "";
- pointsByRoot[rs] = pt;
- if (!dimDataByRoot[rs]) dimDataByRoot[rs] = {{ 实质: [], 形式: [], 意图: [] }};
- ["实质", "形式", "意图"].forEach(function(dim) {{
- const arr = row[dim];
- const list = Array.isArray(arr) ? arr : [];
- list.forEach(function(nm) {{
- const s = (typeof nm === "string") ? nm.trim() : String(nm || "").trim();
- if (!s) return;
- dimDataByRoot[rs][dim].push({{
- name: s,
- cls: "derivation-topic-baseline",
- derivation_type: "",
- is_fully_derived: undefined
- }});
- }});
- }});
- }}
- rootSourceList = Object.keys(pointsByRoot).sort((a, b) => {{
- const pa = pointOrder[pointsByRoot[a] || ""] ?? 99;
- const pb = pointOrder[pointsByRoot[b] || ""] ?? 99;
- if (pa !== pb) return pa - pb;
- return (a || "").localeCompare(b || "");
- }});
- }}
-
- html += '<div class="derivation-round-block">';
- html += '<div class="derivation-round-title">' + escapeHtml(roundLabel) + '</div>';
- html += '<table class="derivation-table"><thead><tr>';
- html += '<th class="col-type">类型</th><th class="col-source">选题点</th>';
- html += '<th class="col-dim">实质</th><th class="col-dim">形式</th><th class="col-dim">意图</th>';
- html += '</tr></thead><tbody>';
-
- rootSourceList.forEach(rs => {{
- const point = pointsByRoot[rs] || "";
- const dimData = dimDataByRoot[rs] || {{ 实质: [], 形式: [], 意图: [] }};
- html += '<tr>';
- html += '<td class="col-type">' + escapeHtml(point) + '</td>';
- html += '<td class="col-source">' + escapeHtml(rs) + '</td>';
- for (const dim of ["实质", "形式", "意图"]) {{
- const items = (dimData[dim] || []).sort((a, b) => (a.name || "").localeCompare(b.name || ""));
- html += '<td class="col-dim">';
- items.forEach(it => {{
- const searchIcon = (it.derivation_type === "search") ? ' <span class="derivation-topic-search-icon" title="外部寻找">🔍</span>' : '';
- const toolIcon = (it.derivation_type === "tool") ? ' <span class="derivation-topic-tool-icon" title="工具调用">🔧</span>' : '';
- // 只有推导成功的选题点可以点击(黄色和绿色),未推导成功的(黑色)不可点击
- const isClickable = it.cls === "derivation-topic-derived" || it.cls === "derivation-topic-new";
- const dataAttr = isClickable ? ' data-topic-name="' + (it.name || "").replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>') + '"' : '';
- const notFullyClass = (it.is_fully_derived === false) ? ' derivation-topic-not-fully-derived' : '';
- html += '<span class="derivation-topic-item ' + it.cls + notFullyClass + '"' + dataAttr + '>' + escapeHtml(it.name) + searchIcon + toolIcon + '</span>';
- }});
- if (items.length === 0) html += '<span style="color:#999;">-</span>';
- html += '</td>';
- }}
- html += '</tr>';
- }});
- if (rootSourceList.length === 0) {{
- html += '<tr><td colspan="5" style="color:#94a3b8;text-align:center;padding:12px;">暂无选题表数据(请检查整体推导结果与 input 解构内容)</td></tr>';
- }}
- html += '</tbody></table>';
- const _dimDoc = (dimensionAnalyzeData && dimensionAnalyzeData[fileId]) ? dimensionAnalyzeData[fileId] : null;
- const _dimRounds = (_dimDoc && _dimDoc.rounds) ? _dimDoc.rounds : [];
- const _dimForRound = _dimRounds.find(function(dr) {{ return dr.round === round.轮次; }}) || null;
- html += '<div class="derivation-dimension-extra">';
- if (_dimForRound) {{
- const _dd = (_dimForRound.derived_dims || []).map(function(d) {{
- if (d && typeof d === 'object') {{
- const tn = d.tree_node_name || '';
- const dim = d.dimension || '';
- const mp = d.matched_point || '';
- let s = tn;
- if (dim) {{
- s += '->' + dim;
- }}
- if (mp) {{
- s += '(' + mp + ')';
- }}
- return escapeHtml(s);
- }}
- return escapeHtml(String(d));
- }}).join('、');
- const _ud = (_dimForRound.underived_dims || []).map(function(d) {{
- if (d && typeof d === 'object') {{
- const tn = d.tree_node_name || '';
- const dim = d.dimension || '';
- const mp = d.matched_point || '';
- let s = tn;
- if (dim) {{
- s += '->' + dim;
- }}
- if (mp) {{
- s += '(' + mp + ')';
- }}
- return escapeHtml(s);
- }}
- return escapeHtml(String(d));
- }}).join('、');
- html += '<div class="derivation-dim-line"><span class="derivation-dim-label">已推导维度</span> <span class="derivation-dim-val dim-derived">' + (_dd || '—') + '</span></div>';
- html += '<div class="derivation-dim-line"><span class="derivation-dim-label">未推导维度</span> <span class="derivation-dim-val dim-underived">' + (_ud || '—') + '</span></div>';
- }} else {{
- html += '<div class="derivation-dim-line dim-muted">暂无与本轮对应的整体推导维度分析</div>';
- }}
- const _pidAttr = String(fileId).replace(/&/g, '&').replace(/"/g, '"');
- html += '<button type="button" class="btn-dimension-patterns" data-post-id="' + _pidAttr + '" data-round="' + String(round.轮次) + '">维度patterns</button>';
- html += '</div>';
- html += '</div>';
- }}
- html += '</div>';
- container.innerHTML = html;
-
- container.querySelectorAll('.btn-dimension-patterns').forEach(function(el) {{
- el.addEventListener('click', function() {{
- const pid = this.getAttribute('data-post-id');
- const rn = parseInt(this.getAttribute('data-round'), 10);
- showDimensionPatternsModal(pid, rn);
- }});
- }});
- // 添加点击事件:点击已推导成功的选题点,在画布中定位(只关联node_list中的节点)
- container.querySelectorAll('.derivation-topic-item[data-topic-name]').forEach(el => {{
- el.addEventListener('click', function() {{
- const topicName = this.getAttribute('data-topic-name');
- if (topicName) {{
- focusOnNodeByName(topicName);
- }}
- }});
- }});
- }}
-
- // 根据选题点名称定位节点(只关联node_list中的节点,不关联all_used_tree_nodes)
- function focusOnNodeByName(topicName) {{
- // 只在node_list中查找,排除level -1的节点(all_used_tree_nodes)
- let node = null;
- for (let level in flatData.nodesByLevel) {{
- const levelNum = parseInt(level);
- if (levelNum !== -1) {{ // 排除level -1的节点
- const found = flatData.nodesByLevel[levelNum].find(n => n.name === topicName);
- if (found) {{
- node = found;
- break;
- }}
- }}
- }}
-
- if (node) {{
- focusOnNode(node);
- highlightDirectSources(node);
- }} else {{
- // 如果在当前数据中找不到,尝试搜索
- const searchInput = document.getElementById('search-input');
- if (searchInput) {{
- searchInput.value = topicName;
- // 再次搜索,排除level -1
- for (let level in flatData.nodesByLevel) {{
- const levelNum = parseInt(level);
- if (levelNum !== -1) {{
- const match = flatData.nodesByLevel[levelNum].find(n => n.name.toLowerCase().includes(topicName.toLowerCase()));
- if (match) {{
- focusOnNode(match);
- highlightDirectSources(match);
- return;
- }}
- }}
- }}
- }}
- }}
- }}
-
- // 切换推导进度显示
- function toggleDerivationProgress() {{
- const section = document.getElementById('derivation-progress-section');
- const appContainer = document.getElementById('app-container');
- const btn = document.querySelector('.derivation-progress-toggle');
- if (section.classList.contains('active')) {{
- section.classList.remove('active');
- appContainer.classList.remove('derivation-open');
- appContainer.style.bottom = '';
- btn.textContent = '展开';
- }} else {{
- section.classList.add('active');
- appContainer.classList.add('derivation-open');
- // 设置画布底部边距为推导进度面板的高度
- const sectionHeight = section.offsetHeight;
- appContainer.style.bottom = sectionHeight + 'px';
- btn.textContent = '收起';
- }}
- }}
- // 推导进度面板高度拖拽调整
- (function() {{
- const resizer = document.getElementById('derivation-resizer');
- const section = document.getElementById('derivation-progress-section');
- const appContainer = document.getElementById('app-container');
- let isResizing = false;
- let startY = 0;
- let startHeight = 0;
- resizer.addEventListener('mousedown', function(e) {{
- isResizing = true;
- startY = e.clientY;
- startHeight = section.offsetHeight;
- resizer.classList.add('active');
- document.body.classList.add('resizing');
- section.style.transition = 'none';
- appContainer.style.transition = 'none';
- e.preventDefault();
- }});
- document.addEventListener('mousemove', function(e) {{
- if (!isResizing) return;
- const delta = startY - e.clientY;
- const minH = 200;
- const maxH = Math.floor(window.innerHeight * 0.8);
- const newHeight = Math.min(maxH, Math.max(minH, startHeight + delta));
- section.style.height = newHeight + 'px';
- if (section.classList.contains('active')) {{
- appContainer.style.bottom = newHeight + 'px';
- }}
- }});
- document.addEventListener('mouseup', function() {{
- if (isResizing) {{
- isResizing = false;
- resizer.classList.remove('active');
- document.body.classList.remove('resizing');
- section.style.transition = '';
- appContainer.style.transition = '';
- }}
- }});
- }})();
- // 初始化
- parseData(currentPostKey);
- calculateLayout();
- renderNodes();
- renderEdges();
- updateTransform();
- renderDerivationProgress(currentPostKey);
- </script>
- </body>
- </html>
- '''
- with open(output_path, 'w', encoding='utf-8') as f:
- f.write(html_content)
- print(f"最终结果可视化已生成: {output_path}")
- def main(account_name) -> None:
- name = account_name
- base = Path(__file__).resolve().parent
- output_base = base / "output" / name
- data_dir = output_base / "整体推导路径可视化"
- if not data_dir.exists():
- print(f"错误: 找不到数据目录 {data_dir}")
- return
- json_files = sorted(f for f in os.listdir(data_dir) if f.endswith(".json"))
- if not json_files:
- print(f"在目录 {data_dir} 中未找到 .json 文件。")
- return
- data_map: Dict[str, dict] = {}
- print("\n" + "=" * 50)
- print(f"账号: {name}")
- print(f"数据目录: {data_dir}")
- print(f"正在读取 {len(json_files)} 个帖子数据...")
- for filename in json_files:
- json_path = data_dir / filename
- try:
- with open(json_path, "r", encoding="utf-8") as f:
- data_map[filename] = json.load(f)
- print(f" -> 已读取: {filename}")
- except Exception as e:
- print(f" [错误] 读取 {filename} 时出错: {e}")
- if not data_map:
- print("没有成功读取到任何数据。")
- return
- post_detail_map: Dict[str, dict] = {}
- for filename in data_map.keys():
- post_id = Path(filename).stem
- try:
- detail = load_post_detail_for_visualization(name, post_id)
- if detail is not None:
- post_detail_map[filename] = detail
- except Exception as e:
- print(f" [警告] 加载帖子详情 {filename} 时出错: {e}")
- derivation_dir = output_base / "整体推导结果"
- derivation_data: Dict[str, list] = {}
- if derivation_dir.exists():
- print("\n正在读取推导进度数据...")
- for json_file in derivation_dir.glob("*.json"):
- try:
- with open(json_file, "r", encoding="utf-8") as f:
- derivation_data[json_file.stem] = json.load(f)
- print(f" -> 已加载推导进度: {json_file.name}")
- except Exception as e:
- print(f" [警告] 读取推导进度 {json_file.name} 时出错: {e}")
- else:
- print(f" [提示] 推导结果目录不存在: {derivation_dir}")
- dimension_analyze_dir = output_base / "整体推导维度分析"
- dimension_analyze_map: Dict[str, dict] = {}
- if dimension_analyze_dir.exists():
- print("\n正在读取整体推导维度分析...")
- suf = "_pattern_dimension_analyze"
- for json_file in sorted(dimension_analyze_dir.glob(f"*{suf}.json")):
- stem = json_file.stem
- if not stem.endswith(suf):
- continue
- post_id_key = stem[: -len(suf)]
- try:
- with open(json_file, "r", encoding="utf-8") as f:
- dimension_analyze_map[post_id_key] = json.load(f)
- print(f" -> 已加载维度分析: {json_file.name}")
- except Exception as e:
- print(f" [警告] 读取维度分析 {json_file.name} 时出错: {e}")
- else:
- print(f" [提示] 整体推导维度分析目录不存在: {dimension_analyze_dir}")
- output_base.mkdir(parents=True, exist_ok=True)
- ts = datetime.now().strftime("%Y%m%d%H%M%S")
- output_path = output_base / f"{name}_how推导可视化_{ts}.html"
- generate_all_in_one_visualization(
- data_map,
- str(output_path),
- name,
- derivation_data=derivation_data,
- post_detail_map=post_detail_map,
- dimension_analyze_map=dimension_analyze_map,
- )
- print("\n" + "=" * 50)
- print("处理完成!")
- print(f"输出文件: {output_path}")
- print("=" * 50 + "\n")
- if __name__ == "__main__":
- main(account_name="空间点阵设计研究室")
|