classification_tree_visualizer.py 144 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237
  1. """
  2. 分类优化结果可视化工具
  3. 功能:
  4. 1. 读取优化后的聚类数据 (optimized_clustered_data_*.json)
  5. 2. 生成交互式HTML树形可视化
  6. 3. 支持查看帖子详情和所有点的信息
  7. 4. 区分显示原始分类、LLM抽象、LLM细分
  8. """
  9. import json
  10. import os
  11. from typing import Dict, Any, List, Optional
  12. from pathlib import Path
  13. from datetime import datetime
  14. class ClassificationTreeVisualizer:
  15. """分类树可视化工具"""
  16. def __init__(self):
  17. self.post_cache: Dict[str, Dict[str, Any]] = {}
  18. self.xuanti_point_map: Dict[str, Dict[str, Any]] = {}
  19. self.clustered_data: Dict[str, Any] = {}
  20. self.dimension_associations: Dict[str, Any] = {}
  21. self.intra_dimension_associations: Dict[str, Any] = {}
  22. def load_post_data(self, post_id: str, posts_dir: Path) -> Optional[Dict[str, Any]]:
  23. """加载帖子详细数据"""
  24. if post_id in self.post_cache:
  25. return self.post_cache[post_id]
  26. post_file = posts_dir / f"{post_id}.json"
  27. if not post_file.exists():
  28. return None
  29. try:
  30. with open(post_file, 'r', encoding='utf-8') as f:
  31. post_data = json.load(f)
  32. self.post_cache[post_id] = post_data
  33. return post_data
  34. except Exception as e:
  35. print(f"加载帖子 {post_id} 失败: {e}")
  36. return None
  37. def generate_tree_node_html(
  38. self,
  39. node_name: str,
  40. node_data: Dict[str, Any],
  41. level: int,
  42. point_type: str,
  43. path: List[str]
  44. ) -> str:
  45. """递归生成树节点的HTML - 支持部分细分结构"""
  46. import html as html_module
  47. node_name_escaped = html_module.escape(node_name)
  48. current_path = path + [node_name]
  49. node_id = f"{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_')
  50. meta = node_data.get('_meta', {})
  51. source = meta.get('分类来源', '')
  52. # 检查是否有保留的点
  53. has_kept_points = '点列表' in node_data and len(node_data.get('点列表', [])) > 0
  54. # 检查是否有子分类
  55. has_children = False
  56. for key in node_data.keys():
  57. if key not in ['_meta', '点列表', '帖子数', '点数', '帖子列表'] and isinstance(node_data[key], dict):
  58. has_children = True
  59. break
  60. # 确定节点样式
  61. if source == 'LLM抽象' or source == 'LLM细分':
  62. node_class = f"tree-node tree-node-llm level-{level}"
  63. else:
  64. node_class = f"tree-node tree-node-original level-{level}"
  65. html = f'<div class="{node_class}">\n'
  66. html += f' <div class="tree-node-header">\n'
  67. html += f' <span class="tree-node-icon toggle-icon" data-node-id="{node_id}" data-action="toggle-node">▶</span>\n'
  68. html += f' <span class="tree-node-title" data-node-id="{node_id}" data-action="show-classification-detail">{node_name_escaped}</span>\n'
  69. # 只有存在下级分类节点时,才显示展开/收起全部按钮
  70. if has_children:
  71. html += f' <span class="expand-all-icon" data-node-id="{node_id}" data-action="toggle-all-children" title="展开/收起所有下级分类节点">⇅</span>\n'
  72. html += f' </div>\n'
  73. html += f' <div class="tree-node-content" id="{node_id}-content">\n'
  74. html += ' <div class="tree-children">\n'
  75. # 1. 先显示保留在原分类的点(如果有)
  76. if has_kept_points:
  77. point_name_field = "灵感点" if point_type == "灵感点列表" else (
  78. "目的点" if point_type == "目的点" else "关键点"
  79. )
  80. for point in node_data.get('点列表', []):
  81. point_name = point.get(point_name_field, '')
  82. if not point_name:
  83. continue
  84. point_name_escaped = html_module.escape(point_name)
  85. point_id = f"{node_id}_kept_{point_name}".replace('/', '_').replace(' ', '_')
  86. # 获取封面图
  87. post_id = point.get('帖子id', '')
  88. thumbnail_html = ''
  89. if post_id and post_id in self.post_cache:
  90. post_data = self.post_cache[post_id]
  91. images = post_data.get('images', [])
  92. if images and len(images) > 0:
  93. first_image = html_module.escape(images[0])
  94. thumbnail_html = f'<img src="{first_image}" class="node-thumbnail" alt="封面图" loading="lazy">'
  95. html += f'<div class="tree-node tree-node-leaf level-{level + 1}">\n'
  96. html += f' <div class="tree-node-header" data-node-id="{point_id}" data-parent-id="{node_id}" data-action="show-point-detail">\n'
  97. html += f' <span class="tree-node-icon">📄</span>\n'
  98. html += f' <span class="tree-node-title">{point_name_escaped}</span>\n'
  99. html += f' {thumbnail_html}\n'
  100. html += f' </div>\n'
  101. html += '</div>\n'
  102. # 2. 再显示子分类(如果有)
  103. if has_children:
  104. for child_name, child_data in node_data.items():
  105. if child_name in ['_meta', '点列表', '帖子数', '点数', '帖子列表']:
  106. continue
  107. if isinstance(child_data, dict):
  108. html += self.generate_tree_node_html(
  109. child_name, child_data, level + 1, point_type, current_path
  110. )
  111. html += ' </div>\n'
  112. html += ' </div>\n'
  113. html += '</div>\n'
  114. return html
  115. def generate_html(
  116. self,
  117. clustered_data: Dict[str, Any],
  118. posts_dir: Path,
  119. xuanti_point_map: Dict[str, Dict[str, Any]],
  120. dimension_associations: Optional[Dict[str, Any]] = None,
  121. intra_dimension_associations: Optional[Dict[str, Any]] = None,
  122. expanded_orthogonal_combinations: Optional[Dict[str, Any]] = None,
  123. enriched_xuanti_point_map: Optional[Dict[str, Any]] = None
  124. ) -> str:
  125. """生成完整的HTML页面"""
  126. self.xuanti_point_map = xuanti_point_map
  127. self.clustered_data = clustered_data
  128. if dimension_associations:
  129. self.dimension_associations = dimension_associations
  130. if intra_dimension_associations:
  131. self.intra_dimension_associations = intra_dimension_associations
  132. if expanded_orthogonal_combinations:
  133. self.expanded_orthogonal_combinations = expanded_orthogonal_combinations
  134. if enriched_xuanti_point_map:
  135. self.enriched_xuanti_point_map = enriched_xuanti_point_map
  136. # 从clustered_data构建完整的帖子ID到特征对象的映射
  137. self.post_to_features_map = self._build_post_to_features_map(clustered_data)
  138. # 预加载所有帖子数据
  139. all_post_ids = set()
  140. for point_type_data in clustered_data.values():
  141. self._collect_post_ids(point_type_data, all_post_ids)
  142. # 从xuanti_point_map中也加载帖子ID
  143. for post_id in xuanti_point_map.keys():
  144. all_post_ids.add(post_id)
  145. for post_id in all_post_ids:
  146. self.load_post_data(post_id, posts_dir)
  147. # 生成HTML
  148. html = self._generate_html_head()
  149. html += '<body>\n'
  150. # Tab切换结构
  151. html += '<div class="tabs-container">\n'
  152. html += ' <div class="tabs-header">\n'
  153. html += ' <button class="tab-button active" data-tab="tab1">帖子视角 - 原始选题点</button>\n'
  154. html += ' <button class="tab-button" data-tab="tab2">特征视角 - 分类优化结果</button>\n'
  155. html += ' <button class="tab-button" data-tab="tab3">叶子分类组合聚类</button>\n'
  156. html += ' <button class="tab-button" data-tab="tab4">维度关联分析</button>\n'
  157. html += ' </div>\n'
  158. html += '</div>\n'
  159. # Tab 1: 帖子视角 - 原始选题点
  160. html += '<div id="tab1" class="tab-content active">\n'
  161. html += self._generate_tab1_content()
  162. html += '</div>\n'
  163. # Tab 2: 特征视角 - 分类优化结果
  164. html += '<div id="tab2" class="tab-content">\n'
  165. html += self._generate_tab2_content(clustered_data)
  166. html += '</div>\n'
  167. # Tab 3: 叶子分类组合聚类
  168. html += '<div id="tab3" class="tab-content">\n'
  169. if self.intra_dimension_associations:
  170. html += self._generate_tab4_content()
  171. else:
  172. html += '<div style="padding: 40px; text-align: center; color: #999;">未加载叶子分类组合聚类数据</div>\n'
  173. html += '</div>\n'
  174. # Tab 4: 维度关联分析
  175. html += '<div id="tab4" class="tab-content">\n'
  176. if self.dimension_associations:
  177. html += self._generate_tab3_content()
  178. else:
  179. html += '<div style="padding: 40px; text-align: center; color: #999;">未加载维度关联分析数据</div>\n'
  180. html += '</div>\n'
  181. # 添加弹窗容器
  182. html += '''
  183. <div id="detail-modal" class="modal" style="display: none;">
  184. <div class="modal-overlay"></div>
  185. <div class="modal-content">
  186. <div class="modal-header">
  187. <h3 id="modal-title"></h3>
  188. <button class="modal-close" data-action="close-modal">✕</button>
  189. </div>
  190. <div class="modal-body" id="modal-body">
  191. </div>
  192. </div>
  193. </div>
  194. '''
  195. # 添加JavaScript
  196. html += self._generate_javascript(posts_dir)
  197. html += '</body>\n</html>\n'
  198. return html
  199. def _generate_tab1_content(self) -> str:
  200. """生成Tab1内容:帖子视角 - 原始选题点"""
  201. import html as html_module
  202. html = '<div class="mindmap-container">\n'
  203. # 按帖子ID排序
  204. sorted_post_ids = sorted(self.xuanti_point_map.keys())
  205. for post_id in sorted_post_ids:
  206. xuanti_point = self.xuanti_point_map[post_id]
  207. post_data = self.post_cache.get(post_id)
  208. if not post_data:
  209. continue
  210. # 帖子卡片
  211. html += ' <div class="mindmap-section">\n'
  212. # 左侧:帖子信息
  213. html += ' <div class="mindmap-root" style="min-width: 250px; max-width: 250px;">\n'
  214. html += f' <div><strong>{html_module.escape(post_data.get("title", "无标题"))}</strong></div>\n'
  215. # 封面图
  216. images = post_data.get('images', [])
  217. if images and len(images) > 0:
  218. first_image = html_module.escape(images[0])
  219. html += f' <img src="{first_image}" style="width:100%; margin-top:8px; border-radius:4px;" alt="封面" loading="lazy">\n'
  220. html += f' <div style="margin-top:8px; font-size:0.8rem; opacity:0.8;">ID: {html_module.escape(post_id[:12])}...</div>\n'
  221. html += ' </div>\n'
  222. # 右侧:选题点树状结构
  223. html += ' <div class="mindmap-tree">\n'
  224. # 遍历三种点类型
  225. for point_type in ["灵感点列表", "目的点", "关键点列表"]:
  226. points = xuanti_point.get(point_type, [])
  227. if not points:
  228. continue
  229. point_name_field = "灵感点" if point_type == "灵感点列表" else (
  230. "目的点" if point_type == "目的点" else "关键点"
  231. )
  232. html += f' <div class="tree-node tree-node-original level-0" style="margin-bottom:12px;">\n'
  233. html += f' <div class="tree-node-header" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight:600;">\n'
  234. html += f' <span>{point_type} ({len(points)})</span>\n'
  235. html += ' </div>\n'
  236. html += ' <div class="tree-node-content" style="display:flex;">\n'
  237. html += ' <div class="tree-children">\n'
  238. # 遍历该类型下的所有点
  239. for idx, point in enumerate(points):
  240. point_name = point.get(point_name_field, '')
  241. point_id = f"tab1_{post_id}_{point_type}_{idx}"
  242. html += f' <div class="tree-node level-1">\n'
  243. html += f' <div class="tree-node-header">\n'
  244. html += f' <span class="tree-node-icon toggle-icon expanded" data-node-id="{point_id}" data-action="toggle-node">▶</span>\n'
  245. html += f' <span class="tree-node-title" data-node-id="{point_id}" data-action="show-tab1-point-detail">{html_module.escape(point_name)}</span>\n'
  246. html += ' </div>\n'
  247. # 点的内容:只显示特征列表(扁平化展示)
  248. html += f' <div class="tree-node-content" id="{point_id}-content">\n'
  249. # 显示提取的特征(扁平化展示为标签)
  250. features = point.get('提取的特征', [])
  251. if features:
  252. html += ' <div style="display:flex; flex-wrap:wrap; gap:8px; padding:8px; background:#fafbfc; border-radius:4px;">\n'
  253. for feature_idx, feature in enumerate(features):
  254. feature_name = feature.get('特征名称', '')
  255. feature_weight = feature.get('权重', 0)
  256. feature_level1 = feature.get('一级分类', '')
  257. feature_level2 = feature.get('二级分类', '')
  258. # 构建特征分类标签
  259. feature_class_tag = ''
  260. if feature_level1:
  261. feature_class_tag = f'{html_module.escape(feature_level1)}'
  262. if feature_level2:
  263. feature_class_tag += f' / {html_module.escape(feature_level2)}'
  264. # 扁平化的特征标签样式
  265. html += f' <div style="display:inline-flex; align-items:center; gap:6px; padding:6px 12px; background:white; border:1px solid #e0e0e0; border-radius:6px; font-size:0.85rem; box-shadow:0 1px 3px rgba(0,0,0,0.08);">\n'
  266. html += f' <span style="font-weight:500; color:#333;">🔖 {html_module.escape(feature_name)}</span>\n'
  267. html += f' <span style="font-size:0.75rem; color:#999;">({feature_weight})</span>\n'
  268. if feature_class_tag:
  269. html += f' <span style="font-size:0.75rem; background:#fff3e0; padding:2px 8px; border-radius:3px; color:#e65100;">{feature_class_tag}</span>\n'
  270. html += ' </div>\n'
  271. html += ' </div>\n'
  272. html += ' </div>\n'
  273. html += ' </div>\n'
  274. # 在点之间添加分割线(最后一个点不添加)
  275. if idx < len(points) - 1:
  276. html += ' <div style="height:1px; background:linear-gradient(to right, transparent, #e0e0e0 20%, #e0e0e0 80%, transparent); margin:12px 0;"></div>\n'
  277. html += ' </div>\n'
  278. html += ' </div>\n'
  279. html += ' </div>\n'
  280. html += ' </div>\n'
  281. html += ' </div>\n'
  282. html += '</div>\n'
  283. return html
  284. def _generate_tab2_content(self, clustered_data: Dict[str, Any]) -> str:
  285. """生成Tab2内容:特征视角 - 分类优化结果"""
  286. html = '<div class="mindmap-container">\n'
  287. # 为每种点类型生成树
  288. for point_type in ["灵感点列表", "目的点", "关键点列表"]:
  289. type_data = clustered_data.get(point_type, {})
  290. if type_data:
  291. html += f' <div class="mindmap-section">\n'
  292. html += f' <div class="mindmap-root">{point_type}</div>\n'
  293. html += ' <div class="mindmap-tree">\n'
  294. for node_name, node_data in type_data.items():
  295. html += self.generate_feature_tree_node_html(
  296. node_name, node_data, 0, point_type, []
  297. )
  298. html += ' </div>\n'
  299. html += ' </div>\n'
  300. html += '</div>\n'
  301. return html
  302. def _generate_tab3_content(self) -> str:
  303. """生成Tab3内容:维度关联分析 - 交互式分类树"""
  304. html = '<div style="padding: 20px;">\n'
  305. html += '<h2 style="text-align: center; color: #667eea; margin-bottom: 10px;">维度关联分析</h2>\n'
  306. # 模式切换按钮
  307. html += '<div style="text-align: center; margin-bottom: 20px;">\n'
  308. html += ' <div style="display: inline-flex; background: #f0f0f0; border-radius: 8px; padding: 4px;">\n'
  309. html += ' <button id="mode-single" class="mode-button active" data-mode="single" style="padding: 8px 20px; border: none; background: white; cursor: pointer; border-radius: 6px; font-weight: 500; transition: all 0.2s; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">单维度关联</button>\n'
  310. html += ' <button id="mode-three" class="mode-button" data-mode="three" style="padding: 8px 20px; border: none; background: transparent; cursor: pointer; border-radius: 6px; font-weight: 500; transition: all 0.2s; margin-left: 4px;">三维正交关联</button>\n'
  311. html += ' </div>\n'
  312. html += ' <div id="mode-description" style="margin-top: 10px; color: #666; font-size: 0.9rem;">\n'
  313. html += ' <span id="desc-single">点击一个维度的分类,查看它与其他维度的关联关系</span>\n'
  314. html += ' <span id="desc-three" style="display: none;">点击灵感点分类,右侧显示目的点和关键点的正交组合及共同帖子</span>\n'
  315. html += ' </div>\n'
  316. html += '</div>\n'
  317. # 单维度关联模式:三列布局
  318. html += '<div id="single-dim-layout" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-top: 20px;">\n'
  319. dimensions = [
  320. ("灵感点列表", "灵感点"),
  321. ("目的点", "目的点"),
  322. ("关键点列表", "关键点")
  323. ]
  324. for point_type, display_name in dimensions:
  325. html += f'<div style="background: white; border-radius: 8px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">\n'
  326. html += f' <h3 style="color: #667eea; margin-bottom: 16px; text-align: center;">{display_name}</h3>\n'
  327. html += f' <div id="tab3-tree-{point_type}" class="tab3-tree-container">\n'
  328. # 生成分类树
  329. type_data = self.clustered_data.get(point_type, {})
  330. if type_data:
  331. for node_name, node_data in type_data.items():
  332. html += self._generate_tab3_tree_node(
  333. node_name, node_data, 0, point_type, []
  334. )
  335. html += ' </div>\n'
  336. html += '</div>\n'
  337. html += '</div>\n'
  338. # 三维正交关联模式:全屏平铺布局
  339. html += '<div id="three-dim-layout" style="display: none; margin-top: 20px;">\n'
  340. # 全屏正交关系结果区域
  341. html += '<div style="background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);" id="orthogonal-scroll-container">\n'
  342. html += ' <div id="orthogonal-results" style="min-height: 200px;">\n'
  343. html += ' <div style="text-align: center; padding: 60px 20px; color: #999;">\n'
  344. html += ' <p style="font-size: 1.1rem; margin-bottom: 10px;">🔄 加载中...</p>\n'
  345. html += ' <p style="font-size: 0.9rem;">正在加载所有三维正交扩展结果</p>\n'
  346. html += ' </div>\n'
  347. html += ' </div>\n'
  348. html += '</div>\n'
  349. html += '</div>\n'
  350. html += '</div>\n'
  351. return html
  352. def _generate_tab3_tree_node(
  353. self,
  354. node_name: str,
  355. node_data: Dict[str, Any],
  356. level: int,
  357. point_type: str,
  358. path: List[str]
  359. ) -> str:
  360. """递归生成Tab3的树节点HTML,使用数据属性存储关联信息"""
  361. import html as html_module
  362. node_name_escaped = html_module.escape(node_name)
  363. current_path = path + [node_name]
  364. path_str = '/'.join(current_path)
  365. node_id = f"tab3_{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_').replace('(', '').replace(')', '')
  366. meta = node_data.get('_meta', {})
  367. source = meta.get('分类来源', '')
  368. # 检查是否有子分类
  369. has_children = any(
  370. key not in ['_meta', '特征列表', '点列表', '帖子数', '特征数', '点数', '帖子列表'] and isinstance(node_data[key], dict)
  371. for key in node_data.keys()
  372. )
  373. # 确定节点样式 - 不使用绿色,使用浅灰色背景区分LLM生成的分类
  374. if source in ['LLM抽象', 'LLM细分']:
  375. bg_color = '#f0f0f0'
  376. text_color = '#333'
  377. border_style = '1px solid #d0d0d0'
  378. else:
  379. bg_color = '#fafafa'
  380. text_color = '#333'
  381. border_style = '1px solid #e0e0e0'
  382. # 节点容器
  383. html = f'<div class="tab3-node" style="margin-bottom: 6px;">\n'
  384. # 节点头部 - 使用data属性存储分类路径和维度信息以及原始样式
  385. padding_left = level * 16
  386. html += f' <div class="tab3-node-header" '
  387. html += f'data-dimension="{point_type}" '
  388. html += f'data-path="{html_module.escape(path_str)}" '
  389. html += f'data-node-id="{node_id}" '
  390. html += f'data-original-bg="{bg_color}" '
  391. html += f'data-original-border="{border_style}" '
  392. html += f'style="padding: 6px 12px; padding-left: {padding_left + 12}px; '
  393. html += f'background: {bg_color}; color: {text_color}; '
  394. html += f'border: {border_style}; '
  395. html += 'border-radius: 4px; cursor: pointer; transition: all 0.2s; '
  396. html += 'box-shadow: 0 1px 2px rgba(0,0,0,0.05); margin-bottom: 4px;">\n'
  397. if has_children:
  398. # 默认展开,所以使用▼图标
  399. html += f' <span class="tab3-toggle" data-target="{node_id}-content" style="margin-right: 8px; user-select: none;">▼</span>\n'
  400. else:
  401. html += ' <span style="margin-right: 8px;">•</span>\n'
  402. html += f' <span>{node_name_escaped}</span>\n'
  403. html += ' </div>\n'
  404. # 封面图容器 - 用于显示该分类下的帖子封面图
  405. html += f' <div class="tab3-thumbnails" id="{node_id}-thumbnails" style="display: none; margin-top: 8px; margin-left: {padding_left + 12}px; flex-wrap: wrap; gap: 8px;"></div>\n'
  406. # 子节点容器 - 默认展开
  407. if has_children:
  408. html += f' <div id="{node_id}-content" style="display: block;">\n'
  409. for child_name, child_data in node_data.items():
  410. if child_name not in ['_meta', '特征列表', '点列表', '帖子数', '特征数', '点数', '帖子列表'] and isinstance(child_data, dict):
  411. html += self._generate_tab3_tree_node(
  412. child_name, child_data, level + 1, point_type, current_path
  413. )
  414. html += ' </div>\n'
  415. html += '</div>\n'
  416. return html
  417. def _generate_leaf_color(self, index: int, total: int) -> str:
  418. """为叶子分类生成不同的颜色(使用HSL色轮)"""
  419. # 使用HSL色轮,在0-360度范围内均匀分布
  420. # 降低亮度到45%,增加饱和度到85%,让白色字体更突出
  421. hue = int((index * 360) / max(total, 1))
  422. return f'hsl({hue}, 85%, 45%)'
  423. def _highlight_features_in_point_name(self, point_name: str, features: list, leaf_color_map: dict) -> str:
  424. """在点名称中高亮显示特征词(使用对应的叶子分类颜色)"""
  425. import html as html_module
  426. # 构建特征名称到颜色的映射
  427. feature_colors = {}
  428. for feature in features:
  429. feature_name = feature.get('特征名称', '')
  430. leaf_class = feature.get('叶子分类', '')
  431. if feature_name and leaf_class in leaf_color_map:
  432. feature_colors[feature_name] = leaf_color_map[leaf_class]
  433. # 按特征名称长度倒序排序,避免短的先替换导致长的无法匹配
  434. sorted_features = sorted(feature_colors.items(), key=lambda x: len(x[0]), reverse=True)
  435. # 先转义整个字符串
  436. highlighted_name = html_module.escape(point_name)
  437. # 逐个替换特征词为带颜色的span
  438. for feature_name, color in sorted_features:
  439. escaped_name = html_module.escape(feature_name)
  440. if escaped_name in highlighted_name:
  441. highlighted_name = highlighted_name.replace(
  442. escaped_name,
  443. f'<span style="display: inline-block; background: {color}; color: white; padding: 2px 6px; border-radius: 3px; font-weight: 600; margin: 0 2px; white-space: nowrap;">{escaped_name}</span>'
  444. )
  445. return highlighted_name
  446. def _generate_tab4_content(self) -> str:
  447. """生成Tab4内容:叶子分类组合聚类"""
  448. import html as html_module
  449. html = '<div style="padding: 20px;">\n'
  450. html += '<h2 style="text-align: center; color: #667eea; margin-bottom: 20px;">叶子分类组合聚类分析</h2>\n'
  451. # 获取聚类数据
  452. clustering_data = self.intra_dimension_associations.get('叶子分类组合聚类', {})
  453. dimensions = [
  454. ("灵感点", "#9C27B0"),
  455. ("目的点", "#E91E63"),
  456. ("关键点", "#FF9800")
  457. ]
  458. for dimension_name, dimension_color in dimensions:
  459. dimension_clusters = clustering_data.get(dimension_name, {})
  460. # 按点数倒序排列
  461. sorted_clusters = sorted(
  462. dimension_clusters.items(),
  463. key=lambda x: x[1]['点数'],
  464. reverse=True
  465. )
  466. html += f'<div style="margin-bottom: 40px;">\n'
  467. html += f'<h3 style="color: {dimension_color}; border-bottom: 3px solid {dimension_color}; padding-bottom: 10px; margin-bottom: 20px;">\n'
  468. html += f' {dimension_name} ({len(sorted_clusters)} 个聚类)\n'
  469. html += '</h3>\n'
  470. if not sorted_clusters:
  471. html += '<p style="color: #999; text-align: center; padding: 20px;">暂无聚类数据</p>\n'
  472. else:
  473. html += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px;">\n'
  474. for cluster_key, cluster_data in sorted_clusters:
  475. leaf_classifications = cluster_data.get('叶子分类组合', [])
  476. point_count = cluster_data.get('点数', 0)
  477. points_details = cluster_data.get('点详情列表', [])
  478. html += '<div style="background: white; border-radius: 12px; padding: 20px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); transition: transform 0.2s, box-shadow 0.2s;" onmouseover="this.style.transform=\'translateY(-4px)\'; this.style.boxShadow=\'0 8px 20px rgba(0,0,0,0.15)\';" onmouseout="this.style.transform=\'translateY(0)\'; this.style.boxShadow=\'0 4px 12px rgba(0,0,0,0.1)\';">\n'
  479. # 建立叶子分类到颜色的映射
  480. total_classifications = len(leaf_classifications)
  481. leaf_color_map = {}
  482. for idx, leaf_class in enumerate(leaf_classifications):
  483. leaf_color_map[leaf_class] = self._generate_leaf_color(idx, total_classifications)
  484. # 标题:叶子分类标签 + 点数徽章
  485. html += '<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;">\n'
  486. # 叶子分类标签 - 每个分类使用不同颜色
  487. for leaf_class in leaf_classifications:
  488. leaf_color = leaf_color_map[leaf_class]
  489. html += f'<span style="background: {leaf_color}; color: white; padding: 6px 12px; border-radius: 6px; font-size: 0.9rem; font-weight: 600; box-shadow: 0 2px 4px rgba(0,0,0,0.15);">{html_module.escape(leaf_class)}</span>\n'
  490. # 点数徽章
  491. html += f'<span style="background: {dimension_color}; color: white; padding: 6px 12px; border-radius: 20px; font-size: 0.85rem; font-weight: 600; margin-left: auto;">{point_count} 个点</span>\n'
  492. html += '</div>\n'
  493. # 点详情列表
  494. html += '<div style="display: flex; flex-direction: column; gap: 16px;">\n'
  495. for idx, point_detail in enumerate(points_details[:10]): # 最多显示10个点
  496. point_name = point_detail.get('点名称', '')
  497. point_desc = point_detail.get('点描述', '')
  498. features = point_detail.get('特征列表', [])
  499. # 点卡片
  500. html += f'<div style="background: #f8f9fa; border-radius: 8px; padding: 12px; border-left: 4px solid {dimension_color};">\n'
  501. # 点名称(高亮显示特征词)
  502. highlighted_point_name = self._highlight_features_in_point_name(point_name, features, leaf_color_map)
  503. html += f'<div style="font-weight: 600; color: #333; margin-bottom: 8px; font-size: 0.95rem; line-height: 1.8; overflow-wrap: break-word;">{highlighted_point_name}</div>\n'
  504. # 特征列表
  505. if features:
  506. html += '<div style="margin-top: 8px; padding-left: 12px;">\n'
  507. for feature in features:
  508. feature_name = feature.get('特征名称', '')
  509. full_path = feature.get('完整路径', '')
  510. weight = feature.get('权重', 0)
  511. # 特征容器(分两行显示)
  512. html += '<div style="margin-bottom: 8px;">\n'
  513. # 第一行:特征名称 + 权重
  514. html += '<div style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; margin-bottom: 3px;">\n'
  515. html += f'<span style="color: {dimension_color}; font-weight: 600;">•</span>\n'
  516. html += f'<span style="color: #555; font-weight: 600;">{html_module.escape(feature_name)}</span>\n'
  517. html += f'<span style="background: #e0e0e0; color: #666; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem;">权重 {weight}</span>\n'
  518. html += '</div>\n'
  519. # 第二行:完整分类路径(缩进),使用灰色背景
  520. html += '<div style="padding-left: 18px; font-size: 0.8rem;">\n'
  521. html += f'<span style="color: #888; font-family: monospace; background: #f5f5f5; padding: 2px 6px; border-radius: 3px;">{html_module.escape(full_path)}</span>\n'
  522. html += '</div>\n'
  523. html += '</div>\n'
  524. html += '</div>\n'
  525. html += '</div>\n'
  526. if len(points_details) > 10:
  527. html += f'<div style="color: #999; font-size: 0.9rem; text-align: center; padding: 12px; background: #f0f0f0; border-radius: 8px;">... 还有 {len(points_details) - 10} 个点</div>\n'
  528. html += '</div>\n'
  529. html += '</div>\n'
  530. html += '</div>\n'
  531. html += '</div>\n'
  532. html += '</div>\n'
  533. return html
  534. def generate_feature_tree_node_html(
  535. self,
  536. node_name: str,
  537. node_data: Dict[str, Any],
  538. level: int,
  539. point_type: str,
  540. path: List[str]
  541. ) -> str:
  542. """递归生成特征树节点的HTML"""
  543. import html as html_module
  544. node_name_escaped = html_module.escape(node_name)
  545. current_path = path + [node_name]
  546. node_id = f"tab2_{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_')
  547. meta = node_data.get('_meta', {})
  548. source = meta.get('分类来源', '')
  549. # 检查是否有保留的特征
  550. has_kept_features = '特征列表' in node_data and len(node_data.get('特征列表', [])) > 0
  551. # 检查是否有子分类
  552. has_children = False
  553. for key in node_data.keys():
  554. if key not in ['_meta', '特征列表', '帖子数', '特征数', '帖子列表'] and isinstance(node_data[key], dict):
  555. has_children = True
  556. break
  557. # 确定节点样式
  558. if source == 'LLM抽象' or source == 'LLM细分':
  559. node_class = f"tree-node tree-node-llm level-{level}"
  560. else:
  561. node_class = f"tree-node tree-node-original level-{level}"
  562. html = f'<div class="{node_class}">\n'
  563. html += f' <div class="tree-node-header">\n'
  564. html += f' <span class="tree-node-icon toggle-icon" data-node-id="{node_id}" data-action="toggle-node">▶</span>\n'
  565. html += f' <span class="tree-node-title" data-node-id="{node_id}" data-action="show-classification-detail">{node_name_escaped}</span>\n'
  566. if has_children:
  567. html += f' <span class="expand-all-icon" data-node-id="{node_id}" data-action="toggle-all-children" title="展开/收起所有下级分类节点">⇅</span>\n'
  568. html += f' </div>\n'
  569. html += f' <div class="tree-node-content" id="{node_id}-content">\n'
  570. html += ' <div class="tree-children">\n'
  571. # 1. 先显示保留在原分类的特征(如果有)
  572. if has_kept_features:
  573. features_list = node_data.get('特征列表', [])
  574. # 去重特征名称用于显示
  575. unique_features = {}
  576. for feature in features_list:
  577. feature_name = feature.get("特征名称", "")
  578. if feature_name not in unique_features:
  579. unique_features[feature_name] = []
  580. unique_features[feature_name].append(feature)
  581. for feature_name, feature_instances in unique_features.items():
  582. feature_name_escaped = html_module.escape(feature_name)
  583. feature_id = f"{node_id}_kept_{feature_name}".replace('/', '_').replace(' ', '_')
  584. count = len(feature_instances)
  585. # 计算权重平均值
  586. weights = [f.get('权重', 0) for f in feature_instances]
  587. avg_weight = sum(weights) / len(weights) if weights else 0
  588. avg_weight_str = f"{avg_weight:.2f}"
  589. html += f'<div class="tree-node tree-node-leaf level-{level + 1}">\n'
  590. html += f' <div class="tree-node-header" data-node-id="{feature_id}" data-parent-id="{node_id}" data-feature-name="{feature_name_escaped}" data-action="show-feature-detail" style="display:flex; justify-content:space-between; align-items:center;">\n'
  591. html += f' <div style="display:flex; align-items:center; gap:8px;">\n'
  592. html += f' <span class="tree-node-icon">🔖</span>\n'
  593. html += f' <span class="tree-node-title">{feature_name_escaped}</span>\n'
  594. html += f' <span style="font-size:0.8rem; color:#999;">×{count}</span>\n'
  595. html += f' </div>\n'
  596. html += f' <span style="font-size:0.8rem; color:#667eea; font-weight:500;">⚖️ {avg_weight_str}</span>\n'
  597. html += f' </div>\n'
  598. html += '</div>\n'
  599. # 2. 再显示子分类(如果有)
  600. if has_children:
  601. for child_name, child_data in node_data.items():
  602. if child_name in ['_meta', '特征列表', '帖子数', '特征数', '帖子列表']:
  603. continue
  604. if isinstance(child_data, dict):
  605. html += self.generate_feature_tree_node_html(
  606. child_name, child_data, level + 1, point_type, current_path
  607. )
  608. html += ' </div>\n'
  609. html += ' </div>\n'
  610. html += '</div>\n'
  611. return html
  612. def _build_post_to_features_map(self, clustered_data: Dict[str, Any]) -> Dict[str, Dict[str, list]]:
  613. """从clustered_data构建帖子ID到完整特征对象的映射"""
  614. post_to_features = {}
  615. def collect_features(node: Any, point_type: str):
  616. """递归收集所有特征"""
  617. if not isinstance(node, dict):
  618. return
  619. # 如果有特征列表,收集所有特征
  620. if '特征列表' in node:
  621. for feature in node['特征列表']:
  622. post_id = feature.get('帖子id')
  623. if post_id:
  624. if post_id not in post_to_features:
  625. post_to_features[post_id] = {
  626. '灵感点列表': [],
  627. '目的点': [],
  628. '关键点列表': []
  629. }
  630. post_to_features[post_id][point_type].append(feature)
  631. # 递归处理子节点
  632. for key, value in node.items():
  633. if key != '_meta' and isinstance(value, dict):
  634. collect_features(value, point_type)
  635. # 收集三种类型的特征
  636. for point_type in ['灵感点列表', '目的点', '关键点列表']:
  637. if point_type in clustered_data:
  638. collect_features(clustered_data[point_type], point_type)
  639. return post_to_features
  640. def _collect_post_ids(self, data: Any, post_ids: set):
  641. """递归收集所有帖子ID"""
  642. if isinstance(data, dict):
  643. if '帖子列表' in data:
  644. post_ids.update(data['帖子列表'])
  645. for value in data.values():
  646. self._collect_post_ids(value, post_ids)
  647. def _generate_html_head(self) -> str:
  648. """生成HTML头部"""
  649. return '''<!DOCTYPE html>
  650. <html lang="zh-CN">
  651. <head>
  652. <meta charset="UTF-8">
  653. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  654. <title>选题点分析可视化</title>
  655. <style>
  656. * {
  657. margin: 0;
  658. padding: 0;
  659. box-sizing: border-box;
  660. }
  661. body {
  662. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  663. background: #f5f7fa;
  664. color: #333;
  665. }
  666. .tabs-container {
  667. background: white;
  668. box-shadow: 0 2px 4px rgba(0,0,0,0.1);
  669. position: sticky;
  670. top: 0;
  671. z-index: 1000;
  672. }
  673. .tabs-header {
  674. display: flex;
  675. border-bottom: 2px solid #e0e0e0;
  676. }
  677. .tab-button {
  678. flex: 1;
  679. padding: 16px 24px;
  680. background: white;
  681. border: none;
  682. cursor: pointer;
  683. font-size: 1rem;
  684. font-weight: 500;
  685. color: #666;
  686. transition: all 0.3s;
  687. border-bottom: 3px solid transparent;
  688. }
  689. .tab-button:hover {
  690. background: #f5f7fa;
  691. color: #333;
  692. }
  693. .tab-button.active {
  694. color: #667eea;
  695. border-bottom-color: #667eea;
  696. background: #f5f7fa;
  697. }
  698. .tab-content {
  699. display: none;
  700. padding: 20px;
  701. }
  702. .tab-content.active {
  703. display: block;
  704. }
  705. .mindmap-container {
  706. display: flex;
  707. flex-direction: column;
  708. gap: 40px;
  709. }
  710. .mindmap-section {
  711. display: flex;
  712. align-items: flex-start;
  713. gap: 20px;
  714. background: white;
  715. padding: 20px;
  716. border-radius: 8px;
  717. box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  718. overflow-x: auto;
  719. }
  720. .mindmap-root {
  721. background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
  722. color: white;
  723. padding: 12px 20px;
  724. border-radius: 8px;
  725. font-weight: 600;
  726. font-size: 1.1rem;
  727. white-space: nowrap;
  728. box-shadow: 0 2px 6px rgba(0,0,0,0.15);
  729. flex-shrink: 0;
  730. }
  731. .mindmap-tree {
  732. display: flex;
  733. flex-direction: column;
  734. gap: 8px;
  735. flex: 1;
  736. }
  737. .tree-node {
  738. display: grid;
  739. grid-template-columns: 350px 1fr;
  740. gap: 10px;
  741. align-items: start;
  742. position: relative;
  743. }
  744. .tree-node-header {
  745. padding: 6px 12px;
  746. cursor: pointer;
  747. display: flex;
  748. align-items: center;
  749. gap: 8px;
  750. border-radius: 6px;
  751. transition: all 0.2s ease;
  752. font-size: 0.9rem;
  753. grid-column: 1;
  754. max-width: 350px;
  755. word-wrap: break-word;
  756. word-break: break-word;
  757. line-height: 1.4;
  758. }
  759. .tree-node-header:hover {
  760. transform: translateX(2px);
  761. box-shadow: 0 2px 6px rgba(0,0,0,0.15);
  762. }
  763. .tree-node-original > .tree-node-header {
  764. background: white;
  765. color: #333;
  766. border: 1px solid #e0e0e0;
  767. }
  768. .tree-node-llm > .tree-node-header {
  769. background: #4caf50;
  770. color: white;
  771. }
  772. .tree-node-leaf > .tree-node-header {
  773. background: white;
  774. color: #333;
  775. border: 1px solid #e0e0e0;
  776. }
  777. .tree-node-icon {
  778. font-size: 1rem;
  779. }
  780. .toggle-icon {
  781. cursor: pointer;
  782. transition: transform 0.2s;
  783. user-select: none;
  784. }
  785. .toggle-icon.expanded {
  786. transform: rotate(90deg);
  787. }
  788. .expand-all-icon {
  789. cursor: pointer;
  790. font-size: 0.9rem;
  791. margin-left: auto;
  792. padding: 2px 6px;
  793. border-radius: 4px;
  794. transition: all 0.2s;
  795. user-select: none;
  796. background: rgba(0, 0, 0, 0.1);
  797. color: #333;
  798. }
  799. .expand-all-icon:hover {
  800. background: rgba(0, 0, 0, 0.2);
  801. transform: scale(1.1);
  802. }
  803. .tree-node-llm .expand-all-icon {
  804. background: rgba(255, 255, 255, 0.3);
  805. color: white;
  806. }
  807. .tree-node-llm .expand-all-icon:hover {
  808. background: rgba(255, 255, 255, 0.5);
  809. }
  810. .tree-node-title {
  811. font-weight: 500;
  812. cursor: pointer;
  813. }
  814. .tree-node-title:hover {
  815. text-decoration: underline;
  816. }
  817. .tree-node-content {
  818. grid-column: 2;
  819. display: flex;
  820. flex-direction: column;
  821. min-width: 0;
  822. }
  823. .tree-children {
  824. display: flex;
  825. flex-direction: column;
  826. gap: 6px;
  827. padding-left: 20px;
  828. border-left: 2px solid #e0e0e0;
  829. }
  830. .modal {
  831. position: fixed;
  832. top: 0;
  833. left: 0;
  834. width: 100%;
  835. height: 100%;
  836. z-index: 9999;
  837. }
  838. .modal-overlay {
  839. position: absolute;
  840. top: 0;
  841. left: 0;
  842. width: 100%;
  843. height: 100%;
  844. background: rgba(0, 0, 0, 0.5);
  845. }
  846. .modal-content {
  847. position: absolute;
  848. top: 50%;
  849. left: 50%;
  850. transform: translate(-50%, -50%);
  851. background: white;
  852. border-radius: 8px;
  853. max-width: 900px;
  854. max-height: 85vh;
  855. width: 95%;
  856. display: flex;
  857. flex-direction: column;
  858. box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  859. }
  860. .modal-header {
  861. display: flex;
  862. justify-content: space-between;
  863. align-items: center;
  864. padding: 16px 20px;
  865. border-bottom: 1px solid #e0e0e0;
  866. }
  867. .modal-header h3 {
  868. margin: 0;
  869. font-size: 1.2rem;
  870. color: #333;
  871. }
  872. .modal-close {
  873. background: none;
  874. border: none;
  875. font-size: 1.5rem;
  876. cursor: pointer;
  877. color: #666;
  878. padding: 0;
  879. width: 30px;
  880. height: 30px;
  881. display: flex;
  882. align-items: center;
  883. justify-content: center;
  884. border-radius: 4px;
  885. transition: all 0.2s;
  886. }
  887. .modal-close:hover {
  888. background: #f0f0f0;
  889. color: #333;
  890. }
  891. .modal-body {
  892. padding: 20px;
  893. overflow-y: auto;
  894. flex: 1;
  895. }
  896. .modal-section {
  897. margin-bottom: 20px;
  898. }
  899. .modal-section:last-child {
  900. margin-bottom: 0;
  901. }
  902. .modal-section h4 {
  903. margin: 0 0 12px 0;
  904. color: #667eea;
  905. font-size: 1rem;
  906. }
  907. .modal-section p {
  908. margin: 8px 0;
  909. line-height: 1.6;
  910. color: #555;
  911. }
  912. .modal-list {
  913. list-style: none;
  914. padding: 0;
  915. margin: 0;
  916. }
  917. .modal-list-item {
  918. padding: 10px;
  919. background: #f8f9fa;
  920. border-radius: 6px;
  921. margin-bottom: 8px;
  922. border-left: 3px solid #667eea;
  923. }
  924. .modal-list-item strong {
  925. display: block;
  926. margin-bottom: 4px;
  927. color: #333;
  928. }
  929. .modal-list-item p {
  930. margin: 4px 0 0 0;
  931. font-size: 0.9rem;
  932. color: #666;
  933. }
  934. .post-grid {
  935. display: grid;
  936. grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  937. gap: 8px;
  938. }
  939. .post-card {
  940. padding: 8px 12px;
  941. background: #e3f2fd;
  942. border-radius: 6px;
  943. cursor: pointer;
  944. transition: all 0.2s;
  945. text-align: center;
  946. font-size: 0.85rem;
  947. color: #1976d2;
  948. }
  949. .post-card:hover {
  950. background: #bbdefb;
  951. transform: translateY(-2px);
  952. box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
  953. }
  954. .post-title {
  955. font-size: 1.1rem;
  956. color: #333;
  957. margin-bottom: 12px;
  958. }
  959. .post-images {
  960. display: grid;
  961. grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
  962. gap: 12px;
  963. margin: 12px 0;
  964. }
  965. .post-image {
  966. width: 100%;
  967. max-width: 400px;
  968. height: auto;
  969. border-radius: 8px;
  970. border: 1px solid #e0e0e0;
  971. cursor: pointer;
  972. transition: transform 0.2s, box-shadow 0.2s;
  973. }
  974. .post-image:hover {
  975. transform: scale(1.02);
  976. box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
  977. }
  978. /* 叶子节点的封面图 */
  979. .node-thumbnail {
  980. width: 40px;
  981. height: 40px;
  982. object-fit: cover;
  983. border-radius: 4px;
  984. border: 1px solid #e0e0e0;
  985. margin-left: 8px;
  986. }
  987. .tree-node-leaf .tree-node-header {
  988. padding: 4px 8px;
  989. }
  990. .more-images {
  991. color: #666;
  992. font-size: 0.9rem;
  993. font-style: italic;
  994. margin-top: 8px;
  995. }
  996. .post-body {
  997. font-weight: 600;
  998. margin-top: 12px;
  999. margin-bottom: 8px;
  1000. }
  1001. .post-body-text {
  1002. background: #f8f9fa;
  1003. padding: 12px;
  1004. border-radius: 6px;
  1005. border-left: 3px solid #667eea;
  1006. line-height: 1.6;
  1007. color: #555;
  1008. white-space: pre-wrap;
  1009. word-wrap: break-word;
  1010. }
  1011. .post-meta {
  1012. color: #999;
  1013. font-size: 0.85rem;
  1014. margin-top: 12px;
  1015. font-style: italic;
  1016. }
  1017. /* 弹窗内的TAB样式 */
  1018. .modal-tabs-header {
  1019. display: flex;
  1020. gap: 8px;
  1021. padding: 8px;
  1022. background: #f5f7fa;
  1023. border-radius: 6px;
  1024. margin-bottom: 16px;
  1025. overflow-x: auto;
  1026. flex-wrap: wrap;
  1027. }
  1028. .modal-tab-button {
  1029. padding: 8px 16px;
  1030. background: white;
  1031. border: 1px solid #e0e0e0;
  1032. border-radius: 4px;
  1033. cursor: pointer;
  1034. font-size: 0.9rem;
  1035. color: #666;
  1036. transition: all 0.2s;
  1037. white-space: nowrap;
  1038. }
  1039. .modal-tab-button:hover {
  1040. background: #f0f0f0;
  1041. color: #333;
  1042. }
  1043. .modal-tab-button.active {
  1044. background: #667eea;
  1045. color: white;
  1046. border-color: #667eea;
  1047. }
  1048. .modal-tab-content {
  1049. display: none;
  1050. }
  1051. .modal-tab-content.active {
  1052. display: block;
  1053. }
  1054. </style>
  1055. </head>
  1056. '''
  1057. def _generate_javascript(self, posts_dir: Path) -> str:
  1058. """生成JavaScript代码"""
  1059. import json as json_module
  1060. post_cache_json = json_module.dumps(self.post_cache, ensure_ascii=True)
  1061. xuanti_point_map_json = json_module.dumps(self.xuanti_point_map, ensure_ascii=True)
  1062. clustered_data_json = json_module.dumps(self.clustered_data, ensure_ascii=True)
  1063. post_to_features_map_json = json_module.dumps(self.post_to_features_map, ensure_ascii=True)
  1064. js_code = '''<script>
  1065. document.addEventListener('DOMContentLoaded', function() {
  1066. const postCache = ''' + post_cache_json + ''';
  1067. const xuantiPointMap = ''' + xuanti_point_map_json + ''';
  1068. const clusteredData = ''' + clustered_data_json + ''';
  1069. const postToFeaturesMap = ''' + post_to_features_map_json + ''';
  1070. console.log('数据加载完成');
  1071. // Tab切换功能
  1072. function switchTab(tabId) {
  1073. // 隐藏所有tab内容
  1074. document.querySelectorAll('.tab-content').forEach(tab => {
  1075. tab.classList.remove('active');
  1076. });
  1077. // 移除所有按钮的active状态
  1078. document.querySelectorAll('.tab-button').forEach(btn => {
  1079. btn.classList.remove('active');
  1080. });
  1081. // 显示选中的tab
  1082. document.getElementById(tabId).classList.add('active');
  1083. // 激活对应的按钮
  1084. document.querySelector(`[data-tab="${tabId}"]`).classList.add('active');
  1085. }
  1086. function escapeHtml(text) {
  1087. if (!text) return '';
  1088. const div = document.createElement('div');
  1089. div.textContent = text;
  1090. return div.innerHTML;
  1091. }
  1092. function showModal(title, content) {
  1093. const modal = document.getElementById('detail-modal');
  1094. document.getElementById('modal-title').textContent = title;
  1095. document.getElementById('modal-body').innerHTML = content;
  1096. modal.style.display = 'block';
  1097. }
  1098. function closeModal() {
  1099. document.getElementById('detail-modal').style.display = 'none';
  1100. }
  1101. function toggleNode(nodeId, toggleIcon) {
  1102. const content = document.getElementById(nodeId + '-content');
  1103. if (!content) return;
  1104. if (content.style.display === 'none') {
  1105. content.style.display = 'flex';
  1106. if (toggleIcon) toggleIcon.classList.add('expanded');
  1107. } else {
  1108. content.style.display = 'none';
  1109. if (toggleIcon) toggleIcon.classList.remove('expanded');
  1110. }
  1111. }
  1112. function toggleAllChildren(nodeId) {
  1113. const content = document.getElementById(nodeId + '-content');
  1114. if (!content) return;
  1115. // 找到直接子分类节点(排除叶子节点)
  1116. const childrenContainer = content.querySelector('.tree-children');
  1117. if (!childrenContainer) return;
  1118. const directChildren = Array.from(childrenContainer.children).filter(child => {
  1119. return child.classList.contains('tree-node') && !child.classList.contains('tree-node-leaf');
  1120. });
  1121. if (directChildren.length === 0) return;
  1122. // 检查第一个子分类节点的状态,决定是全部展开还是全部收起
  1123. const firstChild = directChildren[0];
  1124. const firstChildContent = firstChild.querySelector('.tree-node-content');
  1125. const shouldExpand = !firstChildContent || firstChildContent.style.display === 'none';
  1126. // 统一操作所有直接子分类节点
  1127. directChildren.forEach(childNode => {
  1128. toggleNodeRecursive(childNode, shouldExpand);
  1129. });
  1130. }
  1131. function toggleNodeRecursive(node, expand) {
  1132. const content = node.querySelector('.tree-node-content');
  1133. const toggleIcon = node.querySelector('.toggle-icon');
  1134. if (content) {
  1135. content.style.display = expand ? 'flex' : 'none';
  1136. }
  1137. if (toggleIcon) {
  1138. if (expand) {
  1139. toggleIcon.classList.add('expanded');
  1140. } else {
  1141. toggleIcon.classList.remove('expanded');
  1142. }
  1143. }
  1144. // 递归处理所有子节点
  1145. if (content) {
  1146. const childNodes = content.querySelectorAll(':scope > .tree-children > .tree-node');
  1147. childNodes.forEach(childNode => {
  1148. if (!childNode.classList.contains('tree-node-leaf')) {
  1149. toggleNodeRecursive(childNode, expand);
  1150. }
  1151. });
  1152. }
  1153. }
  1154. function findNodeData(nodeId) {
  1155. const parts = nodeId.split('_');
  1156. // 处理tab2的节点ID格式: tab2_{point_type}_{path}
  1157. let pointType, pathParts;
  1158. if (parts[0] === 'tab2') {
  1159. pointType = parts[1];
  1160. pathParts = parts.slice(2);
  1161. } else {
  1162. // 处理原来的格式
  1163. pointType = parts[0];
  1164. pathParts = parts.slice(1);
  1165. }
  1166. let data = clusteredData[pointType];
  1167. for (const part of pathParts) {
  1168. if (data && data[part]) data = data[part];
  1169. else return null;
  1170. }
  1171. return data;
  1172. }
  1173. function showPointDetail(nodeId, parentId) {
  1174. const parentData = findNodeData(parentId);
  1175. if (!parentData) return;
  1176. const parts = nodeId.split('_');
  1177. const pointType = parts[0];
  1178. // 提取点名称:如果包含 "kept",则点名称在kept之后
  1179. let pointName;
  1180. const keptIndex = parts.indexOf('kept');
  1181. if (keptIndex !== -1 && keptIndex < parts.length - 1) {
  1182. // 保留的点:格式为 ...node_id_kept_pointName
  1183. pointName = parts.slice(keptIndex + 1).join('_');
  1184. } else {
  1185. // 普通点:格式为 ...node_id_pointName
  1186. pointName = parts[parts.length - 1];
  1187. }
  1188. const pointField = pointType === '灵感点列表' ? '灵感点' : (pointType === '目的点' ? '目的点' : '关键点');
  1189. const points = parentData['点列表'] || [];
  1190. const pointInfo = points.find(p => p[pointField] === pointName);
  1191. if (!pointInfo) {
  1192. showModal(pointName, '<p>未找到点信息</p>');
  1193. return;
  1194. }
  1195. let html = '';
  1196. // 显示点信息
  1197. html += '<div class="modal-section"><h4>点信息</h4>';
  1198. html += '<p><strong>' + escapeHtml(pointInfo[pointField] || '') + '</strong></p>';
  1199. if (pointInfo['描述']) {
  1200. html += '<p>' + escapeHtml(pointInfo['描述']) + '</p>';
  1201. }
  1202. html += '</div>';
  1203. // 获取点对应的帖子ID
  1204. const postId = pointInfo['帖子id'];
  1205. if (!postId) {
  1206. showModal(pointName, html + '<p>该点没有关联的帖子</p>');
  1207. return;
  1208. }
  1209. // 显示帖子详情
  1210. const postData = postCache[postId];
  1211. if (postData) {
  1212. html += '<div class="modal-section"><h4>帖子详情</h4>';
  1213. // 标题
  1214. if (postData.title) {
  1215. html += '<p class="post-title"><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
  1216. }
  1217. // 图片
  1218. const images = postData.images || [];
  1219. if (images.length > 0) {
  1220. html += '<div class="post-images">';
  1221. images.forEach((img, idx) => {
  1222. html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
  1223. });
  1224. html += '</div>';
  1225. }
  1226. // 正文
  1227. if (postData.body_text) {
  1228. html += '<p class="post-body"><strong>正文:</strong></p>';
  1229. html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
  1230. }
  1231. html += '<p class="post-meta">帖子ID: ' + escapeHtml(postId) + '</p>';
  1232. html += '</div>';
  1233. // 显示该帖子的所有选题点(从xuantiPointMap获取完整对象)
  1234. const xuantiPoints = xuantiPointMap[postId];
  1235. if (xuantiPoints) {
  1236. html += '<div class="modal-section"><h4>该帖子的所有选题点</h4>';
  1237. ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
  1238. const pts = xuantiPoints[type];
  1239. if (pts && pts.length > 0) {
  1240. const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
  1241. html += '<h5>' + type + '</h5><ul class="modal-list">';
  1242. pts.forEach(pt => {
  1243. html += '<li class="modal-list-item"><strong>' + escapeHtml(pt[field] || '') + '</strong>';
  1244. if (pt['描述']) html += '<p>' + escapeHtml(pt['描述']) + '</p>';
  1245. html += '</li>';
  1246. });
  1247. html += '</ul>';
  1248. }
  1249. });
  1250. html += '</div>';
  1251. }
  1252. } else {
  1253. html += '<div class="modal-section"><p>无法加载帖子数据 (ID: ' + escapeHtml(postId) + ')</p></div>';
  1254. }
  1255. showModal(pointName, html);
  1256. }
  1257. function showClassificationDetail(nodeId) {
  1258. const nodeData = findNodeData(nodeId);
  1259. if (!nodeData) return;
  1260. const meta = nodeData._meta || {};
  1261. const parts = nodeId.split('_');
  1262. const nodeName = parts[parts.length - 1];
  1263. let html = '';
  1264. if (meta.分类来源 === 'LLM抽象') {
  1265. html += '<div class="modal-section"><h4>合并描述</h4><p>' + escapeHtml(meta.合并描述 || '') + '</p></div>';
  1266. html += '<div class="modal-section"><h4>合并理由</h4><p>' + escapeHtml(meta.合并理由 || '') + '</p></div>';
  1267. const originals = meta.包含的原分类 || [];
  1268. html += '<div class="modal-section"><h4>包含的原分类</h4><ul class="modal-list">';
  1269. originals.forEach(name => { html += '<li class="modal-list-item">' + escapeHtml(name) + '</li>'; });
  1270. html += '</ul></div>';
  1271. } else if (meta.分类来源 === 'LLM细分') {
  1272. html += '<div class="modal-section"><h4>子分类描述</h4><p>' + escapeHtml(meta.子分类描述 || '') + '</p></div>';
  1273. html += '<div class="modal-section"><h4>分类依据</h4><p>' + escapeHtml(meta.分类依据 || '') + '</p></div>';
  1274. } else if (meta.已细分) {
  1275. html += '<div class="modal-section"><h4>细分说明</h4><p>' + escapeHtml(meta.细分说明 || '') + '</p></div>';
  1276. } else {
  1277. html += '<div class="modal-section"><p>这是原始分类,没有额外的元信息。</p></div>';
  1278. }
  1279. showModal(nodeName, html);
  1280. }
  1281. function showPostDetail(postId) {
  1282. const postData = postCache[postId];
  1283. if (!postData) {
  1284. showModal(postId, '<p>无法加载帖子数据</p>');
  1285. return;
  1286. }
  1287. let html = '<div class="modal-section"><h4>帖子基本信息</h4>';
  1288. html += '<p><strong>帖子ID:</strong>' + escapeHtml(postId) + '</p>';
  1289. html += '<p><strong>标题:</strong>' + escapeHtml(postData.title || '无标题') + '</p>';
  1290. html += '</div>';
  1291. // 显示所有图片
  1292. const images = postData.images || [];
  1293. if (images.length > 0) {
  1294. html += '<div class="modal-section"><h4>帖子图片 (' + images.length + ')</h4>';
  1295. html += '<div class="post-images">';
  1296. images.forEach((img, idx) => {
  1297. html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
  1298. });
  1299. html += '</div></div>';
  1300. }
  1301. // 显示正文
  1302. if (postData.body_text) {
  1303. html += '<div class="modal-section"><h4>正文</h4>';
  1304. html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
  1305. html += '</div>';
  1306. }
  1307. // 显示该帖子的选题点信息(从xuantiPointMap获取)
  1308. const xuantiPoints = xuantiPointMap[postId];
  1309. if (xuantiPoints) {
  1310. html += '<div class="modal-section"><h4>选题点信息</h4>';
  1311. ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
  1312. const points = xuantiPoints[type];
  1313. if (points && points.length > 0) {
  1314. const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
  1315. html += '<h5>' + type + ' (' + points.length + ')</h5><ul class="modal-list">';
  1316. points.forEach(point => {
  1317. html += '<li class="modal-list-item"><strong>' + escapeHtml(point[field] || '') + '</strong>';
  1318. if (point['描述']) html += '<p>' + escapeHtml(point['描述']) + '</p>';
  1319. html += '</li>';
  1320. });
  1321. html += '</ul>';
  1322. }
  1323. });
  1324. html += '</div>';
  1325. }
  1326. // 显示该帖子的特征信息(从postToFeaturesMap获取)
  1327. const xuantiFeatures = postToFeaturesMap[postId];
  1328. if (xuantiFeatures && Object.keys(xuantiFeatures).length > 0) {
  1329. html += '<div class="modal-section"><h4>提取的特征</h4>';
  1330. ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
  1331. const features = xuantiFeatures[type];
  1332. if (features && features.length > 0) {
  1333. // 去重特征名称
  1334. const uniqueFeatures = {};
  1335. features.forEach(f => {
  1336. const name = f['特征名称'];
  1337. if (!uniqueFeatures[name]) {
  1338. uniqueFeatures[name] = [];
  1339. }
  1340. uniqueFeatures[name].push(f);
  1341. });
  1342. html += '<h5>' + type + ' (' + Object.keys(uniqueFeatures).length + ' 个特征)</h5>';
  1343. html += '<div class="post-grid">';
  1344. Object.keys(uniqueFeatures).forEach(name => {
  1345. html += '<div class="post-card" style="background:#f0f8ff;">';
  1346. html += escapeHtml(name) + ' (' + uniqueFeatures[name].length + ')';
  1347. html += '</div>';
  1348. });
  1349. html += '</div>';
  1350. }
  1351. });
  1352. html += '</div>';
  1353. }
  1354. showModal(postId, html);
  1355. }
  1356. // 暴露showPostDetail为全局函数,以便在动态生成的HTML中使用
  1357. window.showPostDetail = showPostDetail;
  1358. function showTab1PointDetail(nodeId) {
  1359. // nodeId格式: tab1_{post_id}_{point_type}_{idx}
  1360. const parts = nodeId.split('_');
  1361. const postId = parts[1];
  1362. const pointType = parts.slice(2, -1).join('_');
  1363. const idx = parseInt(parts[parts.length - 1]);
  1364. const xuantiPoint = xuantiPointMap[postId];
  1365. if (!xuantiPoint) {
  1366. showModal('错误', '<p>未找到帖子数据</p>');
  1367. return;
  1368. }
  1369. const points = xuantiPoint[pointType] || [];
  1370. const point = points[idx];
  1371. if (!point) {
  1372. showModal('错误', '<p>未找到点信息</p>');
  1373. return;
  1374. }
  1375. const pointField = pointType === '灵感点列表' ? '灵感点' : (pointType === '目的点' ? '目的点' : '关键点');
  1376. const pointName = point[pointField] || '';
  1377. let html = '<div class="modal-section"><h4>点信息</h4>';
  1378. html += '<p><strong>' + escapeHtml(pointName) + '</strong></p>';
  1379. if (point['描述']) {
  1380. html += '<p>' + escapeHtml(point['描述']) + '</p>';
  1381. }
  1382. html += '</div>';
  1383. // 显示提取的特征
  1384. const features = point['提取的特征'] || [];
  1385. if (features.length > 0) {
  1386. html += '<div class="modal-section"><h4>提取的特征</h4><ul class="modal-list">';
  1387. features.forEach(feature => {
  1388. html += '<li class="modal-list-item">';
  1389. html += '<strong>' + escapeHtml(feature['特征名称'] || '') + '</strong>';
  1390. html += '<p>分类: ' + escapeHtml(feature['一级分类'] || '') + ' / ' + escapeHtml(feature['二级分类'] || '') + '</p>';
  1391. html += '<p>权重: ' + feature['权重'] + '</p>';
  1392. html += '</li>';
  1393. });
  1394. html += '</ul></div>';
  1395. }
  1396. // 显示帖子信息
  1397. const postData = postCache[postId];
  1398. if (postData) {
  1399. html += '<div class="modal-section"><h4>帖子信息</h4>';
  1400. if (postData.title) {
  1401. html += '<p><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
  1402. }
  1403. const images = postData.images || [];
  1404. if (images.length > 0) {
  1405. html += '<div class="post-images">';
  1406. images.forEach((img, idx) => {
  1407. html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
  1408. });
  1409. html += '</div>';
  1410. }
  1411. html += '</div>';
  1412. }
  1413. showModal(pointName, html);
  1414. }
  1415. function showFeatureDetail(featureName, parentId) {
  1416. const parentData = findNodeData(parentId);
  1417. if (!parentData) {
  1418. console.error('无法找到父节点数据:', parentId);
  1419. return;
  1420. }
  1421. const features = parentData['特征列表'] || [];
  1422. const matchedFeatures = features.filter(f => f['特征名称'] === featureName);
  1423. if (matchedFeatures.length === 0) {
  1424. showModal(featureName, '<p>未找到特征信息</p>');
  1425. return;
  1426. }
  1427. // 收集所有相关的帖子ID(去重)
  1428. const uniquePostIds = [];
  1429. const postIdSet = new Set();
  1430. matchedFeatures.forEach(feature => {
  1431. const postId = feature['帖子id'];
  1432. if (postId && postCache[postId] && !postIdSet.has(postId)) {
  1433. postIdSet.add(postId);
  1434. uniquePostIds.push(postId);
  1435. }
  1436. });
  1437. if (uniquePostIds.length === 0) {
  1438. showModal(featureName, '<p>未找到相关帖子</p>');
  1439. return;
  1440. }
  1441. let html = '';
  1442. // 如果有多个帖子,生成TAB切换界面
  1443. if (uniquePostIds.length > 1) {
  1444. html += '<div class="modal-tabs-header">';
  1445. uniquePostIds.forEach((postId, index) => {
  1446. const postData = postCache[postId];
  1447. const tabTitle = postData.title ? postData.title.substring(0, 15) + '...' : 'Post ' + (index + 1);
  1448. const activeClass = index === 0 ? ' active' : '';
  1449. html += '<button class="modal-tab-button' + activeClass + '" data-tab-id="post-tab-' + index + '">';
  1450. html += escapeHtml(tabTitle);
  1451. html += '</button>';
  1452. });
  1453. html += '</div>';
  1454. }
  1455. // 为每个帖子生成完整信息
  1456. uniquePostIds.forEach((postId, index) => {
  1457. const postData = postCache[postId];
  1458. const activeClass = index === 0 ? ' active' : '';
  1459. html += '<div class="modal-tab-content' + activeClass + '" id="post-tab-' + index + '">';
  1460. // 特征基本信息
  1461. html += '<div class="modal-section"><h4>特征:' + escapeHtml(featureName) + '</h4>';
  1462. const postFeatures = matchedFeatures.filter(f => f['帖子id'] === postId);
  1463. if (postFeatures.length > 0) {
  1464. html += '<p>权重: ' + (postFeatures[0]['权重'] || 0) + '</p>';
  1465. html += '<p>来自点: ' + escapeHtml(postFeatures[0]['所属点'] || '') + '</p>';
  1466. if (postFeatures[0]['点描述']) {
  1467. html += '<p style="font-size:0.9rem; color:#666;">点描述: ' + escapeHtml(postFeatures[0]['点描述']) + '</p>';
  1468. }
  1469. }
  1470. html += '</div>';
  1471. // 帖子完整信息
  1472. html += '<div class="modal-section"><h4>帖子详情</h4>';
  1473. html += '<p><strong>帖子ID:</strong>' + escapeHtml(postId) + '</p>';
  1474. if (postData.title) {
  1475. html += '<p class="post-title"><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
  1476. }
  1477. // 图片
  1478. const images = postData.images || [];
  1479. if (images.length > 0) {
  1480. html += '<div class="post-images">';
  1481. images.forEach((img, idx) => {
  1482. html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
  1483. });
  1484. html += '</div>';
  1485. }
  1486. // 正文
  1487. if (postData.body_text) {
  1488. html += '<p class="post-body"><strong>正文:</strong></p>';
  1489. html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
  1490. }
  1491. // 帖子元信息
  1492. if (postData.publish_time) {
  1493. html += '<p class="post-meta">发布时间: ' + escapeHtml(postData.publish_time) + '</p>';
  1494. }
  1495. if (postData.like_count !== undefined) {
  1496. html += '<p class="post-meta">点赞: ' + postData.like_count + ' | 收藏: ' + (postData.collect_count || 0) + ' | 评论: ' + (postData.comment_count || 0) + '</p>';
  1497. }
  1498. html += '</div>';
  1499. // 该帖子的所有选题点
  1500. const xuantiPoints = xuantiPointMap[postId];
  1501. if (xuantiPoints) {
  1502. html += '<div class="modal-section"><h4>该帖子的所有选题点</h4>';
  1503. ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
  1504. const pts = xuantiPoints[type];
  1505. if (pts && pts.length > 0) {
  1506. const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
  1507. html += '<h5>' + type + '</h5><ul class="modal-list">';
  1508. pts.forEach(pt => {
  1509. html += '<li class="modal-list-item"><strong>' + escapeHtml(pt[field] || '') + '</strong>';
  1510. if (pt['描述']) html += '<p>' + escapeHtml(pt['描述']) + '</p>';
  1511. // 显示提取的特征
  1512. const ptFeatures = pt['提取的特征'] || [];
  1513. if (ptFeatures.length > 0) {
  1514. html += '<p style="font-size:0.85rem; margin-top:8px;"><strong>特征:</strong> ';
  1515. ptFeatures.forEach((f, idx) => {
  1516. if (idx > 0) html += ', ';
  1517. html += escapeHtml(f['特征名称'] || '');
  1518. });
  1519. html += '</p>';
  1520. }
  1521. html += '</li>';
  1522. });
  1523. html += '</ul>';
  1524. }
  1525. });
  1526. html += '</div>';
  1527. }
  1528. html += '</div>'; // 结束 modal-tab-content
  1529. });
  1530. showModal(featureName, html);
  1531. // 添加TAB切换事件监听
  1532. setTimeout(() => {
  1533. document.querySelectorAll('.modal-tab-button').forEach(btn => {
  1534. btn.addEventListener('click', function() {
  1535. const tabId = this.getAttribute('data-tab-id');
  1536. // 移除所有active状态
  1537. document.querySelectorAll('.modal-tab-button').forEach(b => b.classList.remove('active'));
  1538. document.querySelectorAll('.modal-tab-content').forEach(c => c.classList.remove('active'));
  1539. // 激活选中的tab
  1540. this.classList.add('active');
  1541. document.getElementById(tabId).classList.add('active');
  1542. });
  1543. });
  1544. }, 100);
  1545. }
  1546. document.body.addEventListener('click', function(e) {
  1547. const target = e.target.closest('[data-action]');
  1548. if (!target) {
  1549. if (!e.target.closest('.modal-content')) closeModal();
  1550. return;
  1551. }
  1552. const action = target.getAttribute('data-action');
  1553. const nodeId = target.getAttribute('data-node-id');
  1554. switch(action) {
  1555. case 'toggle-node':
  1556. toggleNode(nodeId, target);
  1557. break;
  1558. case 'toggle-all-children':
  1559. toggleAllChildren(nodeId);
  1560. break;
  1561. case 'show-point-detail':
  1562. const parentId = target.getAttribute('data-parent-id');
  1563. showPointDetail(nodeId, parentId);
  1564. break;
  1565. case 'show-tab1-point-detail':
  1566. showTab1PointDetail(nodeId);
  1567. break;
  1568. case 'show-feature-detail':
  1569. const featureParentId = target.getAttribute('data-parent-id');
  1570. const featureName = target.getAttribute('data-feature-name');
  1571. showFeatureDetail(featureName, featureParentId);
  1572. break;
  1573. case 'show-classification-detail':
  1574. showClassificationDetail(nodeId);
  1575. break;
  1576. case 'show-post':
  1577. const postId = target.getAttribute('data-post-id');
  1578. if (postId) showPostDetail(postId);
  1579. break;
  1580. case 'close-modal':
  1581. closeModal();
  1582. break;
  1583. }
  1584. if (target.tagName === 'A') e.preventDefault();
  1585. });
  1586. // Tab切换事件监听
  1587. document.querySelectorAll('.tab-button').forEach(btn => {
  1588. btn.addEventListener('click', function() {
  1589. const tabId = this.getAttribute('data-tab');
  1590. switchTab(tabId);
  1591. });
  1592. });
  1593. // Tab3: 维度关联分析交互功能
  1594. let dimensionAssociations = ''' + json_module.dumps(self.dimension_associations, ensure_ascii=True) + ''';
  1595. let expandedOrthogonalCombinations = ''' + json_module.dumps(getattr(self, 'expanded_orthogonal_combinations', None), ensure_ascii=True) + ''';
  1596. let enrichedXuantiPointMap = ''' + json_module.dumps(getattr(self, 'enriched_xuanti_point_map', None), ensure_ascii=True) + ''';
  1597. let currentMode = 'single'; // 当前模式:single(单维度)或 three(三维正交)
  1598. // 模式切换
  1599. document.querySelectorAll('.mode-button').forEach(btn => {
  1600. btn.addEventListener('click', function() {
  1601. const mode = this.getAttribute('data-mode');
  1602. currentMode = mode;
  1603. // 更新按钮样式
  1604. document.querySelectorAll('.mode-button').forEach(b => {
  1605. if (b.getAttribute('data-mode') === mode) {
  1606. b.style.background = 'white';
  1607. b.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
  1608. b.classList.add('active');
  1609. } else {
  1610. b.style.background = 'transparent';
  1611. b.style.boxShadow = 'none';
  1612. b.classList.remove('active');
  1613. }
  1614. });
  1615. // 更新描述
  1616. document.getElementById('desc-single').style.display = mode === 'single' ? 'inline' : 'none';
  1617. document.getElementById('desc-three').style.display = mode === 'three' ? 'inline' : 'none';
  1618. // 切换布局显示
  1619. const singleLayout = document.getElementById('single-dim-layout');
  1620. const threeLayout = document.getElementById('three-dim-layout');
  1621. if (mode === 'single') {
  1622. singleLayout.style.display = 'grid';
  1623. threeLayout.style.display = 'none';
  1624. } else {
  1625. singleLayout.style.display = 'none';
  1626. threeLayout.style.display = 'block';
  1627. // 自动加载所有三维正交扩展结果
  1628. loadAllOrthogonalResults();
  1629. }
  1630. // 清除所有高亮和封面图
  1631. clearAllHighlights();
  1632. });
  1633. });
  1634. function clearAllHighlights() {
  1635. // 隐藏所有封面图容器
  1636. document.querySelectorAll('.tab3-thumbnails').forEach(t => {
  1637. t.style.display = 'none';
  1638. t.innerHTML = '';
  1639. });
  1640. // 清除所有高亮
  1641. document.querySelectorAll('.tab3-node-header').forEach(h => {
  1642. const originalBg = h.getAttribute('data-original-bg');
  1643. const originalBorder = h.getAttribute('data-original-border');
  1644. h.style.backgroundColor = originalBg || '';
  1645. h.style.border = originalBorder || '';
  1646. h.style.borderWidth = '';
  1647. h.style.boxShadow = '0 1px 2px rgba(0,0,0,0.05)';
  1648. h.style.fontWeight = '';
  1649. h.style.backgroundImage = '';
  1650. h.style.backgroundOrigin = '';
  1651. h.style.backgroundClip = '';
  1652. // 删除所有组号标签(包括容器)
  1653. const badges = h.querySelectorAll('.group-badge');
  1654. badges.forEach(b => b.remove());
  1655. // 删除Jaccard相似度标签
  1656. const jaccardBadges = h.querySelectorAll('.jaccard-badge');
  1657. jaccardBadges.forEach(b => b.remove());
  1658. // 删除组号标签容器(如果是独立的span)
  1659. const badgeContainers = Array.from(h.children).filter(child =>
  1660. child.tagName === 'SPAN' && child.style.display === 'inline-flex'
  1661. );
  1662. badgeContainers.forEach(bc => bc.remove());
  1663. });
  1664. }
  1665. // Tab3树节点切换
  1666. document.addEventListener('click', function(e) {
  1667. const toggleBtn = e.target.closest('.tab3-toggle');
  1668. if (toggleBtn) {
  1669. const targetId = toggleBtn.getAttribute('data-target');
  1670. const content = document.getElementById(targetId);
  1671. if (content) {
  1672. if (content.style.display === 'none' || content.style.display === '') {
  1673. content.style.display = 'block';
  1674. toggleBtn.textContent = '▼';
  1675. } else {
  1676. content.style.display = 'none';
  1677. toggleBtn.textContent = '▶';
  1678. }
  1679. }
  1680. e.stopPropagation();
  1681. }
  1682. });
  1683. // Tab3节点点击高亮关联分类
  1684. document.addEventListener('click', function(e) {
  1685. const header = e.target.closest('.tab3-node-header');
  1686. if (header && !e.target.closest('.tab3-toggle')) {
  1687. const clickedDimension = header.getAttribute('data-dimension');
  1688. const clickedPath = header.getAttribute('data-path');
  1689. const nodeId = header.getAttribute('data-node-id');
  1690. console.log('点击了分类:', clickedDimension, clickedPath, '模式:', currentMode);
  1691. // 清除所有高亮和封面图
  1692. clearAllHighlights();
  1693. // 高亮当前点击的节点 - 使用蓝色
  1694. header.style.borderColor = '#2196F3';
  1695. header.style.borderWidth = '3px';
  1696. header.style.boxShadow = '0 0 12px rgba(33, 150, 243, 0.6)';
  1697. header.style.fontWeight = '600';
  1698. // 显示该分类下的帖子封面图
  1699. showCategoryThumbnails(clickedDimension, clickedPath, nodeId);
  1700. // 根据模式执行不同的关联查找
  1701. if (currentMode === 'single') {
  1702. // 单维度关联模式
  1703. highlightSingleDimensionAssociations(clickedDimension, clickedPath);
  1704. } else if (currentMode === 'three') {
  1705. // 三维正交关联模式
  1706. if (clickedDimension === '灵感点列表') {
  1707. highlightThreeDimensionAssociations(clickedPath);
  1708. } else {
  1709. alert('三维正交关联模式只支持点击灵感点分类');
  1710. }
  1711. }
  1712. }
  1713. });
  1714. function highlightSingleDimensionAssociations(sourceDimension, sourcePath) {
  1715. if (!dimensionAssociations || !dimensionAssociations['单维度关联分析']) {
  1716. console.log('dimensionAssociations数据不存在');
  1717. return;
  1718. }
  1719. const singleDimAssociations = dimensionAssociations['单维度关联分析'];
  1720. // 维度名称映射:从data-dimension的值映射到JSON中的维度名称
  1721. const dimensionNameMap = {
  1722. '灵感点列表': '灵感点维度',
  1723. '目的点': '目的点维度',
  1724. '关键点列表': '关键点维度'
  1725. };
  1726. const sourceDimensionKey = dimensionNameMap[sourceDimension];
  1727. if (!sourceDimensionKey || !singleDimAssociations[sourceDimensionKey]) {
  1728. console.log('未找到维度:', sourceDimension, sourceDimensionKey);
  1729. return;
  1730. }
  1731. const dimensionData = singleDimAssociations[sourceDimensionKey];
  1732. console.log('查找关联关系,维度:', sourceDimensionKey, '路径:', sourcePath);
  1733. let foundCount = 0;
  1734. // 遍历该维度下的所有关联关系(如 灵感点→目的点、灵感点→关键点)
  1735. for (const relationKey in dimensionData) {
  1736. if (relationKey === '说明') continue;
  1737. const relationData = dimensionData[relationKey];
  1738. // 在该关联关系中查找匹配sourcePath的分类
  1739. if (relationData[sourcePath]) {
  1740. const categoryData = relationData[sourcePath];
  1741. console.log('找到关联关系:', relationKey, '分类:', sourcePath);
  1742. // 确定目标维度和关联字段名
  1743. let targetDimension, associationFieldName;
  1744. if (relationKey.includes('→目的点')) {
  1745. targetDimension = '目的点';
  1746. associationFieldName = '与目的点的关联';
  1747. } else if (relationKey.includes('→关键点')) {
  1748. targetDimension = '关键点列表';
  1749. associationFieldName = '与关键点的关联';
  1750. } else if (relationKey.includes('→灵感点')) {
  1751. targetDimension = '灵感点列表';
  1752. associationFieldName = '与灵感点的关联';
  1753. }
  1754. // 获取关联列表并高亮所有目标分类
  1755. const associations = categoryData[associationFieldName] || [];
  1756. console.log('关联数量:', associations.length);
  1757. associations.forEach(assoc => {
  1758. const targetPath = assoc['目标分类'];
  1759. const commonPostIds = assoc['共同帖子ID'] || [];
  1760. const jaccardSimilarity = assoc['Jaccard相似度'] || 0;
  1761. if (targetPath) {
  1762. console.log('高亮目标:', targetDimension, targetPath, '共同帖子数:', commonPostIds.length, 'Jaccard:', jaccardSimilarity);
  1763. highlightNodeByPath(targetDimension, targetPath, commonPostIds, jaccardSimilarity);
  1764. foundCount++;
  1765. }
  1766. });
  1767. }
  1768. }
  1769. if (foundCount === 0) {
  1770. console.log('未找到任何关联');
  1771. } else {
  1772. console.log('共高亮', foundCount, '个关联分类');
  1773. }
  1774. }
  1775. function highlightNodeByPath(dimension, path, commonPostIds, jaccardSimilarity) {
  1776. // 根据维度选择不同的颜色
  1777. let borderColor, bgColor, boxShadowColor;
  1778. if (dimension === '灵感点列表') {
  1779. // 灵感点:紫色
  1780. borderColor = '#9C27B0';
  1781. bgColor = '#F3E5F5';
  1782. boxShadowColor = 'rgba(156, 39, 176, 0.7)';
  1783. } else if (dimension === '目的点') {
  1784. // 目的点:粉红色
  1785. borderColor = '#E91E63';
  1786. bgColor = '#FCE4EC';
  1787. boxShadowColor = 'rgba(233, 30, 99, 0.7)';
  1788. } else if (dimension === '关键点列表') {
  1789. // 关键点:橙色
  1790. borderColor = '#FF9800';
  1791. bgColor = '#FFF3E0';
  1792. boxShadowColor = 'rgba(255, 152, 0, 0.7)';
  1793. } else {
  1794. // 默认颜色
  1795. borderColor = '#E91E63';
  1796. bgColor = '#FCE4EC';
  1797. boxShadowColor = 'rgba(233, 30, 99, 0.7)';
  1798. }
  1799. const headers = document.querySelectorAll('.tab3-node-header');
  1800. headers.forEach(header => {
  1801. if (header.getAttribute('data-dimension') === dimension &&
  1802. header.getAttribute('data-path') === path) {
  1803. // 使用根据维度选择的颜色高亮关联节点
  1804. header.style.borderColor = borderColor;
  1805. header.style.borderWidth = '3px';
  1806. header.style.boxShadow = `0 0 16px ${boxShadowColor}`;
  1807. header.style.backgroundColor = bgColor;
  1808. header.style.fontWeight = '600';
  1809. // 添加Jaccard相似度标签
  1810. const existingBadge = header.querySelector('.jaccard-badge');
  1811. if (existingBadge) {
  1812. existingBadge.remove();
  1813. }
  1814. if (jaccardSimilarity !== undefined && jaccardSimilarity !== null) {
  1815. const badge = document.createElement('span');
  1816. badge.className = 'jaccard-badge';
  1817. badge.textContent = `J: ${(jaccardSimilarity * 100).toFixed(1)}%`;
  1818. badge.style.cssText = `
  1819. display: inline-block;
  1820. background: ${borderColor};
  1821. color: white;
  1822. padding: 2px 6px;
  1823. border-radius: 8px;
  1824. font-size: 0.7rem;
  1825. font-weight: 600;
  1826. margin-left: 8px;
  1827. vertical-align: middle;
  1828. `;
  1829. badge.title = `Jaccard相似度: ${(jaccardSimilarity * 100).toFixed(2)}%`;
  1830. // 插入到节点文本后面
  1831. const textNode = Array.from(header.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
  1832. if (textNode) {
  1833. textNode.after(badge);
  1834. } else {
  1835. header.appendChild(badge);
  1836. }
  1837. }
  1838. // 为关联的分类显示共同帖子的封面图
  1839. const nodeId = header.getAttribute('data-node-id');
  1840. if (nodeId && commonPostIds && commonPostIds.length > 0) {
  1841. showCategoryThumbnails(dimension, path, nodeId, commonPostIds);
  1842. }
  1843. }
  1844. });
  1845. }
  1846. // 生成唯一颜色的函数,使用HSL色彩空间
  1847. function generateUniqueColor(index, total) {
  1848. // 使用黄金角度(137.5°)分布色相,确保颜色尽可能分散
  1849. const hue = (index * 137.5) % 360;
  1850. // 饱和度在60%-90%之间变化
  1851. const saturation = 60 + (index % 4) * 10;
  1852. // 亮度在45%-55%之间变化,确保颜色既鲜艳又不刺眼
  1853. const lightness = 45 + (index % 3) * 5;
  1854. const borderColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
  1855. const bgColor = `hsl(${hue}, ${saturation}%, ${lightness + 40}%)`;
  1856. return { border: borderColor, bg: bgColor };
  1857. }
  1858. // 自动加载所有灵感点的三维正交扩展结果
  1859. function loadAllOrthogonalResults() {
  1860. if (!expandedOrthogonalCombinations || !expandedOrthogonalCombinations['聚类结果'] || !expandedOrthogonalCombinations['聚类结果']['聚类详情']) {
  1861. console.log('聚类结果数据不存在');
  1862. document.getElementById('orthogonal-results').innerHTML = `
  1863. <div style="text-align: center; padding: 60px 20px; color: #999;">
  1864. <p style="font-size: 1.1rem; margin-bottom: 10px;">未加载聚类结果数据</p>
  1865. </div>
  1866. `;
  1867. return;
  1868. }
  1869. const expandedResults = expandedOrthogonalCombinations['聚类结果']['聚类详情'];
  1870. console.log('加载所有聚类结果,共', expandedResults.length, '组');
  1871. const resultsContainer = document.getElementById('orthogonal-results');
  1872. let html = '';
  1873. html += `<div style="margin-bottom: 20px; padding: 16px; background: #f0f7ff; border-radius: 8px; border-left: 4px solid #667eea;">`;
  1874. html += `<h3 style="color: #667eea; margin: 0; font-size: 1.1rem;">🌟 三维正交扩展结果(共 ${expandedResults.length} 组)</h3>`;
  1875. html += `<p style="color: #666; font-size: 0.85rem; margin: 8px 0 0 0;">点击每组标题可快速展开/收起</p>`;
  1876. html += `</div>`;
  1877. // 遍历所有组
  1878. expandedResults.forEach((result, index) => {
  1879. // 新数据结构:result是聚类详情中的一项
  1880. const dimensionMerge = result['维度合并'];
  1881. const clusterSize = result['聚类大小'];
  1882. const coveragePosts = result['聚类覆盖帖子数'];
  1883. // 获取各维度的第一个叶子分类用于显示
  1884. const lingganLeafClasses = dimensionMerge['灵感点'].map(item => item['叶子分类']);
  1885. const mudiLeafClasses = dimensionMerge['目的点'].map(item => item['叶子分类']);
  1886. const guanjianLeafClasses = dimensionMerge['关键点'].map(item => item['叶子分类']);
  1887. const groupId = `group-${index}`;
  1888. // 组容器 - 添加点击展开/收起功能
  1889. html += `<div style="margin-bottom: 20px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">`;
  1890. // 组头部 - 可点击展开/收起,添加sticky定位
  1891. html += `<div onclick="toggleGroup('${groupId}')" style="position: sticky; top: 0; z-index: 100; background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 16px; cursor: pointer; display: flex; justify-content: space-between; align-items: center; transition: all 0.3s; box-shadow: 0 2px 8px rgba(0,0,0,0.15);">`;
  1892. html += `<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">`;
  1893. // 第一行:聚类号 + 大小 + 覆盖帖子数
  1894. html += `<div style="display: flex; align-items: center; gap: 12px;">`;
  1895. html += `<span style="background: rgba(255,255,255,0.2); padding: 6px 14px; border-radius: 16px; font-weight: 700; font-size: 0.95rem;">聚类 #${result['聚类ID']}</span>`;
  1896. html += `<span style="background: rgba(255,255,255,0.15); padding: 4px 10px; border-radius: 12px; font-size: 0.85rem;">组合数: ${clusterSize}</span>`;
  1897. html += `<span style="background: rgba(255,255,255,0.15); padding: 4px 10px; border-radius: 12px; font-size: 0.85rem;">覆盖帖子: ${coveragePosts}</span>`;
  1898. html += `</div>`;
  1899. // 第二行:半选题维度(显示前几个)
  1900. html += `<div style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; flex-wrap: wrap;">`;
  1901. html += `<span style="background: rgba(33, 150, 243, 0.3); padding: 3px 10px; border-radius: 4px;">💡 ${escapeHtml(lingganLeafClasses.slice(0, 2).join(', '))}${lingganLeafClasses.length > 2 ? '...' : ''}</span>`;
  1902. html += `<span style="color: rgba(255,255,255,0.7);">+</span>`;
  1903. html += `<span style="background: rgba(255, 152, 0, 0.3); padding: 3px 10px; border-radius: 4px;">🎯 ${escapeHtml(mudiLeafClasses.slice(0, 2).join(', '))}${mudiLeafClasses.length > 2 ? '...' : ''}</span>`;
  1904. html += `<span style="color: rgba(255,255,255,0.7);">+</span>`;
  1905. html += `<span style="background: rgba(156, 39, 176, 0.3); padding: 3px 10px; border-radius: 4px;">🔑 ${escapeHtml(guanjianLeafClasses.slice(0, 2).join(', '))}${guanjianLeafClasses.length > 2 ? '...' : ''}</span>`;
  1906. html += `</div>`;
  1907. html += `</div>`;
  1908. html += `<div id="${groupId}-icon" style="font-size: 1.2rem; transition: transform 0.3s; flex-shrink: 0;">▼</div>`;
  1909. html += `</div>`;
  1910. // 组内容 - 默认展开
  1911. html += `<div id="${groupId}" style="background: #f8f9fa; padding: 20px;">`;
  1912. // 调用原有的showOrthogonalResults逻辑,但嵌入到这里
  1913. html += generateGroupContent(result, index);
  1914. html += `</div>`;
  1915. html += `</div>`;
  1916. });
  1917. resultsContainer.innerHTML = html;
  1918. }
  1919. // 展开/收起组的函数
  1920. window.toggleGroup = function(groupId) {
  1921. const content = document.getElementById(groupId);
  1922. const icon = document.getElementById(groupId + '-icon');
  1923. if (content.style.display === 'none') {
  1924. content.style.display = 'block';
  1925. icon.style.transform = 'rotate(0deg)';
  1926. icon.textContent = '▼';
  1927. } else {
  1928. content.style.display = 'none';
  1929. icon.style.transform = 'rotate(-90deg)';
  1930. icon.textContent = '▶';
  1931. }
  1932. };
  1933. // 生成单组内容的函数
  1934. function generateGroupContent(result, index) {
  1935. // 新数据结构:result是聚类详情中的一项
  1936. const dimensionMerge = result['维度合并'];
  1937. const dimensionExpansion = result['维度扩展'];
  1938. let html = '';
  1939. // 扩展结果 - 表格式
  1940. const expandId = `expand-${index}`;
  1941. html += `<div style="background: white; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid #4caf50;">`;
  1942. // 计算各维度的扩展组合总数
  1943. const lingganExpandList = dimensionExpansion ? dimensionExpansion['灵感点'] : [];
  1944. const mudiExpandList = dimensionExpansion ? dimensionExpansion['目的点'] : [];
  1945. const guanjianExpandList = dimensionExpansion ? dimensionExpansion['关键点'] : [];
  1946. // 获取各维度的组合数(累加所有叶子分类的扩展组合数)
  1947. let lingganCount = 0;
  1948. if (lingganExpandList && lingganExpandList.length > 0) {
  1949. lingganExpandList.forEach(item => {
  1950. lingganCount += (item['扩展组合数'] || 0);
  1951. });
  1952. }
  1953. let mudiCount = 0;
  1954. if (mudiExpandList && mudiExpandList.length > 0) {
  1955. mudiExpandList.forEach(item => {
  1956. mudiCount += (item['扩展组合数'] || 0);
  1957. });
  1958. }
  1959. let guanjianTotalCount = 0;
  1960. if (guanjianExpandList && guanjianExpandList.length > 0) {
  1961. guanjianExpandList.forEach(item => {
  1962. guanjianTotalCount += (item['扩展组合数'] || 0);
  1963. });
  1964. }
  1965. // 计算总的扩展组合数(不是笛卡尔积,是各维度的总和)
  1966. const totalCombinations = lingganCount + mudiCount + guanjianTotalCount;
  1967. html += `<div style="display: flex; justify-content: space-between; align-items: center; cursor: pointer;" onclick="document.getElementById('${expandId}').style.display = document.getElementById('${expandId}').style.display === 'none' ? 'block' : 'none';">`;
  1968. html += `<h4 style="color: #4caf50; margin: 0; font-size: 0.95rem;">🌟 半选题维度的点模式映射(共 ${totalCombinations} 个扩展组合)</h4>`;
  1969. html += `<span style="color: #999; font-size: 0.85rem;">点击展开/收起</span>`;
  1970. html += `</div>`;
  1971. html += `<div id="${expandId}" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e0e0e0;">`;
  1972. // 获取组合详情,为每个叶子分类保存其组合列表(新数据结构:维度扩展是数组)
  1973. const lingganCombosList = [];
  1974. const lingganMergeInfo = [];
  1975. if (lingganExpandList && lingganExpandList.length > 0) {
  1976. lingganExpandList.forEach(item => {
  1977. const combos = item['组合详情'] || [];
  1978. // 过滤:只保留包含扩展叶子分类的组合(叶子分类组合长度>1表示有扩展)
  1979. const filteredCombos = combos.filter(combo => {
  1980. const leafCombo = combo['叶子分类组合'] || [];
  1981. return leafCombo.length > 1; // 只有一个叶子分类表示没有扩展
  1982. });
  1983. lingganCombosList.push({
  1984. leafClass: item['叶子分类'],
  1985. combos: filteredCombos
  1986. });
  1987. lingganMergeInfo.push({
  1988. leafClass: item['叶子分类'],
  1989. frequency: item['出现频率']
  1990. });
  1991. });
  1992. }
  1993. const mudiCombosList = [];
  1994. const mudiMergeInfo = [];
  1995. if (mudiExpandList && mudiExpandList.length > 0) {
  1996. mudiExpandList.forEach(item => {
  1997. const combos = item['组合详情'] || [];
  1998. const filteredCombos = combos.filter(combo => {
  1999. const leafCombo = combo['叶子分类组合'] || [];
  2000. return leafCombo.length > 1;
  2001. });
  2002. mudiCombosList.push({
  2003. leafClass: item['叶子分类'],
  2004. combos: filteredCombos
  2005. });
  2006. mudiMergeInfo.push({
  2007. leafClass: item['叶子分类'],
  2008. frequency: item['出现频率']
  2009. });
  2010. });
  2011. }
  2012. const guanjianCombosList = [];
  2013. const guanjianMergeInfo = [];
  2014. if (guanjianExpandList && guanjianExpandList.length > 0) {
  2015. guanjianExpandList.forEach(item => {
  2016. const combos = item['组合详情'] || [];
  2017. const filteredCombos = combos.filter(combo => {
  2018. const leafCombo = combo['叶子分类组合'] || [];
  2019. return leafCombo.length > 1;
  2020. });
  2021. guanjianCombosList.push({
  2022. keyLeafClass: item['叶子分类'],
  2023. combos: filteredCombos
  2024. });
  2025. guanjianMergeInfo.push({
  2026. leafClass: item['叶子分类'],
  2027. frequency: item['出现频率']
  2028. });
  2029. });
  2030. }
  2031. // 计算最大行数(所有叶子分类的扩展组合数的最大值)
  2032. let maxRows = 0;
  2033. lingganCombosList.forEach(item => {
  2034. maxRows = Math.max(maxRows, item.combos.length);
  2035. });
  2036. mudiCombosList.forEach(item => {
  2037. maxRows = Math.max(maxRows, item.combos.length);
  2038. });
  2039. guanjianCombosList.forEach(item => {
  2040. maxRows = Math.max(maxRows, item.combos.length);
  2041. });
  2042. // 计算表格总列数:标签列 + 灵感点列数 + 目的点列数 + 关键点列数
  2043. const lingganColCount = lingganMergeInfo.length || 1;
  2044. const mudiColCount = mudiMergeInfo.length || 1;
  2045. const guanjianColCount = guanjianMergeInfo.length || 1;
  2046. const totalColumns = 1 + lingganColCount + mudiColCount + guanjianColCount;
  2047. html += `<div style="overflow-x: auto;">`;
  2048. html += `<table style="width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden;">`;
  2049. // 表头
  2050. html += `<thead>`;
  2051. html += `<tr style="background: #667eea; color: white;">`;
  2052. html += `<th style="padding: 12px; text-align: left; font-size: 0.85rem; border-right: 1px solid rgba(255,255,255,0.2);">半选题:</th>`;
  2053. // 灵感点表头 - 跨多列
  2054. html += `<th colspan="${lingganColCount}" style="padding: 12px; text-align: center; font-size: 0.85rem; border-right: 1px solid rgba(255,255,255,0.2);">💡 灵感点</th>`;
  2055. // 目的点表头 - 跨多列
  2056. html += `<th colspan="${mudiColCount}" style="padding: 12px; text-align: center; font-size: 0.85rem; border-right: 1px solid rgba(255,255,255,0.2);">🎯 目的点</th>`;
  2057. // 关键点表头 - 跨多列
  2058. html += `<th colspan="${guanjianColCount}" style="padding: 12px; text-align: center; font-size: 0.85rem;">🔑 关键点</th>`;
  2059. html += `</tr>`;
  2060. // 半选题值行(显示叶子分类名称)
  2061. html += `<tr style="background: #f5f5f5; font-weight: 600;">`;
  2062. html += `<td style="padding: 8px; text-align: center; font-size: 0.75rem; color: #999; border-right: 1px solid #e0e0e0;">半选题</td>`;
  2063. // 灵感点叶子分类 - 每个占一列
  2064. lingganMergeInfo.forEach((info, idx) => {
  2065. html += `<td style="padding: 10px; text-align: center; ${idx < lingganMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2066. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
  2067. html += `</td>`;
  2068. });
  2069. // 目的点叶子分类 - 每个占一列
  2070. mudiMergeInfo.forEach((info, idx) => {
  2071. html += `<td style="padding: 10px; text-align: center; ${idx < mudiMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2072. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
  2073. html += `</td>`;
  2074. });
  2075. // 关键点叶子分类 - 每个占一列
  2076. guanjianMergeInfo.forEach((info, idx) => {
  2077. html += `<td style="padding: 10px; text-align: center; ${idx < guanjianMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
  2078. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
  2079. html += `</td>`;
  2080. });
  2081. html += `</tr>`;
  2082. // 频率行(显示每个叶子分类的出现频率)
  2083. html += `<tr style="background: #fafafa;">`;
  2084. html += `<td style="padding: 8px; text-align: center; font-size: 0.75rem; color: #999; border-right: 1px solid #e0e0e0; border-bottom: 2px solid #667eea;">出现频率</td>`;
  2085. // 灵感点频率
  2086. lingganMergeInfo.forEach((info, idx) => {
  2087. html += `<td style="padding: 8px; text-align: center; ${idx < lingganMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'} border-bottom: 2px solid #667eea;">`;
  2088. html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
  2089. html += `</td>`;
  2090. });
  2091. // 目的点频率
  2092. mudiMergeInfo.forEach((info, idx) => {
  2093. html += `<td style="padding: 8px; text-align: center; ${idx < mudiMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'} border-bottom: 2px solid #667eea;">`;
  2094. html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
  2095. html += `</td>`;
  2096. });
  2097. // 关键点频率
  2098. guanjianMergeInfo.forEach((info, idx) => {
  2099. html += `<td style="padding: 8px; text-align: center; ${idx < guanjianMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''} border-bottom: 2px solid #667eea;">`;
  2100. html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
  2101. html += `</td>`;
  2102. });
  2103. html += `</tr>`;
  2104. // 箭头行
  2105. html += `<tr style="background: white;">`;
  2106. html += `<td style="padding: 8px; text-align: center; font-size: 1.2rem; color: #667eea; border-right: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0;">↓</td>`;
  2107. // 灵感点箭头 - 每列一个箭头
  2108. lingganMergeInfo.forEach((info, idx) => {
  2109. html += `<td style="padding: 8px; text-align: center; font-size: 1.2rem; color: #2196f3; ${idx < lingganMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'} border-bottom: 1px solid #e0e0e0;">↓</td>`;
  2110. });
  2111. // 目的点箭头 - 每列一个箭头
  2112. mudiMergeInfo.forEach((info, idx) => {
  2113. html += `<td style="padding: 8px; text-align: center; font-size: 1.2rem; color: #ff9800; ${idx < mudiMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'} border-bottom: 1px solid #e0e0e0;">↓</td>`;
  2114. });
  2115. // 关键点箭头 - 每列一个箭头
  2116. guanjianMergeInfo.forEach((info, idx) => {
  2117. html += `<td style="padding: 8px; text-align: center; font-size: 1.2rem; color: #9c27b0; ${idx < guanjianMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''} border-bottom: 1px solid #e0e0e0;">↓</td>`;
  2118. });
  2119. html += `</tr>`;
  2120. // 扩展标题行
  2121. html += `<tr style="background: #4caf50; color: white;">`;
  2122. html += `<th colspan="${totalColumns}" style="padding: 10px; text-align: left; font-size: 0.85rem;">🌟 点模式映射(仅显示扩展的叶子分类)</th>`;
  2123. html += `</tr>`;
  2124. html += `</thead>`;
  2125. html += `<tbody>`;
  2126. // 数据行 - 并列显示各维度的扩展组合(只显示扩展的叶子分类)
  2127. for (let rowIdx = 0; rowIdx < maxRows; rowIdx++) {
  2128. const rowBg = rowIdx % 2 === 0 ? '#fafafa' : 'white';
  2129. html += `<tr style="background: ${rowBg}; border-bottom: 1px solid #e0e0e0;">`;
  2130. html += `<td style="padding: 8px; text-align: center; font-size: 0.75rem; color: #999; font-weight: 600; border-right: 1px solid #e0e0e0;">#${rowIdx + 1}</td>`;
  2131. // 灵感点列 - 每个叶子分类占一列,只显示新增的扩展分类
  2132. lingganCombosList.forEach((lingganInfo, lingganIdx) => {
  2133. html += `<td style="padding: 8px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2134. if (rowIdx < lingganInfo.combos.length) {
  2135. const combo = lingganInfo.combos[rowIdx];
  2136. const leafCombo = combo['叶子分类组合'] || [];
  2137. // 过滤掉原始叶子分类,只显示新增的扩展分类
  2138. const newLeafs = leafCombo.filter(leaf => leaf !== lingganInfo.leafClass);
  2139. if (newLeafs.length > 0) {
  2140. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
  2141. newLeafs.forEach(leaf => {
  2142. html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
  2143. });
  2144. html += `</div>`;
  2145. } else {
  2146. html += `<span style="color: #ccc;">-</span>`;
  2147. }
  2148. } else {
  2149. html += `<span style="color: #ccc;">-</span>`;
  2150. }
  2151. html += `</td>`;
  2152. });
  2153. // 目的点列 - 每个叶子分类占一列,只显示新增的扩展分类
  2154. mudiCombosList.forEach((mudiInfo, mudiIdx) => {
  2155. html += `<td style="padding: 8px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2156. if (rowIdx < mudiInfo.combos.length) {
  2157. const combo = mudiInfo.combos[rowIdx];
  2158. const leafCombo = combo['叶子分类组合'] || [];
  2159. // 过滤掉原始叶子分类,只显示新增的扩展分类
  2160. const newLeafs = leafCombo.filter(leaf => leaf !== mudiInfo.leafClass);
  2161. if (newLeafs.length > 0) {
  2162. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
  2163. newLeafs.forEach(leaf => {
  2164. html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
  2165. });
  2166. html += `</div>`;
  2167. } else {
  2168. html += `<span style="color: #ccc;">-</span>`;
  2169. }
  2170. } else {
  2171. html += `<span style="color: #ccc;">-</span>`;
  2172. }
  2173. html += `</td>`;
  2174. });
  2175. // 关键点列 - 每个叶子分类占一列,只显示新增的扩展分类
  2176. guanjianCombosList.forEach((keyInfo, keyIdx) => {
  2177. html += `<td style="padding: 8px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
  2178. if (rowIdx < keyInfo.combos.length) {
  2179. const guanjianCombo = keyInfo.combos[rowIdx];
  2180. const leafCombo = guanjianCombo['叶子分类组合'] || [];
  2181. // 过滤掉原始叶子分类,只显示新增的扩展分类
  2182. const newLeafs = leafCombo.filter(leaf => leaf !== keyInfo.keyLeafClass);
  2183. if (newLeafs.length > 0) {
  2184. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
  2185. newLeafs.forEach(leaf => {
  2186. html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
  2187. });
  2188. html += `</div>`;
  2189. } else {
  2190. html += `<span style="color: #ccc;">-</span>`;
  2191. }
  2192. } else {
  2193. html += `<span style="color: #ccc;">-</span>`;
  2194. }
  2195. html += `</td>`;
  2196. });
  2197. html += `</tr>`;
  2198. }
  2199. // 组合分类合并行
  2200. html += `<tr style="background: #e8f5e9; border-top: 2px solid #4caf50;">`;
  2201. html += `<td style="padding: 10px; text-align: center; font-size: 0.8rem; font-weight: 600; color: #2e7d32; border-right: 1px solid #e0e0e0;">组合分类合并</td>`;
  2202. // 灵感点扩展分类合并 - 每个叶子分类占一列
  2203. lingganCombosList.forEach((lingganInfo, lingganIdx) => {
  2204. html += `<td style="padding: 10px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2205. const item = lingganExpandList.find(x => x['叶子分类'] === lingganInfo.leafClass);
  2206. const merged = item ? item['扩展分类合并'] : '';
  2207. if (merged) {
  2208. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 5px 12px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; display: inline-block;">${escapeHtml(merged)}</span>`;
  2209. } else {
  2210. html += `<span style="color: #ccc;">-</span>`;
  2211. }
  2212. html += `</td>`;
  2213. });
  2214. // 目的点扩展分类合并 - 每个叶子分类占一列
  2215. mudiCombosList.forEach((mudiInfo, mudiIdx) => {
  2216. html += `<td style="padding: 10px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2217. const item = mudiExpandList.find(x => x['叶子分类'] === mudiInfo.leafClass);
  2218. const merged = item ? item['扩展分类合并'] : '';
  2219. if (merged) {
  2220. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 5px 12px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; display: inline-block;">${escapeHtml(merged)}</span>`;
  2221. } else {
  2222. html += `<span style="color: #ccc;">-</span>`;
  2223. }
  2224. html += `</td>`;
  2225. });
  2226. // 关键点扩展分类合并 - 每个叶子分类占一列
  2227. guanjianCombosList.forEach((keyInfo, keyIdx) => {
  2228. html += `<td style="padding: 10px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
  2229. const item = guanjianExpandList.find(x => x['叶子分类'] === keyInfo.keyLeafClass);
  2230. const merged = item ? item['扩展分类合并'] : '';
  2231. if (merged) {
  2232. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 5px 12px; border-radius: 4px; font-size: 0.8rem; font-weight: 600; display: inline-block;">${escapeHtml(merged)}</span>`;
  2233. } else {
  2234. html += `<span style="color: #ccc;">-</span>`;
  2235. }
  2236. html += `</td>`;
  2237. });
  2238. html += `</tr>`;
  2239. // 完整选题结果行
  2240. html += `<tr style="background: #f3e5f5; border-top: 2px solid #9c27b0;">`;
  2241. html += `<td style="padding: 10px; text-align: center; font-size: 0.8rem; font-weight: 600; color: #7b1fa2; border-right: 1px solid #e0e0e0;">🎉 完整选题结果</td>`;
  2242. // 灵感点完整选题 - 每个叶子分类占一列
  2243. lingganCombosList.forEach((lingganInfo, lingganIdx) => {
  2244. html += `<td style="padding: 10px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2245. const item = lingganExpandList.find(x => x['叶子分类'] === lingganInfo.leafClass);
  2246. const merged = item ? item['扩展分类合并'] : '';
  2247. // 使用flex布局让内容更紧凑
  2248. html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
  2249. // 显示原始叶子分类 - 使用蓝色(与半选题对应)
  2250. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(lingganInfo.leafClass)}</span>`;
  2251. // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
  2252. if (merged) {
  2253. html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
  2254. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(merged)}</span>`;
  2255. }
  2256. html += `</div>`;
  2257. html += `</td>`;
  2258. });
  2259. // 目的点完整选题 - 每个叶子分类占一列
  2260. mudiCombosList.forEach((mudiInfo, mudiIdx) => {
  2261. html += `<td style="padding: 10px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
  2262. const item = mudiExpandList.find(x => x['叶子分类'] === mudiInfo.leafClass);
  2263. const merged = item ? item['扩展分类合并'] : '';
  2264. // 使用flex布局让内容更紧凑
  2265. html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
  2266. // 显示原始叶子分类 - 使用蓝色(与半选题对应)
  2267. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(mudiInfo.leafClass)}</span>`;
  2268. // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
  2269. if (merged) {
  2270. html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
  2271. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(merged)}</span>`;
  2272. }
  2273. html += `</div>`;
  2274. html += `</td>`;
  2275. });
  2276. // 关键点完整选题 - 每个叶子分类占一列
  2277. guanjianCombosList.forEach((keyInfo, keyIdx) => {
  2278. html += `<td style="padding: 10px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
  2279. const item = guanjianExpandList.find(x => x['叶子分类'] === keyInfo.keyLeafClass);
  2280. const merged = item ? item['扩展分类合并'] : '';
  2281. // 使用flex布局让内容更紧凑
  2282. html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
  2283. // 显示原始叶子分类 - 使用蓝色(与半选题对应)
  2284. html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(keyInfo.keyLeafClass)}</span>`;
  2285. // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
  2286. if (merged) {
  2287. html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
  2288. html += `<span style="background: #c8e6c9; color: #2e7d32; padding: 4px 10px; border-radius: 4px; font-size: 0.75rem; font-weight: 600; white-space: nowrap;">${escapeHtml(merged)}</span>`;
  2289. }
  2290. html += `</div>`;
  2291. html += `</td>`;
  2292. });
  2293. html += `</tr>`;
  2294. html += `</tbody>`;
  2295. html += `</table>`;
  2296. html += `</div>`;
  2297. html += `</div>`;
  2298. html += `</div>`;
  2299. // 共同帖子详情 - 从聚类结果中获取
  2300. const commonPostIds = result['聚类覆盖帖子ID'] || [];
  2301. if (commonPostIds.length > 0) {
  2302. html += `<div style="background: white; padding: 16px; border-radius: 8px; border-left: 4px solid #ff9800;">`;
  2303. html += `<h4 style="color: #ff9800; margin: 0 0 12px 0; font-size: 0.95rem;">📝 共同帖子详情 (${commonPostIds.length}个)</h4>`;
  2304. commonPostIds.forEach(postId => {
  2305. const postData = enrichedXuantiPointMap ? enrichedXuantiPointMap[postId] : null;
  2306. const cachedPostData = postCache[postId];
  2307. if (postData) {
  2308. html += `<div style="margin-bottom: 12px; padding: 12px; background: #f5f5f5; border-radius: 6px; border-left: 3px solid #ff9800; display: flex; gap: 12px;">`;
  2309. // 显示第一张图片(如果有)
  2310. if (cachedPostData && cachedPostData.images && cachedPostData.images.length > 0) {
  2311. html += `<img src="${escapeHtml(cachedPostData.images[0])}"
  2312. alt="${escapeHtml(postData['选题'] || '帖子')}"
  2313. title="点击查看详情"
  2314. onclick="window.showPostDetail('${postId}');"
  2315. style="width: 100px; height: 100px; object-fit: cover; border-radius: 6px; cursor: pointer; flex-shrink: 0;
  2316. transition: all 0.2s; border: 2px solid #ff9800;"
  2317. onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)';"
  2318. onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='none';">`;
  2319. }
  2320. // 右侧信息区域
  2321. html += `<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;">`;
  2322. // 标题、选题描述和ID
  2323. html += `<div>`;
  2324. html += `<div style="font-weight: 600; color: #333; font-size: 0.9rem; cursor: pointer; margin-bottom: 5px;" onclick="window.showPostDetail('${postId}');" title="点击查看详情">${escapeHtml(postData['选题'] || '未命名选题')}</div>`;
  2325. if (postData['选题描述']) {
  2326. html += `<div style="color: #666; font-size: 0.75rem; line-height: 1.4; margin-bottom: 4px;">${escapeHtml(postData['选题描述'])}</div>`;
  2327. }
  2328. html += `<div style="color: #999; font-size: 0.7rem;">ID: ${escapeHtml(postId)}</div>`;
  2329. html += `</div>`;
  2330. // 显示灵感点、目的点、关键点详情(优化布局)
  2331. // 灵感点
  2332. const lingganList = postData['灵感点列表'] || [];
  2333. if (lingganList.length > 0) {
  2334. html += `<div style="padding: 8px; background: #e8f5e9; border-radius: 4px; border-left: 3px solid #2196f3;">`;
  2335. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
  2336. html += `<span style="font-weight: 700; color: #1565c0; font-size: 0.75rem; margin-right: 4px;">💡 灵感点</span>`;
  2337. lingganList.forEach(point => {
  2338. html += `<span style="background: #1565c0; color: white; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-decoration: underline;">${escapeHtml(point['灵感点'] || '')}</span>`;
  2339. // 显示该灵感点的特征和叶子分类
  2340. const features = point['提取的特征'] || [];
  2341. features.forEach(f => {
  2342. html += `<span style="background: white; color: #1976d2; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #bbdefb;">`;
  2343. html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
  2344. html += `</span>`;
  2345. });
  2346. });
  2347. html += `</div>`;
  2348. html += `</div>`;
  2349. }
  2350. // 目的点
  2351. const mudiList = postData['目的点'] || [];
  2352. if (mudiList.length > 0) {
  2353. html += `<div style="padding: 8px; background: #fff8e1; border-radius: 4px; border-left: 3px solid #ff9800;">`;
  2354. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
  2355. html += `<span style="font-weight: 700; color: #ef6c00; font-size: 0.75rem; margin-right: 4px;">🎯 目的点</span>`;
  2356. mudiList.forEach(point => {
  2357. html += `<span style="background: #ef6c00; color: white; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-decoration: underline;">${escapeHtml(point['目的点'] || '')}</span>`;
  2358. // 显示该目的点的特征和叶子分类
  2359. const features = point['提取的特征'] || [];
  2360. features.forEach(f => {
  2361. html += `<span style="background: white; color: #f57c00; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #ffe0b2;">`;
  2362. html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
  2363. html += `</span>`;
  2364. });
  2365. });
  2366. html += `</div>`;
  2367. html += `</div>`;
  2368. }
  2369. // 关键点
  2370. const guanjianList = postData['关键点列表'] || [];
  2371. if (guanjianList.length > 0) {
  2372. html += `<div style="padding: 8px; background: #f3e5f5; border-radius: 4px; border-left: 3px solid #9c27b0;">`;
  2373. html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
  2374. html += `<span style="font-weight: 700; color: #6a1b9a; font-size: 0.75rem; margin-right: 4px;">🔑 关键点</span>`;
  2375. guanjianList.forEach(point => {
  2376. html += `<span style="background: #6a1b9a; color: white; padding: 3px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 700; text-decoration: underline;">${escapeHtml(point['关键点'] || '')}</span>`;
  2377. // 显示该关键点的特征和叶子分类
  2378. const features = point['提取的特征'] || [];
  2379. features.forEach(f => {
  2380. html += `<span style="background: white; color: #7b1fa2; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #e1bee7;">`;
  2381. html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
  2382. html += `</span>`;
  2383. });
  2384. });
  2385. html += `</div>`;
  2386. html += `</div>`;
  2387. }
  2388. html += `</div>`; // 结束右侧信息区域
  2389. html += `</div>`;
  2390. } else if (cachedPostData && cachedPostData.images && cachedPostData.images.length > 0) {
  2391. // 如果没有enrichedXuantiPointMap数据,仅显示图片
  2392. html += `<img src="${escapeHtml(cachedPostData.images[0])}"
  2393. alt="${escapeHtml(cachedPostData.title || '帖子')}"
  2394. title="点击查看详情"
  2395. onclick="window.showPostDetail('${postId}');"
  2396. style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; cursor: pointer; margin-right: 8px; margin-bottom: 8px;
  2397. transition: all 0.2s; border: 2px solid #ff9800; display: inline-block;"
  2398. onmouseenter="this.style.transform='scale(1.1)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)';"
  2399. onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='none';">`;
  2400. }
  2401. });
  2402. html += `</div>`;
  2403. }
  2404. return html;
  2405. }
  2406. function showOrthogonalResults(lingganPath, matchedResults) {
  2407. const resultsContainer = document.getElementById('orthogonal-results');
  2408. // 设置标题
  2409. const lingganLeafClass = lingganPath.split('/').pop();
  2410. let html = '';
  2411. // 灵感点信息区域 - 滑动置顶
  2412. html += `<div style="position: sticky; top: -20px; background: white; z-index: 10; padding-bottom: 16px; margin: -20px -20px 16px -20px; padding: 20px 20px 16px 20px; border-bottom: 2px solid #e0e0e0;">`;
  2413. html += `<h3 style="color: #667eea; margin-bottom: 12px;">
  2414. 灵感点「${escapeHtml(lingganLeafClass)}」
  2415. <span style="color: #999; font-size: 0.9rem; font-weight: normal; margin-left: 10px;">(${matchedResults.length}组三维正交组合)</span>
  2416. </h3>`;
  2417. html += `</div>`;
  2418. // 遍历每个匹配结果
  2419. matchedResults.forEach((result, index) => {
  2420. html += `<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">`;
  2421. // 组号标题(显示聚类信息)
  2422. html += `<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px;">`;
  2423. html += `<span style="background: #667eea; color: white; padding: 6px 14px; border-radius: 16px; font-weight: 700; font-size: 0.95rem;">聚类 #${result['聚类ID']}</span>`;
  2424. html += `<span style="color: #666; font-size: 0.9rem;">组合数: ${result['聚类大小']}</span>`;
  2425. html += `<span style="color: #666; font-size: 0.9rem;">覆盖帖子: ${result['聚类覆盖帖子数']}</span>`;
  2426. html += `</div>`;
  2427. // 直接调用generateGroupContent生成表格内容
  2428. html += generateGroupContent(result, index);
  2429. html += `</div>`;
  2430. });
  2431. resultsContainer.innerHTML = html;
  2432. }
  2433. // 获取灵感点的所有帖子ID
  2434. function getLingganPostIds(lingganPath) {
  2435. const pathParts = lingganPath.split('/');
  2436. let categoryData = clusteredData['灵感点列表'];
  2437. // 逐级查找到目标分类
  2438. for (const part of pathParts) {
  2439. if (categoryData && categoryData[part]) {
  2440. categoryData = categoryData[part];
  2441. } else {
  2442. return [];
  2443. }
  2444. }
  2445. return categoryData['帖子列表'] || [];
  2446. }
  2447. function highlightThreeDimensionAssociations(lingganPath) {
  2448. if (!expandedOrthogonalCombinations || !expandedOrthogonalCombinations['聚类结果'] || !expandedOrthogonalCombinations['聚类结果']['聚类详情']) {
  2449. console.log('聚类结果数据不存在');
  2450. document.getElementById('orthogonal-results').innerHTML = `
  2451. <div style="text-align: center; padding: 60px 20px; color: #999;">
  2452. <p style="font-size: 1.1rem; margin-bottom: 10px;">未加载扩展正交组合数据</p>
  2453. </div>
  2454. `;
  2455. return;
  2456. }
  2457. const expandedResults = expandedOrthogonalCombinations['聚类结果']['聚类详情'];
  2458. // 从路径中提取叶子分类
  2459. const lingganLeafClass = lingganPath.split('/').pop();
  2460. console.log('查找灵感点叶子分类:', lingganLeafClass);
  2461. // 查找匹配的聚类结果(灵感点维度合并中包含该叶子分类)
  2462. const matchedResults = expandedResults.filter(result => {
  2463. const dimensionMerge = result['维度合并'];
  2464. if (!dimensionMerge || !dimensionMerge['灵感点']) return false;
  2465. return dimensionMerge['灵感点'].some(item => item['叶子分类'] === lingganLeafClass);
  2466. });
  2467. console.log('找到匹配的扩展结果数量:', matchedResults.length);
  2468. if (matchedResults.length === 0) {
  2469. document.getElementById('orthogonal-results').innerHTML = `
  2470. <div style="text-align: center; padding: 60px 20px; color: #999;">
  2471. <p style="font-size: 1.1rem; margin-bottom: 10px;">该灵感点没有三维正交关联</p>
  2472. <p style="font-size: 0.9rem;">(叶子分类: ${escapeHtml(lingganLeafClass)})</p>
  2473. </div>
  2474. `;
  2475. return;
  2476. }
  2477. // 显示正交关系卡片
  2478. showOrthogonalResults(lingganPath, matchedResults);
  2479. console.log('共显示', matchedResults.length, '组三维正交关联');
  2480. }
  2481. function highlightOrthogonalNodeMultiGroup(dimension, path, groups) {
  2482. const headers = document.querySelectorAll('.tab3-node-header');
  2483. headers.forEach(header => {
  2484. if (header.getAttribute('data-dimension') === dimension &&
  2485. header.getAttribute('data-path') === path) {
  2486. // 如果参与多个组,使用渐变边框
  2487. let borderStyle, bgColor, boxShadow;
  2488. if (groups.length === 1) {
  2489. // 只参与一个组,使用单一颜色
  2490. borderStyle = `3px solid ${groups[0].color.border}`;
  2491. bgColor = groups[0].color.bg;
  2492. boxShadow = `0 0 16px ${groups[0].color.border}99`;
  2493. } else {
  2494. // 参与多个组,使用渐变边框
  2495. const colors = groups.map(g => g.color.border).join(', ');
  2496. borderStyle = `3px solid transparent`;
  2497. header.style.backgroundImage = `linear-gradient(white, white), linear-gradient(90deg, ${colors})`;
  2498. header.style.backgroundOrigin = 'padding-box, border-box';
  2499. header.style.backgroundClip = 'padding-box, border-box';
  2500. // 背景色使用第一个组的颜色
  2501. bgColor = groups[0].color.bg;
  2502. // 组合所有组的阴影效果
  2503. boxShadow = groups.map((g, i) =>
  2504. `${i * 2}px ${i * 2}px ${12 + i * 2}px ${g.color.border}66`
  2505. ).join(', ');
  2506. }
  2507. header.style.border = borderStyle;
  2508. header.style.backgroundColor = bgColor;
  2509. header.style.boxShadow = boxShadow;
  2510. header.style.fontWeight = '600';
  2511. // 删除旧的组号标签
  2512. const existingBadges = header.querySelectorAll('.group-badge');
  2513. existingBadges.forEach(b => b.remove());
  2514. // 添加所有组号标签
  2515. const badgeContainer = document.createElement('span');
  2516. badgeContainer.style.cssText = 'display: inline-flex; gap: 4px; margin-right: 6px; flex-wrap: wrap;';
  2517. groups.forEach(group => {
  2518. const badge = document.createElement('span');
  2519. badge.className = 'group-badge';
  2520. badge.textContent = `#${group.groupNumber}`;
  2521. badge.style.cssText = `
  2522. display: inline-block;
  2523. background: ${group.color.border};
  2524. color: white;
  2525. padding: 2px 6px;
  2526. border-radius: 10px;
  2527. font-size: 0.75rem;
  2528. font-weight: 700;
  2529. `;
  2530. badgeContainer.appendChild(badge);
  2531. });
  2532. header.insertBefore(badgeContainer, header.firstChild);
  2533. // 收集所有组的共同帖子(取并集)
  2534. const allPostIds = new Set();
  2535. groups.forEach(group => {
  2536. group.commonPostIds.forEach(id => allPostIds.add(id));
  2537. });
  2538. // 显示所有相关的帖子封面图
  2539. const nodeId = header.getAttribute('data-node-id');
  2540. if (nodeId && allPostIds.size > 0) {
  2541. showCategoryThumbnails(dimension, path, nodeId, Array.from(allPostIds));
  2542. }
  2543. }
  2544. });
  2545. }
  2546. function highlightOrthogonalNode(dimension, path, commonPostIds, borderColor, bgColor, groupNumber) {
  2547. const headers = document.querySelectorAll('.tab3-node-header');
  2548. headers.forEach(header => {
  2549. if (header.getAttribute('data-dimension') === dimension &&
  2550. header.getAttribute('data-path') === path) {
  2551. // 高亮节点
  2552. header.style.borderColor = borderColor;
  2553. header.style.borderWidth = '3px';
  2554. header.style.boxShadow = `0 0 16px ${borderColor}99`;
  2555. header.style.backgroundColor = bgColor;
  2556. header.style.fontWeight = '600';
  2557. // 在节点前添加组号标签
  2558. const existingBadge = header.querySelector('.group-badge');
  2559. if (existingBadge) {
  2560. existingBadge.remove();
  2561. }
  2562. const badge = document.createElement('span');
  2563. badge.className = 'group-badge';
  2564. badge.textContent = `#${groupNumber}`;
  2565. badge.style.cssText = `
  2566. display: inline-block;
  2567. background: ${borderColor};
  2568. color: white;
  2569. padding: 2px 6px;
  2570. border-radius: 10px;
  2571. font-size: 0.75rem;
  2572. font-weight: 700;
  2573. margin-right: 6px;
  2574. `;
  2575. header.insertBefore(badge, header.firstChild);
  2576. // 显示三维共同帖子的封面图
  2577. const nodeId = header.getAttribute('data-node-id');
  2578. if (nodeId && commonPostIds && commonPostIds.length > 0) {
  2579. showCategoryThumbnails(dimension, path, nodeId, commonPostIds);
  2580. }
  2581. }
  2582. });
  2583. }
  2584. function showCategoryThumbnails(dimension, path, nodeId, filterPostIds) {
  2585. // 在clusteredData中查找该分类的数据
  2586. const pathParts = path.split('/');
  2587. let categoryData = clusteredData[dimension];
  2588. // 逐级查找到目标分类
  2589. for (const part of pathParts) {
  2590. if (categoryData && categoryData[part]) {
  2591. categoryData = categoryData[part];
  2592. } else {
  2593. console.log('未找到分类数据:', dimension, path);
  2594. return;
  2595. }
  2596. }
  2597. // 获取帖子列表
  2598. let postIds = categoryData['帖子列表'] || [];
  2599. // 如果提供了过滤列表,只显示过滤列表中的帖子(用于关联分类显示共同帖子)
  2600. if (filterPostIds && filterPostIds.length > 0) {
  2601. postIds = postIds.filter(id => filterPostIds.includes(id));
  2602. }
  2603. console.log('该分类的帖子数量:', postIds.length);
  2604. if (postIds.length === 0) {
  2605. return;
  2606. }
  2607. // 获取封面图容器
  2608. const thumbnailsContainer = document.getElementById(nodeId + '-thumbnails');
  2609. if (!thumbnailsContainer) {
  2610. return;
  2611. }
  2612. // 清空容器
  2613. thumbnailsContainer.innerHTML = '';
  2614. // 为每个帖子添加封面图
  2615. postIds.forEach(postId => {
  2616. const postData = postCache[postId];
  2617. if (postData && postData.images && postData.images.length > 0) {
  2618. const img = document.createElement('img');
  2619. img.src = postData.images[0];
  2620. img.alt = postData.title || '帖子封面';
  2621. img.style.cssText = 'width: 80px; height: 80px; object-fit: cover; border-radius: 6px; cursor: pointer; transition: all 0.2s; border: 2px solid #e0e0e0;';
  2622. // 鼠标悬停效果
  2623. img.addEventListener('mouseenter', function() {
  2624. this.style.transform = 'scale(1.1)';
  2625. this.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
  2626. this.style.borderColor = '#2196F3';
  2627. });
  2628. img.addEventListener('mouseleave', function() {
  2629. this.style.transform = 'scale(1)';
  2630. this.style.boxShadow = 'none';
  2631. this.style.borderColor = '#e0e0e0';
  2632. });
  2633. // 点击封面图显示帖子详情
  2634. img.addEventListener('click', function(e) {
  2635. e.stopPropagation();
  2636. showPostDetail(postId);
  2637. });
  2638. thumbnailsContainer.appendChild(img);
  2639. }
  2640. });
  2641. // 显示容器
  2642. if (thumbnailsContainer.children.length > 0) {
  2643. thumbnailsContainer.style.display = 'flex';
  2644. }
  2645. }
  2646. document.querySelector('.modal-overlay')?.addEventListener('click', closeModal);
  2647. });
  2648. </script>
  2649. '''
  2650. return js_code
  2651. def visualize_classification_tree(
  2652. optimized_data_path: str,
  2653. posts_dir: str,
  2654. xuanti_point_map: Dict[str, Dict[str, Any]],
  2655. output_path: str = None,
  2656. dimension_associations_path: str = None,
  2657. intra_dimension_associations_path: str = None,
  2658. expanded_orthogonal_combinations_path: str = None,
  2659. enriched_xuanti_point_map_path: str = None
  2660. ) -> str:
  2661. """可视化分类树"""
  2662. with open(optimized_data_path, 'r', encoding='utf-8') as f:
  2663. clustered_data = json.load(f)
  2664. # 加载跨维度关联分析数据(如果提供了路径)
  2665. dimension_associations = None
  2666. if dimension_associations_path and os.path.exists(dimension_associations_path):
  2667. try:
  2668. with open(dimension_associations_path, 'r', encoding='utf-8') as f:
  2669. dimension_associations = json.load(f)
  2670. print(f"✅ 已加载跨维度关联分析数据: {dimension_associations_path}")
  2671. except Exception as e:
  2672. print(f"⚠️ 加载跨维度关联分析数据失败: {e}")
  2673. # 加载维度内部关联分析数据(如果提供了路径)
  2674. intra_dimension_associations = None
  2675. if intra_dimension_associations_path and os.path.exists(intra_dimension_associations_path):
  2676. try:
  2677. with open(intra_dimension_associations_path, 'r', encoding='utf-8') as f:
  2678. intra_dimension_associations = json.load(f)
  2679. print(f"✅ 已加载维度内部关联分析数据: {intra_dimension_associations_path}")
  2680. except Exception as e:
  2681. print(f"⚠️ 加载维度内部关联分析数据失败: {e}")
  2682. # 加载扩展正交组合数据(新增)
  2683. expanded_orthogonal_combinations = None
  2684. if expanded_orthogonal_combinations_path and os.path.exists(expanded_orthogonal_combinations_path):
  2685. try:
  2686. with open(expanded_orthogonal_combinations_path, 'r', encoding='utf-8') as f:
  2687. expanded_orthogonal_combinations = json.load(f)
  2688. print(f"✅ 已加载扩展正交组合数据: {expanded_orthogonal_combinations_path}")
  2689. except Exception as e:
  2690. print(f"⚠️ 加载扩展正交组合数据失败: {e}")
  2691. # 加载丰富选题点映射数据(新增)
  2692. enriched_xuanti_point_map = None
  2693. if enriched_xuanti_point_map_path and os.path.exists(enriched_xuanti_point_map_path):
  2694. try:
  2695. with open(enriched_xuanti_point_map_path, 'r', encoding='utf-8') as f:
  2696. enriched_xuanti_point_map = json.load(f)
  2697. print(f"✅ 已加载丰富选题点映射数据: {enriched_xuanti_point_map_path}")
  2698. except Exception as e:
  2699. print(f"⚠️ 加载丰富选题点映射数据失败: {e}")
  2700. visualizer = ClassificationTreeVisualizer()
  2701. html_content = visualizer.generate_html(
  2702. clustered_data,
  2703. Path(posts_dir),
  2704. xuanti_point_map,
  2705. dimension_associations,
  2706. intra_dimension_associations,
  2707. expanded_orthogonal_combinations,
  2708. enriched_xuanti_point_map
  2709. )
  2710. if output_path is None:
  2711. timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
  2712. base_dir = os.path.dirname(optimized_data_path)
  2713. output_path = os.path.join(base_dir, f"visualization/classification_tree_visualization_{timestamp}.html")
  2714. os.makedirs(os.path.dirname(output_path), exist_ok=True)
  2715. with open(output_path, 'w', encoding='utf-8') as f:
  2716. f.write(html_content)
  2717. print(f"✅ 可视化文件已生成: {output_path}")
  2718. return output_path
  2719. if __name__ == "__main__":
  2720. account_name = "阿里多多酱"
  2721. base_dir = "/Users/nieqi/Documents/workspace/python/image_article_comprehension/aiddit/pattern/pattern_from_xuanti_point_label"
  2722. mode = "detail"
  2723. optimized_data_path = f"{base_dir}/result/{account_name}/optimization/{mode}/optimized_clustered_data_gemini-3-pro-preview.json"
  2724. posts_dir = f"/Users/nieqi/Documents/workspace/python/image_article_comprehension/aigc_data/{account_name}"
  2725. dimension_associations_path = f"{base_dir}/result/{account_name}/optimization/{mode}/dimension_associations_analysis.json"
  2726. intra_dimension_associations_path = f"{base_dir}/result/{account_name}/optimization/{mode}/intra_dimension_associations_analysis.json"
  2727. expanded_orthogonal_combinations_path = f"{base_dir}/result/{account_name}/optimization/detail/orthogonal_combinations_clustering.json"
  2728. enriched_xuanti_point_map_path = f"{base_dir}/result/{account_name}/optimization/{mode}/enriched_xuanti_point_map.json"
  2729. from aiddit.pattern.pattern_from_xuanti_point_label import pattern_utils
  2730. xuanti_point_map = pattern_utils.get_xuanti_point__map(account_name)
  2731. output_path = visualize_classification_tree(
  2732. optimized_data_path=optimized_data_path,
  2733. posts_dir=posts_dir,
  2734. xuanti_point_map=xuanti_point_map,
  2735. dimension_associations_path=dimension_associations_path,
  2736. intra_dimension_associations_path=intra_dimension_associations_path,
  2737. expanded_orthogonal_combinations_path=expanded_orthogonal_combinations_path,
  2738. enriched_xuanti_point_map_path=enriched_xuanti_point_map_path
  2739. )
  2740. print(f"🎉 可视化完成!请在浏览器中打开: {output_path}")