| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781278227832784278527862787278827892790279127922793279427952796279727982799280028012802280328042805280628072808280928102811281228132814281528162817281828192820282128222823282428252826282728282829283028312832283328342835283628372838283928402841284228432844284528462847284828492850285128522853285428552856285728582859286028612862286328642865286628672868286928702871287228732874287528762877287828792880288128822883288428852886288728882889289028912892289328942895289628972898289929002901290229032904290529062907290829092910291129122913291429152916291729182919292029212922292329242925292629272928292929302931293229332934293529362937293829392940294129422943294429452946294729482949295029512952295329542955295629572958295929602961296229632964296529662967296829692970297129722973297429752976297729782979298029812982298329842985298629872988298929902991299229932994299529962997299829993000300130023003300430053006300730083009301030113012301330143015301630173018301930203021302230233024302530263027302830293030303130323033303430353036303730383039304030413042304330443045304630473048304930503051305230533054305530563057305830593060306130623063306430653066306730683069307030713072307330743075307630773078307930803081308230833084308530863087308830893090309130923093309430953096309730983099310031013102310331043105310631073108310931103111311231133114311531163117311831193120312131223123312431253126312731283129313031313132313331343135313631373138313931403141314231433144314531463147314831493150315131523153315431553156315731583159316031613162316331643165316631673168316931703171317231733174317531763177317831793180318131823183318431853186318731883189319031913192319331943195319631973198319932003201320232033204320532063207320832093210321132123213321432153216321732183219322032213222322332243225322632273228322932303231323232333234323532363237 |
- """
- 分类优化结果可视化工具
- 功能:
- 1. 读取优化后的聚类数据 (optimized_clustered_data_*.json)
- 2. 生成交互式HTML树形可视化
- 3. 支持查看帖子详情和所有点的信息
- 4. 区分显示原始分类、LLM抽象、LLM细分
- """
- import json
- import os
- from typing import Dict, Any, List, Optional
- from pathlib import Path
- from datetime import datetime
- class ClassificationTreeVisualizer:
- """分类树可视化工具"""
- def __init__(self):
- self.post_cache: Dict[str, Dict[str, Any]] = {}
- self.xuanti_point_map: Dict[str, Dict[str, Any]] = {}
- self.clustered_data: Dict[str, Any] = {}
- self.dimension_associations: Dict[str, Any] = {}
- self.intra_dimension_associations: Dict[str, Any] = {}
- def load_post_data(self, post_id: str, posts_dir: Path) -> Optional[Dict[str, Any]]:
- """加载帖子详细数据"""
- if post_id in self.post_cache:
- return self.post_cache[post_id]
- post_file = posts_dir / f"{post_id}.json"
- if not post_file.exists():
- return None
- try:
- with open(post_file, 'r', encoding='utf-8') as f:
- post_data = json.load(f)
- self.post_cache[post_id] = post_data
- return post_data
- except Exception as e:
- print(f"加载帖子 {post_id} 失败: {e}")
- return None
- def generate_tree_node_html(
- self,
- node_name: str,
- node_data: Dict[str, Any],
- level: int,
- point_type: str,
- path: List[str]
- ) -> str:
- """递归生成树节点的HTML - 支持部分细分结构"""
- import html as html_module
- node_name_escaped = html_module.escape(node_name)
- current_path = path + [node_name]
- node_id = f"{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_')
- meta = node_data.get('_meta', {})
- source = meta.get('分类来源', '')
- # 检查是否有保留的点
- has_kept_points = '点列表' in node_data and len(node_data.get('点列表', [])) > 0
- # 检查是否有子分类
- has_children = False
- for key in node_data.keys():
- if key not in ['_meta', '点列表', '帖子数', '点数', '帖子列表'] and isinstance(node_data[key], dict):
- has_children = True
- break
- # 确定节点样式
- if source == 'LLM抽象' or source == 'LLM细分':
- node_class = f"tree-node tree-node-llm level-{level}"
- else:
- node_class = f"tree-node tree-node-original level-{level}"
- html = f'<div class="{node_class}">\n'
- html += f' <div class="tree-node-header">\n'
- html += f' <span class="tree-node-icon toggle-icon" data-node-id="{node_id}" data-action="toggle-node">▶</span>\n'
- html += f' <span class="tree-node-title" data-node-id="{node_id}" data-action="show-classification-detail">{node_name_escaped}</span>\n'
- # 只有存在下级分类节点时,才显示展开/收起全部按钮
- if has_children:
- html += f' <span class="expand-all-icon" data-node-id="{node_id}" data-action="toggle-all-children" title="展开/收起所有下级分类节点">⇅</span>\n'
- html += f' </div>\n'
- html += f' <div class="tree-node-content" id="{node_id}-content">\n'
- html += ' <div class="tree-children">\n'
- # 1. 先显示保留在原分类的点(如果有)
- if has_kept_points:
- point_name_field = "灵感点" if point_type == "灵感点列表" else (
- "目的点" if point_type == "目的点" else "关键点"
- )
- for point in node_data.get('点列表', []):
- point_name = point.get(point_name_field, '')
- if not point_name:
- continue
- point_name_escaped = html_module.escape(point_name)
- point_id = f"{node_id}_kept_{point_name}".replace('/', '_').replace(' ', '_')
- # 获取封面图
- post_id = point.get('帖子id', '')
- thumbnail_html = ''
- if post_id and post_id in self.post_cache:
- post_data = self.post_cache[post_id]
- images = post_data.get('images', [])
- if images and len(images) > 0:
- first_image = html_module.escape(images[0])
- thumbnail_html = f'<img src="{first_image}" class="node-thumbnail" alt="封面图" loading="lazy">'
- html += f'<div class="tree-node tree-node-leaf level-{level + 1}">\n'
- html += f' <div class="tree-node-header" data-node-id="{point_id}" data-parent-id="{node_id}" data-action="show-point-detail">\n'
- html += f' <span class="tree-node-icon">📄</span>\n'
- html += f' <span class="tree-node-title">{point_name_escaped}</span>\n'
- html += f' {thumbnail_html}\n'
- html += f' </div>\n'
- html += '</div>\n'
- # 2. 再显示子分类(如果有)
- if has_children:
- for child_name, child_data in node_data.items():
- if child_name in ['_meta', '点列表', '帖子数', '点数', '帖子列表']:
- continue
- if isinstance(child_data, dict):
- html += self.generate_tree_node_html(
- child_name, child_data, level + 1, point_type, current_path
- )
- html += ' </div>\n'
- html += ' </div>\n'
- html += '</div>\n'
- return html
- def generate_html(
- self,
- clustered_data: Dict[str, Any],
- posts_dir: Path,
- xuanti_point_map: Dict[str, Dict[str, Any]],
- dimension_associations: Optional[Dict[str, Any]] = None,
- intra_dimension_associations: Optional[Dict[str, Any]] = None,
- expanded_orthogonal_combinations: Optional[Dict[str, Any]] = None,
- enriched_xuanti_point_map: Optional[Dict[str, Any]] = None
- ) -> str:
- """生成完整的HTML页面"""
- self.xuanti_point_map = xuanti_point_map
- self.clustered_data = clustered_data
- if dimension_associations:
- self.dimension_associations = dimension_associations
- if intra_dimension_associations:
- self.intra_dimension_associations = intra_dimension_associations
- if expanded_orthogonal_combinations:
- self.expanded_orthogonal_combinations = expanded_orthogonal_combinations
- if enriched_xuanti_point_map:
- self.enriched_xuanti_point_map = enriched_xuanti_point_map
- # 从clustered_data构建完整的帖子ID到特征对象的映射
- self.post_to_features_map = self._build_post_to_features_map(clustered_data)
- # 预加载所有帖子数据
- all_post_ids = set()
- for point_type_data in clustered_data.values():
- self._collect_post_ids(point_type_data, all_post_ids)
- # 从xuanti_point_map中也加载帖子ID
- for post_id in xuanti_point_map.keys():
- all_post_ids.add(post_id)
- for post_id in all_post_ids:
- self.load_post_data(post_id, posts_dir)
- # 生成HTML
- html = self._generate_html_head()
- html += '<body>\n'
- # Tab切换结构
- html += '<div class="tabs-container">\n'
- html += ' <div class="tabs-header">\n'
- html += ' <button class="tab-button active" data-tab="tab1">帖子视角 - 原始选题点</button>\n'
- html += ' <button class="tab-button" data-tab="tab2">特征视角 - 分类优化结果</button>\n'
- html += ' <button class="tab-button" data-tab="tab3">叶子分类组合聚类</button>\n'
- html += ' <button class="tab-button" data-tab="tab4">维度关联分析</button>\n'
- html += ' </div>\n'
- html += '</div>\n'
- # Tab 1: 帖子视角 - 原始选题点
- html += '<div id="tab1" class="tab-content active">\n'
- html += self._generate_tab1_content()
- html += '</div>\n'
- # Tab 2: 特征视角 - 分类优化结果
- html += '<div id="tab2" class="tab-content">\n'
- html += self._generate_tab2_content(clustered_data)
- html += '</div>\n'
- # Tab 3: 叶子分类组合聚类
- html += '<div id="tab3" class="tab-content">\n'
- if self.intra_dimension_associations:
- html += self._generate_tab4_content()
- else:
- html += '<div style="padding: 40px; text-align: center; color: #999;">未加载叶子分类组合聚类数据</div>\n'
- html += '</div>\n'
- # Tab 4: 维度关联分析
- html += '<div id="tab4" class="tab-content">\n'
- if self.dimension_associations:
- html += self._generate_tab3_content()
- else:
- html += '<div style="padding: 40px; text-align: center; color: #999;">未加载维度关联分析数据</div>\n'
- html += '</div>\n'
- # 添加弹窗容器
- html += '''
- <div id="detail-modal" class="modal" style="display: none;">
- <div class="modal-overlay"></div>
- <div class="modal-content">
- <div class="modal-header">
- <h3 id="modal-title"></h3>
- <button class="modal-close" data-action="close-modal">✕</button>
- </div>
- <div class="modal-body" id="modal-body">
- </div>
- </div>
- </div>
- '''
- # 添加JavaScript
- html += self._generate_javascript(posts_dir)
- html += '</body>\n</html>\n'
- return html
- def _generate_tab1_content(self) -> str:
- """生成Tab1内容:帖子视角 - 原始选题点"""
- import html as html_module
- html = '<div class="mindmap-container">\n'
- # 按帖子ID排序
- sorted_post_ids = sorted(self.xuanti_point_map.keys())
- for post_id in sorted_post_ids:
- xuanti_point = self.xuanti_point_map[post_id]
- post_data = self.post_cache.get(post_id)
- if not post_data:
- continue
- # 帖子卡片
- html += ' <div class="mindmap-section">\n'
- # 左侧:帖子信息
- html += ' <div class="mindmap-root" style="min-width: 250px; max-width: 250px;">\n'
- html += f' <div><strong>{html_module.escape(post_data.get("title", "无标题"))}</strong></div>\n'
- # 封面图
- images = post_data.get('images', [])
- if images and len(images) > 0:
- first_image = html_module.escape(images[0])
- html += f' <img src="{first_image}" style="width:100%; margin-top:8px; border-radius:4px;" alt="封面" loading="lazy">\n'
- html += f' <div style="margin-top:8px; font-size:0.8rem; opacity:0.8;">ID: {html_module.escape(post_id[:12])}...</div>\n'
- html += ' </div>\n'
- # 右侧:选题点树状结构
- html += ' <div class="mindmap-tree">\n'
- # 遍历三种点类型
- for point_type in ["灵感点列表", "目的点", "关键点列表"]:
- points = xuanti_point.get(point_type, [])
- if not points:
- continue
- point_name_field = "灵感点" if point_type == "灵感点列表" else (
- "目的点" if point_type == "目的点" else "关键点"
- )
- html += f' <div class="tree-node tree-node-original level-0" style="margin-bottom:12px;">\n'
- html += f' <div class="tree-node-header" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; font-weight:600;">\n'
- html += f' <span>{point_type} ({len(points)})</span>\n'
- html += ' </div>\n'
- html += ' <div class="tree-node-content" style="display:flex;">\n'
- html += ' <div class="tree-children">\n'
- # 遍历该类型下的所有点
- for idx, point in enumerate(points):
- point_name = point.get(point_name_field, '')
- point_id = f"tab1_{post_id}_{point_type}_{idx}"
- html += f' <div class="tree-node level-1">\n'
- html += f' <div class="tree-node-header">\n'
- html += f' <span class="tree-node-icon toggle-icon expanded" data-node-id="{point_id}" data-action="toggle-node">▶</span>\n'
- 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'
- html += ' </div>\n'
- # 点的内容:只显示特征列表(扁平化展示)
- html += f' <div class="tree-node-content" id="{point_id}-content">\n'
- # 显示提取的特征(扁平化展示为标签)
- features = point.get('提取的特征', [])
- if features:
- html += ' <div style="display:flex; flex-wrap:wrap; gap:8px; padding:8px; background:#fafbfc; border-radius:4px;">\n'
- for feature_idx, feature in enumerate(features):
- feature_name = feature.get('特征名称', '')
- feature_weight = feature.get('权重', 0)
- feature_level1 = feature.get('一级分类', '')
- feature_level2 = feature.get('二级分类', '')
- # 构建特征分类标签
- feature_class_tag = ''
- if feature_level1:
- feature_class_tag = f'{html_module.escape(feature_level1)}'
- if feature_level2:
- feature_class_tag += f' / {html_module.escape(feature_level2)}'
- # 扁平化的特征标签样式
- 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'
- html += f' <span style="font-weight:500; color:#333;">🔖 {html_module.escape(feature_name)}</span>\n'
- html += f' <span style="font-size:0.75rem; color:#999;">({feature_weight})</span>\n'
- if feature_class_tag:
- html += f' <span style="font-size:0.75rem; background:#fff3e0; padding:2px 8px; border-radius:3px; color:#e65100;">{feature_class_tag}</span>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- # 在点之间添加分割线(最后一个点不添加)
- if idx < len(points) - 1:
- html += ' <div style="height:1px; background:linear-gradient(to right, transparent, #e0e0e0 20%, #e0e0e0 80%, transparent); margin:12px 0;"></div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += '</div>\n'
- return html
- def _generate_tab2_content(self, clustered_data: Dict[str, Any]) -> str:
- """生成Tab2内容:特征视角 - 分类优化结果"""
- html = '<div class="mindmap-container">\n'
- # 为每种点类型生成树
- for point_type in ["灵感点列表", "目的点", "关键点列表"]:
- type_data = clustered_data.get(point_type, {})
- if type_data:
- html += f' <div class="mindmap-section">\n'
- html += f' <div class="mindmap-root">{point_type}</div>\n'
- html += ' <div class="mindmap-tree">\n'
- for node_name, node_data in type_data.items():
- html += self.generate_feature_tree_node_html(
- node_name, node_data, 0, point_type, []
- )
- html += ' </div>\n'
- html += ' </div>\n'
- html += '</div>\n'
- return html
- def _generate_tab3_content(self) -> str:
- """生成Tab3内容:维度关联分析 - 交互式分类树"""
- html = '<div style="padding: 20px;">\n'
- html += '<h2 style="text-align: center; color: #667eea; margin-bottom: 10px;">维度关联分析</h2>\n'
- # 模式切换按钮
- html += '<div style="text-align: center; margin-bottom: 20px;">\n'
- html += ' <div style="display: inline-flex; background: #f0f0f0; border-radius: 8px; padding: 4px;">\n'
- 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'
- 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'
- html += ' </div>\n'
- html += ' <div id="mode-description" style="margin-top: 10px; color: #666; font-size: 0.9rem;">\n'
- html += ' <span id="desc-single">点击一个维度的分类,查看它与其他维度的关联关系</span>\n'
- html += ' <span id="desc-three" style="display: none;">点击灵感点分类,右侧显示目的点和关键点的正交组合及共同帖子</span>\n'
- html += ' </div>\n'
- html += '</div>\n'
- # 单维度关联模式:三列布局
- html += '<div id="single-dim-layout" style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; margin-top: 20px;">\n'
- dimensions = [
- ("灵感点列表", "灵感点"),
- ("目的点", "目的点"),
- ("关键点列表", "关键点")
- ]
- for point_type, display_name in dimensions:
- html += f'<div style="background: white; border-radius: 8px; padding: 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">\n'
- html += f' <h3 style="color: #667eea; margin-bottom: 16px; text-align: center;">{display_name}</h3>\n'
- html += f' <div id="tab3-tree-{point_type}" class="tab3-tree-container">\n'
- # 生成分类树
- type_data = self.clustered_data.get(point_type, {})
- if type_data:
- for node_name, node_data in type_data.items():
- html += self._generate_tab3_tree_node(
- node_name, node_data, 0, point_type, []
- )
- html += ' </div>\n'
- html += '</div>\n'
- html += '</div>\n'
- # 三维正交关联模式:全屏平铺布局
- html += '<div id="three-dim-layout" style="display: none; margin-top: 20px;">\n'
- # 全屏正交关系结果区域
- 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'
- html += ' <div id="orthogonal-results" style="min-height: 200px;">\n'
- html += ' <div style="text-align: center; padding: 60px 20px; color: #999;">\n'
- html += ' <p style="font-size: 1.1rem; margin-bottom: 10px;">🔄 加载中...</p>\n'
- html += ' <p style="font-size: 0.9rem;">正在加载所有三维正交扩展结果</p>\n'
- html += ' </div>\n'
- html += ' </div>\n'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- return html
- def _generate_tab3_tree_node(
- self,
- node_name: str,
- node_data: Dict[str, Any],
- level: int,
- point_type: str,
- path: List[str]
- ) -> str:
- """递归生成Tab3的树节点HTML,使用数据属性存储关联信息"""
- import html as html_module
- node_name_escaped = html_module.escape(node_name)
- current_path = path + [node_name]
- path_str = '/'.join(current_path)
- node_id = f"tab3_{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_').replace('(', '').replace(')', '')
- meta = node_data.get('_meta', {})
- source = meta.get('分类来源', '')
- # 检查是否有子分类
- has_children = any(
- key not in ['_meta', '特征列表', '点列表', '帖子数', '特征数', '点数', '帖子列表'] and isinstance(node_data[key], dict)
- for key in node_data.keys()
- )
- # 确定节点样式 - 不使用绿色,使用浅灰色背景区分LLM生成的分类
- if source in ['LLM抽象', 'LLM细分']:
- bg_color = '#f0f0f0'
- text_color = '#333'
- border_style = '1px solid #d0d0d0'
- else:
- bg_color = '#fafafa'
- text_color = '#333'
- border_style = '1px solid #e0e0e0'
- # 节点容器
- html = f'<div class="tab3-node" style="margin-bottom: 6px;">\n'
- # 节点头部 - 使用data属性存储分类路径和维度信息以及原始样式
- padding_left = level * 16
- html += f' <div class="tab3-node-header" '
- html += f'data-dimension="{point_type}" '
- html += f'data-path="{html_module.escape(path_str)}" '
- html += f'data-node-id="{node_id}" '
- html += f'data-original-bg="{bg_color}" '
- html += f'data-original-border="{border_style}" '
- html += f'style="padding: 6px 12px; padding-left: {padding_left + 12}px; '
- html += f'background: {bg_color}; color: {text_color}; '
- html += f'border: {border_style}; '
- html += 'border-radius: 4px; cursor: pointer; transition: all 0.2s; '
- html += 'box-shadow: 0 1px 2px rgba(0,0,0,0.05); margin-bottom: 4px;">\n'
- if has_children:
- # 默认展开,所以使用▼图标
- html += f' <span class="tab3-toggle" data-target="{node_id}-content" style="margin-right: 8px; user-select: none;">▼</span>\n'
- else:
- html += ' <span style="margin-right: 8px;">•</span>\n'
- html += f' <span>{node_name_escaped}</span>\n'
- html += ' </div>\n'
- # 封面图容器 - 用于显示该分类下的帖子封面图
- 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'
- # 子节点容器 - 默认展开
- if has_children:
- html += f' <div id="{node_id}-content" style="display: block;">\n'
- for child_name, child_data in node_data.items():
- if child_name not in ['_meta', '特征列表', '点列表', '帖子数', '特征数', '点数', '帖子列表'] and isinstance(child_data, dict):
- html += self._generate_tab3_tree_node(
- child_name, child_data, level + 1, point_type, current_path
- )
- html += ' </div>\n'
- html += '</div>\n'
- return html
- def _generate_leaf_color(self, index: int, total: int) -> str:
- """为叶子分类生成不同的颜色(使用HSL色轮)"""
- # 使用HSL色轮,在0-360度范围内均匀分布
- # 降低亮度到45%,增加饱和度到85%,让白色字体更突出
- hue = int((index * 360) / max(total, 1))
- return f'hsl({hue}, 85%, 45%)'
- def _highlight_features_in_point_name(self, point_name: str, features: list, leaf_color_map: dict) -> str:
- """在点名称中高亮显示特征词(使用对应的叶子分类颜色)"""
- import html as html_module
- # 构建特征名称到颜色的映射
- feature_colors = {}
- for feature in features:
- feature_name = feature.get('特征名称', '')
- leaf_class = feature.get('叶子分类', '')
- if feature_name and leaf_class in leaf_color_map:
- feature_colors[feature_name] = leaf_color_map[leaf_class]
- # 按特征名称长度倒序排序,避免短的先替换导致长的无法匹配
- sorted_features = sorted(feature_colors.items(), key=lambda x: len(x[0]), reverse=True)
- # 先转义整个字符串
- highlighted_name = html_module.escape(point_name)
- # 逐个替换特征词为带颜色的span
- for feature_name, color in sorted_features:
- escaped_name = html_module.escape(feature_name)
- if escaped_name in highlighted_name:
- highlighted_name = highlighted_name.replace(
- escaped_name,
- 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>'
- )
- return highlighted_name
- def _generate_tab4_content(self) -> str:
- """生成Tab4内容:叶子分类组合聚类"""
- import html as html_module
- html = '<div style="padding: 20px;">\n'
- html += '<h2 style="text-align: center; color: #667eea; margin-bottom: 20px;">叶子分类组合聚类分析</h2>\n'
- # 获取聚类数据
- clustering_data = self.intra_dimension_associations.get('叶子分类组合聚类', {})
- dimensions = [
- ("灵感点", "#9C27B0"),
- ("目的点", "#E91E63"),
- ("关键点", "#FF9800")
- ]
- for dimension_name, dimension_color in dimensions:
- dimension_clusters = clustering_data.get(dimension_name, {})
- # 按点数倒序排列
- sorted_clusters = sorted(
- dimension_clusters.items(),
- key=lambda x: x[1]['点数'],
- reverse=True
- )
- html += f'<div style="margin-bottom: 40px;">\n'
- html += f'<h3 style="color: {dimension_color}; border-bottom: 3px solid {dimension_color}; padding-bottom: 10px; margin-bottom: 20px;">\n'
- html += f' {dimension_name} ({len(sorted_clusters)} 个聚类)\n'
- html += '</h3>\n'
- if not sorted_clusters:
- html += '<p style="color: #999; text-align: center; padding: 20px;">暂无聚类数据</p>\n'
- else:
- html += '<div style="display: grid; grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); gap: 20px;">\n'
- for cluster_key, cluster_data in sorted_clusters:
- leaf_classifications = cluster_data.get('叶子分类组合', [])
- point_count = cluster_data.get('点数', 0)
- points_details = cluster_data.get('点详情列表', [])
- 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'
- # 建立叶子分类到颜色的映射
- total_classifications = len(leaf_classifications)
- leaf_color_map = {}
- for idx, leaf_class in enumerate(leaf_classifications):
- leaf_color_map[leaf_class] = self._generate_leaf_color(idx, total_classifications)
- # 标题:叶子分类标签 + 点数徽章
- html += '<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap;">\n'
- # 叶子分类标签 - 每个分类使用不同颜色
- for leaf_class in leaf_classifications:
- leaf_color = leaf_color_map[leaf_class]
- 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'
- # 点数徽章
- 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'
- html += '</div>\n'
- # 点详情列表
- html += '<div style="display: flex; flex-direction: column; gap: 16px;">\n'
- for idx, point_detail in enumerate(points_details[:10]): # 最多显示10个点
- point_name = point_detail.get('点名称', '')
- point_desc = point_detail.get('点描述', '')
- features = point_detail.get('特征列表', [])
- # 点卡片
- html += f'<div style="background: #f8f9fa; border-radius: 8px; padding: 12px; border-left: 4px solid {dimension_color};">\n'
- # 点名称(高亮显示特征词)
- highlighted_point_name = self._highlight_features_in_point_name(point_name, features, leaf_color_map)
- 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'
- # 特征列表
- if features:
- html += '<div style="margin-top: 8px; padding-left: 12px;">\n'
- for feature in features:
- feature_name = feature.get('特征名称', '')
- full_path = feature.get('完整路径', '')
- weight = feature.get('权重', 0)
- # 特征容器(分两行显示)
- html += '<div style="margin-bottom: 8px;">\n'
- # 第一行:特征名称 + 权重
- html += '<div style="display: flex; align-items: center; gap: 6px; font-size: 0.85rem; margin-bottom: 3px;">\n'
- html += f'<span style="color: {dimension_color}; font-weight: 600;">•</span>\n'
- html += f'<span style="color: #555; font-weight: 600;">{html_module.escape(feature_name)}</span>\n'
- html += f'<span style="background: #e0e0e0; color: #666; padding: 2px 6px; border-radius: 4px; font-size: 0.75rem;">权重 {weight}</span>\n'
- html += '</div>\n'
- # 第二行:完整分类路径(缩进),使用灰色背景
- html += '<div style="padding-left: 18px; font-size: 0.8rem;">\n'
- html += f'<span style="color: #888; font-family: monospace; background: #f5f5f5; padding: 2px 6px; border-radius: 3px;">{html_module.escape(full_path)}</span>\n'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- if len(points_details) > 10:
- 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'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- html += '</div>\n'
- return html
- def generate_feature_tree_node_html(
- self,
- node_name: str,
- node_data: Dict[str, Any],
- level: int,
- point_type: str,
- path: List[str]
- ) -> str:
- """递归生成特征树节点的HTML"""
- import html as html_module
- node_name_escaped = html_module.escape(node_name)
- current_path = path + [node_name]
- node_id = f"tab2_{point_type}_{'_'.join(current_path)}".replace('/', '_').replace(' ', '_')
- meta = node_data.get('_meta', {})
- source = meta.get('分类来源', '')
- # 检查是否有保留的特征
- has_kept_features = '特征列表' in node_data and len(node_data.get('特征列表', [])) > 0
- # 检查是否有子分类
- has_children = False
- for key in node_data.keys():
- if key not in ['_meta', '特征列表', '帖子数', '特征数', '帖子列表'] and isinstance(node_data[key], dict):
- has_children = True
- break
- # 确定节点样式
- if source == 'LLM抽象' or source == 'LLM细分':
- node_class = f"tree-node tree-node-llm level-{level}"
- else:
- node_class = f"tree-node tree-node-original level-{level}"
- html = f'<div class="{node_class}">\n'
- html += f' <div class="tree-node-header">\n'
- html += f' <span class="tree-node-icon toggle-icon" data-node-id="{node_id}" data-action="toggle-node">▶</span>\n'
- html += f' <span class="tree-node-title" data-node-id="{node_id}" data-action="show-classification-detail">{node_name_escaped}</span>\n'
- if has_children:
- html += f' <span class="expand-all-icon" data-node-id="{node_id}" data-action="toggle-all-children" title="展开/收起所有下级分类节点">⇅</span>\n'
- html += f' </div>\n'
- html += f' <div class="tree-node-content" id="{node_id}-content">\n'
- html += ' <div class="tree-children">\n'
- # 1. 先显示保留在原分类的特征(如果有)
- if has_kept_features:
- features_list = node_data.get('特征列表', [])
- # 去重特征名称用于显示
- unique_features = {}
- for feature in features_list:
- feature_name = feature.get("特征名称", "")
- if feature_name not in unique_features:
- unique_features[feature_name] = []
- unique_features[feature_name].append(feature)
- for feature_name, feature_instances in unique_features.items():
- feature_name_escaped = html_module.escape(feature_name)
- feature_id = f"{node_id}_kept_{feature_name}".replace('/', '_').replace(' ', '_')
- count = len(feature_instances)
- # 计算权重平均值
- weights = [f.get('权重', 0) for f in feature_instances]
- avg_weight = sum(weights) / len(weights) if weights else 0
- avg_weight_str = f"{avg_weight:.2f}"
- html += f'<div class="tree-node tree-node-leaf level-{level + 1}">\n'
- 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'
- html += f' <div style="display:flex; align-items:center; gap:8px;">\n'
- html += f' <span class="tree-node-icon">🔖</span>\n'
- html += f' <span class="tree-node-title">{feature_name_escaped}</span>\n'
- html += f' <span style="font-size:0.8rem; color:#999;">×{count}</span>\n'
- html += f' </div>\n'
- html += f' <span style="font-size:0.8rem; color:#667eea; font-weight:500;">⚖️ {avg_weight_str}</span>\n'
- html += f' </div>\n'
- html += '</div>\n'
- # 2. 再显示子分类(如果有)
- if has_children:
- for child_name, child_data in node_data.items():
- if child_name in ['_meta', '特征列表', '帖子数', '特征数', '帖子列表']:
- continue
- if isinstance(child_data, dict):
- html += self.generate_feature_tree_node_html(
- child_name, child_data, level + 1, point_type, current_path
- )
- html += ' </div>\n'
- html += ' </div>\n'
- html += '</div>\n'
- return html
- def _build_post_to_features_map(self, clustered_data: Dict[str, Any]) -> Dict[str, Dict[str, list]]:
- """从clustered_data构建帖子ID到完整特征对象的映射"""
- post_to_features = {}
- def collect_features(node: Any, point_type: str):
- """递归收集所有特征"""
- if not isinstance(node, dict):
- return
- # 如果有特征列表,收集所有特征
- if '特征列表' in node:
- for feature in node['特征列表']:
- post_id = feature.get('帖子id')
- if post_id:
- if post_id not in post_to_features:
- post_to_features[post_id] = {
- '灵感点列表': [],
- '目的点': [],
- '关键点列表': []
- }
- post_to_features[post_id][point_type].append(feature)
- # 递归处理子节点
- for key, value in node.items():
- if key != '_meta' and isinstance(value, dict):
- collect_features(value, point_type)
- # 收集三种类型的特征
- for point_type in ['灵感点列表', '目的点', '关键点列表']:
- if point_type in clustered_data:
- collect_features(clustered_data[point_type], point_type)
- return post_to_features
- def _collect_post_ids(self, data: Any, post_ids: set):
- """递归收集所有帖子ID"""
- if isinstance(data, dict):
- if '帖子列表' in data:
- post_ids.update(data['帖子列表'])
- for value in data.values():
- self._collect_post_ids(value, post_ids)
- def _generate_html_head(self) -> str:
- """生成HTML头部"""
- return '''<!DOCTYPE html>
- <html lang="zh-CN">
- <head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>选题点分析可视化</title>
- <style>
- * {
- margin: 0;
- padding: 0;
- box-sizing: border-box;
- }
- body {
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- background: #f5f7fa;
- color: #333;
- }
- .tabs-container {
- background: white;
- box-shadow: 0 2px 4px rgba(0,0,0,0.1);
- position: sticky;
- top: 0;
- z-index: 1000;
- }
- .tabs-header {
- display: flex;
- border-bottom: 2px solid #e0e0e0;
- }
- .tab-button {
- flex: 1;
- padding: 16px 24px;
- background: white;
- border: none;
- cursor: pointer;
- font-size: 1rem;
- font-weight: 500;
- color: #666;
- transition: all 0.3s;
- border-bottom: 3px solid transparent;
- }
- .tab-button:hover {
- background: #f5f7fa;
- color: #333;
- }
- .tab-button.active {
- color: #667eea;
- border-bottom-color: #667eea;
- background: #f5f7fa;
- }
- .tab-content {
- display: none;
- padding: 20px;
- }
- .tab-content.active {
- display: block;
- }
- .mindmap-container {
- display: flex;
- flex-direction: column;
- gap: 40px;
- }
- .mindmap-section {
- display: flex;
- align-items: flex-start;
- gap: 20px;
- background: white;
- padding: 20px;
- border-radius: 8px;
- box-shadow: 0 2px 8px rgba(0,0,0,0.1);
- overflow-x: auto;
- }
- .mindmap-root {
- background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
- color: white;
- padding: 12px 20px;
- border-radius: 8px;
- font-weight: 600;
- font-size: 1.1rem;
- white-space: nowrap;
- box-shadow: 0 2px 6px rgba(0,0,0,0.15);
- flex-shrink: 0;
- }
- .mindmap-tree {
- display: flex;
- flex-direction: column;
- gap: 8px;
- flex: 1;
- }
- .tree-node {
- display: grid;
- grid-template-columns: 350px 1fr;
- gap: 10px;
- align-items: start;
- position: relative;
- }
- .tree-node-header {
- padding: 6px 12px;
- cursor: pointer;
- display: flex;
- align-items: center;
- gap: 8px;
- border-radius: 6px;
- transition: all 0.2s ease;
- font-size: 0.9rem;
- grid-column: 1;
- max-width: 350px;
- word-wrap: break-word;
- word-break: break-word;
- line-height: 1.4;
- }
- .tree-node-header:hover {
- transform: translateX(2px);
- box-shadow: 0 2px 6px rgba(0,0,0,0.15);
- }
- .tree-node-original > .tree-node-header {
- background: white;
- color: #333;
- border: 1px solid #e0e0e0;
- }
- .tree-node-llm > .tree-node-header {
- background: #4caf50;
- color: white;
- }
- .tree-node-leaf > .tree-node-header {
- background: white;
- color: #333;
- border: 1px solid #e0e0e0;
- }
- .tree-node-icon {
- font-size: 1rem;
- }
- .toggle-icon {
- cursor: pointer;
- transition: transform 0.2s;
- user-select: none;
- }
- .toggle-icon.expanded {
- transform: rotate(90deg);
- }
- .expand-all-icon {
- cursor: pointer;
- font-size: 0.9rem;
- margin-left: auto;
- padding: 2px 6px;
- border-radius: 4px;
- transition: all 0.2s;
- user-select: none;
- background: rgba(0, 0, 0, 0.1);
- color: #333;
- }
- .expand-all-icon:hover {
- background: rgba(0, 0, 0, 0.2);
- transform: scale(1.1);
- }
- .tree-node-llm .expand-all-icon {
- background: rgba(255, 255, 255, 0.3);
- color: white;
- }
- .tree-node-llm .expand-all-icon:hover {
- background: rgba(255, 255, 255, 0.5);
- }
- .tree-node-title {
- font-weight: 500;
- cursor: pointer;
- }
- .tree-node-title:hover {
- text-decoration: underline;
- }
- .tree-node-content {
- grid-column: 2;
- display: flex;
- flex-direction: column;
- min-width: 0;
- }
- .tree-children {
- display: flex;
- flex-direction: column;
- gap: 6px;
- padding-left: 20px;
- border-left: 2px solid #e0e0e0;
- }
- .modal {
- position: fixed;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- z-index: 9999;
- }
- .modal-overlay {
- position: absolute;
- top: 0;
- left: 0;
- width: 100%;
- height: 100%;
- background: rgba(0, 0, 0, 0.5);
- }
- .modal-content {
- position: absolute;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: white;
- border-radius: 8px;
- max-width: 900px;
- max-height: 85vh;
- width: 95%;
- display: flex;
- flex-direction: column;
- box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
- }
- .modal-header {
- display: flex;
- justify-content: space-between;
- align-items: center;
- padding: 16px 20px;
- border-bottom: 1px solid #e0e0e0;
- }
- .modal-header h3 {
- margin: 0;
- font-size: 1.2rem;
- color: #333;
- }
- .modal-close {
- background: none;
- border: none;
- font-size: 1.5rem;
- cursor: pointer;
- color: #666;
- padding: 0;
- width: 30px;
- height: 30px;
- display: flex;
- align-items: center;
- justify-content: center;
- border-radius: 4px;
- transition: all 0.2s;
- }
- .modal-close:hover {
- background: #f0f0f0;
- color: #333;
- }
- .modal-body {
- padding: 20px;
- overflow-y: auto;
- flex: 1;
- }
- .modal-section {
- margin-bottom: 20px;
- }
- .modal-section:last-child {
- margin-bottom: 0;
- }
- .modal-section h4 {
- margin: 0 0 12px 0;
- color: #667eea;
- font-size: 1rem;
- }
- .modal-section p {
- margin: 8px 0;
- line-height: 1.6;
- color: #555;
- }
- .modal-list {
- list-style: none;
- padding: 0;
- margin: 0;
- }
- .modal-list-item {
- padding: 10px;
- background: #f8f9fa;
- border-radius: 6px;
- margin-bottom: 8px;
- border-left: 3px solid #667eea;
- }
- .modal-list-item strong {
- display: block;
- margin-bottom: 4px;
- color: #333;
- }
- .modal-list-item p {
- margin: 4px 0 0 0;
- font-size: 0.9rem;
- color: #666;
- }
- .post-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
- gap: 8px;
- }
- .post-card {
- padding: 8px 12px;
- background: #e3f2fd;
- border-radius: 6px;
- cursor: pointer;
- transition: all 0.2s;
- text-align: center;
- font-size: 0.85rem;
- color: #1976d2;
- }
- .post-card:hover {
- background: #bbdefb;
- transform: translateY(-2px);
- box-shadow: 0 2px 6px rgba(0, 0, 0, 0.1);
- }
- .post-title {
- font-size: 1.1rem;
- color: #333;
- margin-bottom: 12px;
- }
- .post-images {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 12px;
- margin: 12px 0;
- }
- .post-image {
- width: 100%;
- max-width: 400px;
- height: auto;
- border-radius: 8px;
- border: 1px solid #e0e0e0;
- cursor: pointer;
- transition: transform 0.2s, box-shadow 0.2s;
- }
- .post-image:hover {
- transform: scale(1.02);
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
- }
- /* 叶子节点的封面图 */
- .node-thumbnail {
- width: 40px;
- height: 40px;
- object-fit: cover;
- border-radius: 4px;
- border: 1px solid #e0e0e0;
- margin-left: 8px;
- }
- .tree-node-leaf .tree-node-header {
- padding: 4px 8px;
- }
- .more-images {
- color: #666;
- font-size: 0.9rem;
- font-style: italic;
- margin-top: 8px;
- }
- .post-body {
- font-weight: 600;
- margin-top: 12px;
- margin-bottom: 8px;
- }
- .post-body-text {
- background: #f8f9fa;
- padding: 12px;
- border-radius: 6px;
- border-left: 3px solid #667eea;
- line-height: 1.6;
- color: #555;
- white-space: pre-wrap;
- word-wrap: break-word;
- }
- .post-meta {
- color: #999;
- font-size: 0.85rem;
- margin-top: 12px;
- font-style: italic;
- }
- /* 弹窗内的TAB样式 */
- .modal-tabs-header {
- display: flex;
- gap: 8px;
- padding: 8px;
- background: #f5f7fa;
- border-radius: 6px;
- margin-bottom: 16px;
- overflow-x: auto;
- flex-wrap: wrap;
- }
- .modal-tab-button {
- padding: 8px 16px;
- background: white;
- border: 1px solid #e0e0e0;
- border-radius: 4px;
- cursor: pointer;
- font-size: 0.9rem;
- color: #666;
- transition: all 0.2s;
- white-space: nowrap;
- }
- .modal-tab-button:hover {
- background: #f0f0f0;
- color: #333;
- }
- .modal-tab-button.active {
- background: #667eea;
- color: white;
- border-color: #667eea;
- }
- .modal-tab-content {
- display: none;
- }
- .modal-tab-content.active {
- display: block;
- }
- </style>
- </head>
- '''
- def _generate_javascript(self, posts_dir: Path) -> str:
- """生成JavaScript代码"""
- import json as json_module
- post_cache_json = json_module.dumps(self.post_cache, ensure_ascii=True)
- xuanti_point_map_json = json_module.dumps(self.xuanti_point_map, ensure_ascii=True)
- clustered_data_json = json_module.dumps(self.clustered_data, ensure_ascii=True)
- post_to_features_map_json = json_module.dumps(self.post_to_features_map, ensure_ascii=True)
- js_code = '''<script>
- document.addEventListener('DOMContentLoaded', function() {
- const postCache = ''' + post_cache_json + ''';
- const xuantiPointMap = ''' + xuanti_point_map_json + ''';
- const clusteredData = ''' + clustered_data_json + ''';
- const postToFeaturesMap = ''' + post_to_features_map_json + ''';
- console.log('数据加载完成');
- // Tab切换功能
- function switchTab(tabId) {
- // 隐藏所有tab内容
- document.querySelectorAll('.tab-content').forEach(tab => {
- tab.classList.remove('active');
- });
- // 移除所有按钮的active状态
- document.querySelectorAll('.tab-button').forEach(btn => {
- btn.classList.remove('active');
- });
- // 显示选中的tab
- document.getElementById(tabId).classList.add('active');
- // 激活对应的按钮
- document.querySelector(`[data-tab="${tabId}"]`).classList.add('active');
- }
- function escapeHtml(text) {
- if (!text) return '';
- const div = document.createElement('div');
- div.textContent = text;
- return div.innerHTML;
- }
- function showModal(title, content) {
- const modal = document.getElementById('detail-modal');
- document.getElementById('modal-title').textContent = title;
- document.getElementById('modal-body').innerHTML = content;
- modal.style.display = 'block';
- }
- function closeModal() {
- document.getElementById('detail-modal').style.display = 'none';
- }
- function toggleNode(nodeId, toggleIcon) {
- const content = document.getElementById(nodeId + '-content');
- if (!content) return;
- if (content.style.display === 'none') {
- content.style.display = 'flex';
- if (toggleIcon) toggleIcon.classList.add('expanded');
- } else {
- content.style.display = 'none';
- if (toggleIcon) toggleIcon.classList.remove('expanded');
- }
- }
- function toggleAllChildren(nodeId) {
- const content = document.getElementById(nodeId + '-content');
- if (!content) return;
- // 找到直接子分类节点(排除叶子节点)
- const childrenContainer = content.querySelector('.tree-children');
- if (!childrenContainer) return;
- const directChildren = Array.from(childrenContainer.children).filter(child => {
- return child.classList.contains('tree-node') && !child.classList.contains('tree-node-leaf');
- });
- if (directChildren.length === 0) return;
- // 检查第一个子分类节点的状态,决定是全部展开还是全部收起
- const firstChild = directChildren[0];
- const firstChildContent = firstChild.querySelector('.tree-node-content');
- const shouldExpand = !firstChildContent || firstChildContent.style.display === 'none';
- // 统一操作所有直接子分类节点
- directChildren.forEach(childNode => {
- toggleNodeRecursive(childNode, shouldExpand);
- });
- }
- function toggleNodeRecursive(node, expand) {
- const content = node.querySelector('.tree-node-content');
- const toggleIcon = node.querySelector('.toggle-icon');
- if (content) {
- content.style.display = expand ? 'flex' : 'none';
- }
- if (toggleIcon) {
- if (expand) {
- toggleIcon.classList.add('expanded');
- } else {
- toggleIcon.classList.remove('expanded');
- }
- }
- // 递归处理所有子节点
- if (content) {
- const childNodes = content.querySelectorAll(':scope > .tree-children > .tree-node');
- childNodes.forEach(childNode => {
- if (!childNode.classList.contains('tree-node-leaf')) {
- toggleNodeRecursive(childNode, expand);
- }
- });
- }
- }
- function findNodeData(nodeId) {
- const parts = nodeId.split('_');
- // 处理tab2的节点ID格式: tab2_{point_type}_{path}
- let pointType, pathParts;
- if (parts[0] === 'tab2') {
- pointType = parts[1];
- pathParts = parts.slice(2);
- } else {
- // 处理原来的格式
- pointType = parts[0];
- pathParts = parts.slice(1);
- }
- let data = clusteredData[pointType];
- for (const part of pathParts) {
- if (data && data[part]) data = data[part];
- else return null;
- }
- return data;
- }
- function showPointDetail(nodeId, parentId) {
- const parentData = findNodeData(parentId);
- if (!parentData) return;
- const parts = nodeId.split('_');
- const pointType = parts[0];
- // 提取点名称:如果包含 "kept",则点名称在kept之后
- let pointName;
- const keptIndex = parts.indexOf('kept');
- if (keptIndex !== -1 && keptIndex < parts.length - 1) {
- // 保留的点:格式为 ...node_id_kept_pointName
- pointName = parts.slice(keptIndex + 1).join('_');
- } else {
- // 普通点:格式为 ...node_id_pointName
- pointName = parts[parts.length - 1];
- }
- const pointField = pointType === '灵感点列表' ? '灵感点' : (pointType === '目的点' ? '目的点' : '关键点');
- const points = parentData['点列表'] || [];
- const pointInfo = points.find(p => p[pointField] === pointName);
- if (!pointInfo) {
- showModal(pointName, '<p>未找到点信息</p>');
- return;
- }
- let html = '';
- // 显示点信息
- html += '<div class="modal-section"><h4>点信息</h4>';
- html += '<p><strong>' + escapeHtml(pointInfo[pointField] || '') + '</strong></p>';
- if (pointInfo['描述']) {
- html += '<p>' + escapeHtml(pointInfo['描述']) + '</p>';
- }
- html += '</div>';
- // 获取点对应的帖子ID
- const postId = pointInfo['帖子id'];
- if (!postId) {
- showModal(pointName, html + '<p>该点没有关联的帖子</p>');
- return;
- }
- // 显示帖子详情
- const postData = postCache[postId];
- if (postData) {
- html += '<div class="modal-section"><h4>帖子详情</h4>';
- // 标题
- if (postData.title) {
- html += '<p class="post-title"><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
- }
- // 图片
- const images = postData.images || [];
- if (images.length > 0) {
- html += '<div class="post-images">';
- images.forEach((img, idx) => {
- html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
- });
- html += '</div>';
- }
- // 正文
- if (postData.body_text) {
- html += '<p class="post-body"><strong>正文:</strong></p>';
- html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
- }
- html += '<p class="post-meta">帖子ID: ' + escapeHtml(postId) + '</p>';
- html += '</div>';
- // 显示该帖子的所有选题点(从xuantiPointMap获取完整对象)
- const xuantiPoints = xuantiPointMap[postId];
- if (xuantiPoints) {
- html += '<div class="modal-section"><h4>该帖子的所有选题点</h4>';
- ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
- const pts = xuantiPoints[type];
- if (pts && pts.length > 0) {
- const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
- html += '<h5>' + type + '</h5><ul class="modal-list">';
- pts.forEach(pt => {
- html += '<li class="modal-list-item"><strong>' + escapeHtml(pt[field] || '') + '</strong>';
- if (pt['描述']) html += '<p>' + escapeHtml(pt['描述']) + '</p>';
- html += '</li>';
- });
- html += '</ul>';
- }
- });
- html += '</div>';
- }
- } else {
- html += '<div class="modal-section"><p>无法加载帖子数据 (ID: ' + escapeHtml(postId) + ')</p></div>';
- }
- showModal(pointName, html);
- }
- function showClassificationDetail(nodeId) {
- const nodeData = findNodeData(nodeId);
- if (!nodeData) return;
- const meta = nodeData._meta || {};
- const parts = nodeId.split('_');
- const nodeName = parts[parts.length - 1];
- let html = '';
- if (meta.分类来源 === 'LLM抽象') {
- html += '<div class="modal-section"><h4>合并描述</h4><p>' + escapeHtml(meta.合并描述 || '') + '</p></div>';
- html += '<div class="modal-section"><h4>合并理由</h4><p>' + escapeHtml(meta.合并理由 || '') + '</p></div>';
- const originals = meta.包含的原分类 || [];
- html += '<div class="modal-section"><h4>包含的原分类</h4><ul class="modal-list">';
- originals.forEach(name => { html += '<li class="modal-list-item">' + escapeHtml(name) + '</li>'; });
- html += '</ul></div>';
- } else if (meta.分类来源 === 'LLM细分') {
- html += '<div class="modal-section"><h4>子分类描述</h4><p>' + escapeHtml(meta.子分类描述 || '') + '</p></div>';
- html += '<div class="modal-section"><h4>分类依据</h4><p>' + escapeHtml(meta.分类依据 || '') + '</p></div>';
- } else if (meta.已细分) {
- html += '<div class="modal-section"><h4>细分说明</h4><p>' + escapeHtml(meta.细分说明 || '') + '</p></div>';
- } else {
- html += '<div class="modal-section"><p>这是原始分类,没有额外的元信息。</p></div>';
- }
- showModal(nodeName, html);
- }
- function showPostDetail(postId) {
- const postData = postCache[postId];
- if (!postData) {
- showModal(postId, '<p>无法加载帖子数据</p>');
- return;
- }
- let html = '<div class="modal-section"><h4>帖子基本信息</h4>';
- html += '<p><strong>帖子ID:</strong>' + escapeHtml(postId) + '</p>';
- html += '<p><strong>标题:</strong>' + escapeHtml(postData.title || '无标题') + '</p>';
- html += '</div>';
- // 显示所有图片
- const images = postData.images || [];
- if (images.length > 0) {
- html += '<div class="modal-section"><h4>帖子图片 (' + images.length + ')</h4>';
- html += '<div class="post-images">';
- images.forEach((img, idx) => {
- html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
- });
- html += '</div></div>';
- }
- // 显示正文
- if (postData.body_text) {
- html += '<div class="modal-section"><h4>正文</h4>';
- html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
- html += '</div>';
- }
- // 显示该帖子的选题点信息(从xuantiPointMap获取)
- const xuantiPoints = xuantiPointMap[postId];
- if (xuantiPoints) {
- html += '<div class="modal-section"><h4>选题点信息</h4>';
- ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
- const points = xuantiPoints[type];
- if (points && points.length > 0) {
- const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
- html += '<h5>' + type + ' (' + points.length + ')</h5><ul class="modal-list">';
- points.forEach(point => {
- html += '<li class="modal-list-item"><strong>' + escapeHtml(point[field] || '') + '</strong>';
- if (point['描述']) html += '<p>' + escapeHtml(point['描述']) + '</p>';
- html += '</li>';
- });
- html += '</ul>';
- }
- });
- html += '</div>';
- }
- // 显示该帖子的特征信息(从postToFeaturesMap获取)
- const xuantiFeatures = postToFeaturesMap[postId];
- if (xuantiFeatures && Object.keys(xuantiFeatures).length > 0) {
- html += '<div class="modal-section"><h4>提取的特征</h4>';
- ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
- const features = xuantiFeatures[type];
- if (features && features.length > 0) {
- // 去重特征名称
- const uniqueFeatures = {};
- features.forEach(f => {
- const name = f['特征名称'];
- if (!uniqueFeatures[name]) {
- uniqueFeatures[name] = [];
- }
- uniqueFeatures[name].push(f);
- });
- html += '<h5>' + type + ' (' + Object.keys(uniqueFeatures).length + ' 个特征)</h5>';
- html += '<div class="post-grid">';
- Object.keys(uniqueFeatures).forEach(name => {
- html += '<div class="post-card" style="background:#f0f8ff;">';
- html += escapeHtml(name) + ' (' + uniqueFeatures[name].length + ')';
- html += '</div>';
- });
- html += '</div>';
- }
- });
- html += '</div>';
- }
- showModal(postId, html);
- }
- // 暴露showPostDetail为全局函数,以便在动态生成的HTML中使用
- window.showPostDetail = showPostDetail;
- function showTab1PointDetail(nodeId) {
- // nodeId格式: tab1_{post_id}_{point_type}_{idx}
- const parts = nodeId.split('_');
- const postId = parts[1];
- const pointType = parts.slice(2, -1).join('_');
- const idx = parseInt(parts[parts.length - 1]);
- const xuantiPoint = xuantiPointMap[postId];
- if (!xuantiPoint) {
- showModal('错误', '<p>未找到帖子数据</p>');
- return;
- }
- const points = xuantiPoint[pointType] || [];
- const point = points[idx];
- if (!point) {
- showModal('错误', '<p>未找到点信息</p>');
- return;
- }
- const pointField = pointType === '灵感点列表' ? '灵感点' : (pointType === '目的点' ? '目的点' : '关键点');
- const pointName = point[pointField] || '';
- let html = '<div class="modal-section"><h4>点信息</h4>';
- html += '<p><strong>' + escapeHtml(pointName) + '</strong></p>';
- if (point['描述']) {
- html += '<p>' + escapeHtml(point['描述']) + '</p>';
- }
- html += '</div>';
- // 显示提取的特征
- const features = point['提取的特征'] || [];
- if (features.length > 0) {
- html += '<div class="modal-section"><h4>提取的特征</h4><ul class="modal-list">';
- features.forEach(feature => {
- html += '<li class="modal-list-item">';
- html += '<strong>' + escapeHtml(feature['特征名称'] || '') + '</strong>';
- html += '<p>分类: ' + escapeHtml(feature['一级分类'] || '') + ' / ' + escapeHtml(feature['二级分类'] || '') + '</p>';
- html += '<p>权重: ' + feature['权重'] + '</p>';
- html += '</li>';
- });
- html += '</ul></div>';
- }
- // 显示帖子信息
- const postData = postCache[postId];
- if (postData) {
- html += '<div class="modal-section"><h4>帖子信息</h4>';
- if (postData.title) {
- html += '<p><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
- }
- const images = postData.images || [];
- if (images.length > 0) {
- html += '<div class="post-images">';
- images.forEach((img, idx) => {
- html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
- });
- html += '</div>';
- }
- html += '</div>';
- }
- showModal(pointName, html);
- }
- function showFeatureDetail(featureName, parentId) {
- const parentData = findNodeData(parentId);
- if (!parentData) {
- console.error('无法找到父节点数据:', parentId);
- return;
- }
- const features = parentData['特征列表'] || [];
- const matchedFeatures = features.filter(f => f['特征名称'] === featureName);
- if (matchedFeatures.length === 0) {
- showModal(featureName, '<p>未找到特征信息</p>');
- return;
- }
- // 收集所有相关的帖子ID(去重)
- const uniquePostIds = [];
- const postIdSet = new Set();
- matchedFeatures.forEach(feature => {
- const postId = feature['帖子id'];
- if (postId && postCache[postId] && !postIdSet.has(postId)) {
- postIdSet.add(postId);
- uniquePostIds.push(postId);
- }
- });
- if (uniquePostIds.length === 0) {
- showModal(featureName, '<p>未找到相关帖子</p>');
- return;
- }
- let html = '';
- // 如果有多个帖子,生成TAB切换界面
- if (uniquePostIds.length > 1) {
- html += '<div class="modal-tabs-header">';
- uniquePostIds.forEach((postId, index) => {
- const postData = postCache[postId];
- const tabTitle = postData.title ? postData.title.substring(0, 15) + '...' : 'Post ' + (index + 1);
- const activeClass = index === 0 ? ' active' : '';
- html += '<button class="modal-tab-button' + activeClass + '" data-tab-id="post-tab-' + index + '">';
- html += escapeHtml(tabTitle);
- html += '</button>';
- });
- html += '</div>';
- }
- // 为每个帖子生成完整信息
- uniquePostIds.forEach((postId, index) => {
- const postData = postCache[postId];
- const activeClass = index === 0 ? ' active' : '';
- html += '<div class="modal-tab-content' + activeClass + '" id="post-tab-' + index + '">';
- // 特征基本信息
- html += '<div class="modal-section"><h4>特征:' + escapeHtml(featureName) + '</h4>';
- const postFeatures = matchedFeatures.filter(f => f['帖子id'] === postId);
- if (postFeatures.length > 0) {
- html += '<p>权重: ' + (postFeatures[0]['权重'] || 0) + '</p>';
- html += '<p>来自点: ' + escapeHtml(postFeatures[0]['所属点'] || '') + '</p>';
- if (postFeatures[0]['点描述']) {
- html += '<p style="font-size:0.9rem; color:#666;">点描述: ' + escapeHtml(postFeatures[0]['点描述']) + '</p>';
- }
- }
- html += '</div>';
- // 帖子完整信息
- html += '<div class="modal-section"><h4>帖子详情</h4>';
- html += '<p><strong>帖子ID:</strong>' + escapeHtml(postId) + '</p>';
- if (postData.title) {
- html += '<p class="post-title"><strong>标题:</strong>' + escapeHtml(postData.title) + '</p>';
- }
- // 图片
- const images = postData.images || [];
- if (images.length > 0) {
- html += '<div class="post-images">';
- images.forEach((img, idx) => {
- html += '<img src="' + escapeHtml(img) + '" alt="图片' + (idx + 1) + '" class="post-image" loading="lazy" onclick="window.open(this.src)">';
- });
- html += '</div>';
- }
- // 正文
- if (postData.body_text) {
- html += '<p class="post-body"><strong>正文:</strong></p>';
- html += '<p class="post-body-text">' + escapeHtml(postData.body_text) + '</p>';
- }
- // 帖子元信息
- if (postData.publish_time) {
- html += '<p class="post-meta">发布时间: ' + escapeHtml(postData.publish_time) + '</p>';
- }
- if (postData.like_count !== undefined) {
- html += '<p class="post-meta">点赞: ' + postData.like_count + ' | 收藏: ' + (postData.collect_count || 0) + ' | 评论: ' + (postData.comment_count || 0) + '</p>';
- }
- html += '</div>';
- // 该帖子的所有选题点
- const xuantiPoints = xuantiPointMap[postId];
- if (xuantiPoints) {
- html += '<div class="modal-section"><h4>该帖子的所有选题点</h4>';
- ['灵感点列表', '目的点', '关键点列表'].forEach(type => {
- const pts = xuantiPoints[type];
- if (pts && pts.length > 0) {
- const field = type === '灵感点列表' ? '灵感点' : (type === '目的点' ? '目的点' : '关键点');
- html += '<h5>' + type + '</h5><ul class="modal-list">';
- pts.forEach(pt => {
- html += '<li class="modal-list-item"><strong>' + escapeHtml(pt[field] || '') + '</strong>';
- if (pt['描述']) html += '<p>' + escapeHtml(pt['描述']) + '</p>';
- // 显示提取的特征
- const ptFeatures = pt['提取的特征'] || [];
- if (ptFeatures.length > 0) {
- html += '<p style="font-size:0.85rem; margin-top:8px;"><strong>特征:</strong> ';
- ptFeatures.forEach((f, idx) => {
- if (idx > 0) html += ', ';
- html += escapeHtml(f['特征名称'] || '');
- });
- html += '</p>';
- }
- html += '</li>';
- });
- html += '</ul>';
- }
- });
- html += '</div>';
- }
- html += '</div>'; // 结束 modal-tab-content
- });
- showModal(featureName, html);
- // 添加TAB切换事件监听
- setTimeout(() => {
- document.querySelectorAll('.modal-tab-button').forEach(btn => {
- btn.addEventListener('click', function() {
- const tabId = this.getAttribute('data-tab-id');
- // 移除所有active状态
- document.querySelectorAll('.modal-tab-button').forEach(b => b.classList.remove('active'));
- document.querySelectorAll('.modal-tab-content').forEach(c => c.classList.remove('active'));
- // 激活选中的tab
- this.classList.add('active');
- document.getElementById(tabId).classList.add('active');
- });
- });
- }, 100);
- }
- document.body.addEventListener('click', function(e) {
- const target = e.target.closest('[data-action]');
- if (!target) {
- if (!e.target.closest('.modal-content')) closeModal();
- return;
- }
- const action = target.getAttribute('data-action');
- const nodeId = target.getAttribute('data-node-id');
- switch(action) {
- case 'toggle-node':
- toggleNode(nodeId, target);
- break;
- case 'toggle-all-children':
- toggleAllChildren(nodeId);
- break;
- case 'show-point-detail':
- const parentId = target.getAttribute('data-parent-id');
- showPointDetail(nodeId, parentId);
- break;
- case 'show-tab1-point-detail':
- showTab1PointDetail(nodeId);
- break;
- case 'show-feature-detail':
- const featureParentId = target.getAttribute('data-parent-id');
- const featureName = target.getAttribute('data-feature-name');
- showFeatureDetail(featureName, featureParentId);
- break;
- case 'show-classification-detail':
- showClassificationDetail(nodeId);
- break;
- case 'show-post':
- const postId = target.getAttribute('data-post-id');
- if (postId) showPostDetail(postId);
- break;
- case 'close-modal':
- closeModal();
- break;
- }
- if (target.tagName === 'A') e.preventDefault();
- });
- // Tab切换事件监听
- document.querySelectorAll('.tab-button').forEach(btn => {
- btn.addEventListener('click', function() {
- const tabId = this.getAttribute('data-tab');
- switchTab(tabId);
- });
- });
- // Tab3: 维度关联分析交互功能
- let dimensionAssociations = ''' + json_module.dumps(self.dimension_associations, ensure_ascii=True) + ''';
- let expandedOrthogonalCombinations = ''' + json_module.dumps(getattr(self, 'expanded_orthogonal_combinations', None), ensure_ascii=True) + ''';
- let enrichedXuantiPointMap = ''' + json_module.dumps(getattr(self, 'enriched_xuanti_point_map', None), ensure_ascii=True) + ''';
- let currentMode = 'single'; // 当前模式:single(单维度)或 three(三维正交)
- // 模式切换
- document.querySelectorAll('.mode-button').forEach(btn => {
- btn.addEventListener('click', function() {
- const mode = this.getAttribute('data-mode');
- currentMode = mode;
- // 更新按钮样式
- document.querySelectorAll('.mode-button').forEach(b => {
- if (b.getAttribute('data-mode') === mode) {
- b.style.background = 'white';
- b.style.boxShadow = '0 2px 4px rgba(0,0,0,0.1)';
- b.classList.add('active');
- } else {
- b.style.background = 'transparent';
- b.style.boxShadow = 'none';
- b.classList.remove('active');
- }
- });
- // 更新描述
- document.getElementById('desc-single').style.display = mode === 'single' ? 'inline' : 'none';
- document.getElementById('desc-three').style.display = mode === 'three' ? 'inline' : 'none';
- // 切换布局显示
- const singleLayout = document.getElementById('single-dim-layout');
- const threeLayout = document.getElementById('three-dim-layout');
- if (mode === 'single') {
- singleLayout.style.display = 'grid';
- threeLayout.style.display = 'none';
- } else {
- singleLayout.style.display = 'none';
- threeLayout.style.display = 'block';
- // 自动加载所有三维正交扩展结果
- loadAllOrthogonalResults();
- }
- // 清除所有高亮和封面图
- clearAllHighlights();
- });
- });
- function clearAllHighlights() {
- // 隐藏所有封面图容器
- document.querySelectorAll('.tab3-thumbnails').forEach(t => {
- t.style.display = 'none';
- t.innerHTML = '';
- });
- // 清除所有高亮
- document.querySelectorAll('.tab3-node-header').forEach(h => {
- const originalBg = h.getAttribute('data-original-bg');
- const originalBorder = h.getAttribute('data-original-border');
- h.style.backgroundColor = originalBg || '';
- h.style.border = originalBorder || '';
- h.style.borderWidth = '';
- h.style.boxShadow = '0 1px 2px rgba(0,0,0,0.05)';
- h.style.fontWeight = '';
- h.style.backgroundImage = '';
- h.style.backgroundOrigin = '';
- h.style.backgroundClip = '';
- // 删除所有组号标签(包括容器)
- const badges = h.querySelectorAll('.group-badge');
- badges.forEach(b => b.remove());
- // 删除Jaccard相似度标签
- const jaccardBadges = h.querySelectorAll('.jaccard-badge');
- jaccardBadges.forEach(b => b.remove());
- // 删除组号标签容器(如果是独立的span)
- const badgeContainers = Array.from(h.children).filter(child =>
- child.tagName === 'SPAN' && child.style.display === 'inline-flex'
- );
- badgeContainers.forEach(bc => bc.remove());
- });
- }
- // Tab3树节点切换
- document.addEventListener('click', function(e) {
- const toggleBtn = e.target.closest('.tab3-toggle');
- if (toggleBtn) {
- const targetId = toggleBtn.getAttribute('data-target');
- const content = document.getElementById(targetId);
- if (content) {
- if (content.style.display === 'none' || content.style.display === '') {
- content.style.display = 'block';
- toggleBtn.textContent = '▼';
- } else {
- content.style.display = 'none';
- toggleBtn.textContent = '▶';
- }
- }
- e.stopPropagation();
- }
- });
- // Tab3节点点击高亮关联分类
- document.addEventListener('click', function(e) {
- const header = e.target.closest('.tab3-node-header');
- if (header && !e.target.closest('.tab3-toggle')) {
- const clickedDimension = header.getAttribute('data-dimension');
- const clickedPath = header.getAttribute('data-path');
- const nodeId = header.getAttribute('data-node-id');
- console.log('点击了分类:', clickedDimension, clickedPath, '模式:', currentMode);
- // 清除所有高亮和封面图
- clearAllHighlights();
- // 高亮当前点击的节点 - 使用蓝色
- header.style.borderColor = '#2196F3';
- header.style.borderWidth = '3px';
- header.style.boxShadow = '0 0 12px rgba(33, 150, 243, 0.6)';
- header.style.fontWeight = '600';
- // 显示该分类下的帖子封面图
- showCategoryThumbnails(clickedDimension, clickedPath, nodeId);
- // 根据模式执行不同的关联查找
- if (currentMode === 'single') {
- // 单维度关联模式
- highlightSingleDimensionAssociations(clickedDimension, clickedPath);
- } else if (currentMode === 'three') {
- // 三维正交关联模式
- if (clickedDimension === '灵感点列表') {
- highlightThreeDimensionAssociations(clickedPath);
- } else {
- alert('三维正交关联模式只支持点击灵感点分类');
- }
- }
- }
- });
- function highlightSingleDimensionAssociations(sourceDimension, sourcePath) {
- if (!dimensionAssociations || !dimensionAssociations['单维度关联分析']) {
- console.log('dimensionAssociations数据不存在');
- return;
- }
- const singleDimAssociations = dimensionAssociations['单维度关联分析'];
- // 维度名称映射:从data-dimension的值映射到JSON中的维度名称
- const dimensionNameMap = {
- '灵感点列表': '灵感点维度',
- '目的点': '目的点维度',
- '关键点列表': '关键点维度'
- };
- const sourceDimensionKey = dimensionNameMap[sourceDimension];
- if (!sourceDimensionKey || !singleDimAssociations[sourceDimensionKey]) {
- console.log('未找到维度:', sourceDimension, sourceDimensionKey);
- return;
- }
- const dimensionData = singleDimAssociations[sourceDimensionKey];
- console.log('查找关联关系,维度:', sourceDimensionKey, '路径:', sourcePath);
- let foundCount = 0;
- // 遍历该维度下的所有关联关系(如 灵感点→目的点、灵感点→关键点)
- for (const relationKey in dimensionData) {
- if (relationKey === '说明') continue;
- const relationData = dimensionData[relationKey];
- // 在该关联关系中查找匹配sourcePath的分类
- if (relationData[sourcePath]) {
- const categoryData = relationData[sourcePath];
- console.log('找到关联关系:', relationKey, '分类:', sourcePath);
- // 确定目标维度和关联字段名
- let targetDimension, associationFieldName;
- if (relationKey.includes('→目的点')) {
- targetDimension = '目的点';
- associationFieldName = '与目的点的关联';
- } else if (relationKey.includes('→关键点')) {
- targetDimension = '关键点列表';
- associationFieldName = '与关键点的关联';
- } else if (relationKey.includes('→灵感点')) {
- targetDimension = '灵感点列表';
- associationFieldName = '与灵感点的关联';
- }
- // 获取关联列表并高亮所有目标分类
- const associations = categoryData[associationFieldName] || [];
- console.log('关联数量:', associations.length);
- associations.forEach(assoc => {
- const targetPath = assoc['目标分类'];
- const commonPostIds = assoc['共同帖子ID'] || [];
- const jaccardSimilarity = assoc['Jaccard相似度'] || 0;
- if (targetPath) {
- console.log('高亮目标:', targetDimension, targetPath, '共同帖子数:', commonPostIds.length, 'Jaccard:', jaccardSimilarity);
- highlightNodeByPath(targetDimension, targetPath, commonPostIds, jaccardSimilarity);
- foundCount++;
- }
- });
- }
- }
- if (foundCount === 0) {
- console.log('未找到任何关联');
- } else {
- console.log('共高亮', foundCount, '个关联分类');
- }
- }
- function highlightNodeByPath(dimension, path, commonPostIds, jaccardSimilarity) {
- // 根据维度选择不同的颜色
- let borderColor, bgColor, boxShadowColor;
- if (dimension === '灵感点列表') {
- // 灵感点:紫色
- borderColor = '#9C27B0';
- bgColor = '#F3E5F5';
- boxShadowColor = 'rgba(156, 39, 176, 0.7)';
- } else if (dimension === '目的点') {
- // 目的点:粉红色
- borderColor = '#E91E63';
- bgColor = '#FCE4EC';
- boxShadowColor = 'rgba(233, 30, 99, 0.7)';
- } else if (dimension === '关键点列表') {
- // 关键点:橙色
- borderColor = '#FF9800';
- bgColor = '#FFF3E0';
- boxShadowColor = 'rgba(255, 152, 0, 0.7)';
- } else {
- // 默认颜色
- borderColor = '#E91E63';
- bgColor = '#FCE4EC';
- boxShadowColor = 'rgba(233, 30, 99, 0.7)';
- }
- const headers = document.querySelectorAll('.tab3-node-header');
- headers.forEach(header => {
- if (header.getAttribute('data-dimension') === dimension &&
- header.getAttribute('data-path') === path) {
- // 使用根据维度选择的颜色高亮关联节点
- header.style.borderColor = borderColor;
- header.style.borderWidth = '3px';
- header.style.boxShadow = `0 0 16px ${boxShadowColor}`;
- header.style.backgroundColor = bgColor;
- header.style.fontWeight = '600';
- // 添加Jaccard相似度标签
- const existingBadge = header.querySelector('.jaccard-badge');
- if (existingBadge) {
- existingBadge.remove();
- }
- if (jaccardSimilarity !== undefined && jaccardSimilarity !== null) {
- const badge = document.createElement('span');
- badge.className = 'jaccard-badge';
- badge.textContent = `J: ${(jaccardSimilarity * 100).toFixed(1)}%`;
- badge.style.cssText = `
- display: inline-block;
- background: ${borderColor};
- color: white;
- padding: 2px 6px;
- border-radius: 8px;
- font-size: 0.7rem;
- font-weight: 600;
- margin-left: 8px;
- vertical-align: middle;
- `;
- badge.title = `Jaccard相似度: ${(jaccardSimilarity * 100).toFixed(2)}%`;
- // 插入到节点文本后面
- const textNode = Array.from(header.childNodes).find(node => node.nodeType === Node.TEXT_NODE);
- if (textNode) {
- textNode.after(badge);
- } else {
- header.appendChild(badge);
- }
- }
- // 为关联的分类显示共同帖子的封面图
- const nodeId = header.getAttribute('data-node-id');
- if (nodeId && commonPostIds && commonPostIds.length > 0) {
- showCategoryThumbnails(dimension, path, nodeId, commonPostIds);
- }
- }
- });
- }
- // 生成唯一颜色的函数,使用HSL色彩空间
- function generateUniqueColor(index, total) {
- // 使用黄金角度(137.5°)分布色相,确保颜色尽可能分散
- const hue = (index * 137.5) % 360;
- // 饱和度在60%-90%之间变化
- const saturation = 60 + (index % 4) * 10;
- // 亮度在45%-55%之间变化,确保颜色既鲜艳又不刺眼
- const lightness = 45 + (index % 3) * 5;
- const borderColor = `hsl(${hue}, ${saturation}%, ${lightness}%)`;
- const bgColor = `hsl(${hue}, ${saturation}%, ${lightness + 40}%)`;
- return { border: borderColor, bg: bgColor };
- }
- // 自动加载所有灵感点的三维正交扩展结果
- function loadAllOrthogonalResults() {
- if (!expandedOrthogonalCombinations || !expandedOrthogonalCombinations['聚类结果'] || !expandedOrthogonalCombinations['聚类结果']['聚类详情']) {
- console.log('聚类结果数据不存在');
- document.getElementById('orthogonal-results').innerHTML = `
- <div style="text-align: center; padding: 60px 20px; color: #999;">
- <p style="font-size: 1.1rem; margin-bottom: 10px;">未加载聚类结果数据</p>
- </div>
- `;
- return;
- }
- const expandedResults = expandedOrthogonalCombinations['聚类结果']['聚类详情'];
- console.log('加载所有聚类结果,共', expandedResults.length, '组');
- const resultsContainer = document.getElementById('orthogonal-results');
- let html = '';
- html += `<div style="margin-bottom: 20px; padding: 16px; background: #f0f7ff; border-radius: 8px; border-left: 4px solid #667eea;">`;
- html += `<h3 style="color: #667eea; margin: 0; font-size: 1.1rem;">🌟 三维正交扩展结果(共 ${expandedResults.length} 组)</h3>`;
- html += `<p style="color: #666; font-size: 0.85rem; margin: 8px 0 0 0;">点击每组标题可快速展开/收起</p>`;
- html += `</div>`;
- // 遍历所有组
- expandedResults.forEach((result, index) => {
- // 新数据结构:result是聚类详情中的一项
- const dimensionMerge = result['维度合并'];
- const clusterSize = result['聚类大小'];
- const coveragePosts = result['聚类覆盖帖子数'];
- // 获取各维度的第一个叶子分类用于显示
- const lingganLeafClasses = dimensionMerge['灵感点'].map(item => item['叶子分类']);
- const mudiLeafClasses = dimensionMerge['目的点'].map(item => item['叶子分类']);
- const guanjianLeafClasses = dimensionMerge['关键点'].map(item => item['叶子分类']);
- const groupId = `group-${index}`;
- // 组容器 - 添加点击展开/收起功能
- html += `<div style="margin-bottom: 20px; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.1);">`;
- // 组头部 - 可点击展开/收起,添加sticky定位
- 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);">`;
- html += `<div style="flex: 1; display: flex; flex-direction: column; gap: 8px;">`;
- // 第一行:聚类号 + 大小 + 覆盖帖子数
- html += `<div style="display: flex; align-items: center; gap: 12px;">`;
- 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>`;
- html += `<span style="background: rgba(255,255,255,0.15); padding: 4px 10px; border-radius: 12px; font-size: 0.85rem;">组合数: ${clusterSize}</span>`;
- html += `<span style="background: rgba(255,255,255,0.15); padding: 4px 10px; border-radius: 12px; font-size: 0.85rem;">覆盖帖子: ${coveragePosts}</span>`;
- html += `</div>`;
- // 第二行:半选题维度(显示前几个)
- html += `<div style="display: flex; align-items: center; gap: 8px; font-size: 0.85rem; flex-wrap: wrap;">`;
- 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>`;
- html += `<span style="color: rgba(255,255,255,0.7);">+</span>`;
- 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>`;
- html += `<span style="color: rgba(255,255,255,0.7);">+</span>`;
- 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>`;
- html += `</div>`;
- html += `</div>`;
- html += `<div id="${groupId}-icon" style="font-size: 1.2rem; transition: transform 0.3s; flex-shrink: 0;">▼</div>`;
- html += `</div>`;
- // 组内容 - 默认展开
- html += `<div id="${groupId}" style="background: #f8f9fa; padding: 20px;">`;
- // 调用原有的showOrthogonalResults逻辑,但嵌入到这里
- html += generateGroupContent(result, index);
- html += `</div>`;
- html += `</div>`;
- });
- resultsContainer.innerHTML = html;
- }
- // 展开/收起组的函数
- window.toggleGroup = function(groupId) {
- const content = document.getElementById(groupId);
- const icon = document.getElementById(groupId + '-icon');
- if (content.style.display === 'none') {
- content.style.display = 'block';
- icon.style.transform = 'rotate(0deg)';
- icon.textContent = '▼';
- } else {
- content.style.display = 'none';
- icon.style.transform = 'rotate(-90deg)';
- icon.textContent = '▶';
- }
- };
- // 生成单组内容的函数
- function generateGroupContent(result, index) {
- // 新数据结构:result是聚类详情中的一项
- const dimensionMerge = result['维度合并'];
- const dimensionExpansion = result['维度扩展'];
- let html = '';
- // 扩展结果 - 表格式
- const expandId = `expand-${index}`;
- html += `<div style="background: white; padding: 16px; border-radius: 8px; margin-bottom: 12px; border-left: 4px solid #4caf50;">`;
- // 计算各维度的扩展组合总数
- const lingganExpandList = dimensionExpansion ? dimensionExpansion['灵感点'] : [];
- const mudiExpandList = dimensionExpansion ? dimensionExpansion['目的点'] : [];
- const guanjianExpandList = dimensionExpansion ? dimensionExpansion['关键点'] : [];
- // 获取各维度的组合数(累加所有叶子分类的扩展组合数)
- let lingganCount = 0;
- if (lingganExpandList && lingganExpandList.length > 0) {
- lingganExpandList.forEach(item => {
- lingganCount += (item['扩展组合数'] || 0);
- });
- }
- let mudiCount = 0;
- if (mudiExpandList && mudiExpandList.length > 0) {
- mudiExpandList.forEach(item => {
- mudiCount += (item['扩展组合数'] || 0);
- });
- }
- let guanjianTotalCount = 0;
- if (guanjianExpandList && guanjianExpandList.length > 0) {
- guanjianExpandList.forEach(item => {
- guanjianTotalCount += (item['扩展组合数'] || 0);
- });
- }
- // 计算总的扩展组合数(不是笛卡尔积,是各维度的总和)
- const totalCombinations = lingganCount + mudiCount + guanjianTotalCount;
- 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';">`;
- html += `<h4 style="color: #4caf50; margin: 0; font-size: 0.95rem;">🌟 半选题维度的点模式映射(共 ${totalCombinations} 个扩展组合)</h4>`;
- html += `<span style="color: #999; font-size: 0.85rem;">点击展开/收起</span>`;
- html += `</div>`;
- html += `<div id="${expandId}" style="display: none; margin-top: 12px; padding-top: 12px; border-top: 1px solid #e0e0e0;">`;
- // 获取组合详情,为每个叶子分类保存其组合列表(新数据结构:维度扩展是数组)
- const lingganCombosList = [];
- const lingganMergeInfo = [];
- if (lingganExpandList && lingganExpandList.length > 0) {
- lingganExpandList.forEach(item => {
- const combos = item['组合详情'] || [];
- // 过滤:只保留包含扩展叶子分类的组合(叶子分类组合长度>1表示有扩展)
- const filteredCombos = combos.filter(combo => {
- const leafCombo = combo['叶子分类组合'] || [];
- return leafCombo.length > 1; // 只有一个叶子分类表示没有扩展
- });
- lingganCombosList.push({
- leafClass: item['叶子分类'],
- combos: filteredCombos
- });
- lingganMergeInfo.push({
- leafClass: item['叶子分类'],
- frequency: item['出现频率']
- });
- });
- }
- const mudiCombosList = [];
- const mudiMergeInfo = [];
- if (mudiExpandList && mudiExpandList.length > 0) {
- mudiExpandList.forEach(item => {
- const combos = item['组合详情'] || [];
- const filteredCombos = combos.filter(combo => {
- const leafCombo = combo['叶子分类组合'] || [];
- return leafCombo.length > 1;
- });
- mudiCombosList.push({
- leafClass: item['叶子分类'],
- combos: filteredCombos
- });
- mudiMergeInfo.push({
- leafClass: item['叶子分类'],
- frequency: item['出现频率']
- });
- });
- }
- const guanjianCombosList = [];
- const guanjianMergeInfo = [];
- if (guanjianExpandList && guanjianExpandList.length > 0) {
- guanjianExpandList.forEach(item => {
- const combos = item['组合详情'] || [];
- const filteredCombos = combos.filter(combo => {
- const leafCombo = combo['叶子分类组合'] || [];
- return leafCombo.length > 1;
- });
- guanjianCombosList.push({
- keyLeafClass: item['叶子分类'],
- combos: filteredCombos
- });
- guanjianMergeInfo.push({
- leafClass: item['叶子分类'],
- frequency: item['出现频率']
- });
- });
- }
- // 计算最大行数(所有叶子分类的扩展组合数的最大值)
- let maxRows = 0;
- lingganCombosList.forEach(item => {
- maxRows = Math.max(maxRows, item.combos.length);
- });
- mudiCombosList.forEach(item => {
- maxRows = Math.max(maxRows, item.combos.length);
- });
- guanjianCombosList.forEach(item => {
- maxRows = Math.max(maxRows, item.combos.length);
- });
- // 计算表格总列数:标签列 + 灵感点列数 + 目的点列数 + 关键点列数
- const lingganColCount = lingganMergeInfo.length || 1;
- const mudiColCount = mudiMergeInfo.length || 1;
- const guanjianColCount = guanjianMergeInfo.length || 1;
- const totalColumns = 1 + lingganColCount + mudiColCount + guanjianColCount;
- html += `<div style="overflow-x: auto;">`;
- html += `<table style="width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden;">`;
- // 表头
- html += `<thead>`;
- html += `<tr style="background: #667eea; color: white;">`;
- html += `<th style="padding: 12px; text-align: left; font-size: 0.85rem; border-right: 1px solid rgba(255,255,255,0.2);">半选题:</th>`;
- // 灵感点表头 - 跨多列
- 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>`;
- // 目的点表头 - 跨多列
- 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>`;
- // 关键点表头 - 跨多列
- html += `<th colspan="${guanjianColCount}" style="padding: 12px; text-align: center; font-size: 0.85rem;">🔑 关键点</th>`;
- html += `</tr>`;
- // 半选题值行(显示叶子分类名称)
- html += `<tr style="background: #f5f5f5; font-weight: 600;">`;
- html += `<td style="padding: 8px; text-align: center; font-size: 0.75rem; color: #999; border-right: 1px solid #e0e0e0;">半选题</td>`;
- // 灵感点叶子分类 - 每个占一列
- lingganMergeInfo.forEach((info, idx) => {
- html += `<td style="padding: 10px; text-align: center; ${idx < lingganMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
- html += `</td>`;
- });
- // 目的点叶子分类 - 每个占一列
- mudiMergeInfo.forEach((info, idx) => {
- html += `<td style="padding: 10px; text-align: center; ${idx < mudiMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
- html += `</td>`;
- });
- // 关键点叶子分类 - 每个占一列
- guanjianMergeInfo.forEach((info, idx) => {
- html += `<td style="padding: 10px; text-align: center; ${idx < guanjianMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
- html += `<span style="background: #e3f2fd; color: #1976d2; padding: 4px 10px; border-radius: 4px; font-size: 0.8rem; display: inline-block;">${escapeHtml(info.leafClass)}</span>`;
- html += `</td>`;
- });
- html += `</tr>`;
- // 频率行(显示每个叶子分类的出现频率)
- html += `<tr style="background: #fafafa;">`;
- 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>`;
- // 灵感点频率
- lingganMergeInfo.forEach((info, idx) => {
- 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;">`;
- html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
- html += `</td>`;
- });
- // 目的点频率
- mudiMergeInfo.forEach((info, idx) => {
- 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;">`;
- html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
- html += `</td>`;
- });
- // 关键点频率
- guanjianMergeInfo.forEach((info, idx) => {
- html += `<td style="padding: 8px; text-align: center; ${idx < guanjianMergeInfo.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''} border-bottom: 2px solid #667eea;">`;
- html += `<span style="color: #666; font-size: 0.75rem; font-weight: 600;">${(info.frequency * 100).toFixed(0)}%</span>`;
- html += `</td>`;
- });
- html += `</tr>`;
- // 箭头行
- html += `<tr style="background: white;">`;
- 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>`;
- // 灵感点箭头 - 每列一个箭头
- lingganMergeInfo.forEach((info, idx) => {
- 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>`;
- });
- // 目的点箭头 - 每列一个箭头
- mudiMergeInfo.forEach((info, idx) => {
- 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>`;
- });
- // 关键点箭头 - 每列一个箭头
- guanjianMergeInfo.forEach((info, idx) => {
- 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>`;
- });
- html += `</tr>`;
- // 扩展标题行
- html += `<tr style="background: #4caf50; color: white;">`;
- html += `<th colspan="${totalColumns}" style="padding: 10px; text-align: left; font-size: 0.85rem;">🌟 点模式映射(仅显示扩展的叶子分类)</th>`;
- html += `</tr>`;
- html += `</thead>`;
- html += `<tbody>`;
- // 数据行 - 并列显示各维度的扩展组合(只显示扩展的叶子分类)
- for (let rowIdx = 0; rowIdx < maxRows; rowIdx++) {
- const rowBg = rowIdx % 2 === 0 ? '#fafafa' : 'white';
- html += `<tr style="background: ${rowBg}; border-bottom: 1px solid #e0e0e0;">`;
- 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>`;
- // 灵感点列 - 每个叶子分类占一列,只显示新增的扩展分类
- lingganCombosList.forEach((lingganInfo, lingganIdx) => {
- html += `<td style="padding: 8px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- if (rowIdx < lingganInfo.combos.length) {
- const combo = lingganInfo.combos[rowIdx];
- const leafCombo = combo['叶子分类组合'] || [];
- // 过滤掉原始叶子分类,只显示新增的扩展分类
- const newLeafs = leafCombo.filter(leaf => leaf !== lingganInfo.leafClass);
- if (newLeafs.length > 0) {
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
- newLeafs.forEach(leaf => {
- html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
- });
- html += `</div>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- // 目的点列 - 每个叶子分类占一列,只显示新增的扩展分类
- mudiCombosList.forEach((mudiInfo, mudiIdx) => {
- html += `<td style="padding: 8px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- if (rowIdx < mudiInfo.combos.length) {
- const combo = mudiInfo.combos[rowIdx];
- const leafCombo = combo['叶子分类组合'] || [];
- // 过滤掉原始叶子分类,只显示新增的扩展分类
- const newLeafs = leafCombo.filter(leaf => leaf !== mudiInfo.leafClass);
- if (newLeafs.length > 0) {
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
- newLeafs.forEach(leaf => {
- html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
- });
- html += `</div>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- // 关键点列 - 每个叶子分类占一列,只显示新增的扩展分类
- guanjianCombosList.forEach((keyInfo, keyIdx) => {
- html += `<td style="padding: 8px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
- if (rowIdx < keyInfo.combos.length) {
- const guanjianCombo = keyInfo.combos[rowIdx];
- const leafCombo = guanjianCombo['叶子分类组合'] || [];
- // 过滤掉原始叶子分类,只显示新增的扩展分类
- const newLeafs = leafCombo.filter(leaf => leaf !== keyInfo.keyLeafClass);
- if (newLeafs.length > 0) {
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; justify-content: center;">`;
- newLeafs.forEach(leaf => {
- html += `<span style="background: #fff3e0; color: #f57c00; padding: 3px 8px; border-radius: 3px; font-size: 0.75rem; font-weight: 500;">${escapeHtml(leaf)} ✨</span>`;
- });
- html += `</div>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- html += `</tr>`;
- }
- // 组合分类合并行
- html += `<tr style="background: #e8f5e9; border-top: 2px solid #4caf50;">`;
- html += `<td style="padding: 10px; text-align: center; font-size: 0.8rem; font-weight: 600; color: #2e7d32; border-right: 1px solid #e0e0e0;">组合分类合并</td>`;
- // 灵感点扩展分类合并 - 每个叶子分类占一列
- lingganCombosList.forEach((lingganInfo, lingganIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- const item = lingganExpandList.find(x => x['叶子分类'] === lingganInfo.leafClass);
- const merged = item ? item['扩展分类合并'] : '';
- if (merged) {
- 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>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- // 目的点扩展分类合并 - 每个叶子分类占一列
- mudiCombosList.forEach((mudiInfo, mudiIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- const item = mudiExpandList.find(x => x['叶子分类'] === mudiInfo.leafClass);
- const merged = item ? item['扩展分类合并'] : '';
- if (merged) {
- 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>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- // 关键点扩展分类合并 - 每个叶子分类占一列
- guanjianCombosList.forEach((keyInfo, keyIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
- const item = guanjianExpandList.find(x => x['叶子分类'] === keyInfo.keyLeafClass);
- const merged = item ? item['扩展分类合并'] : '';
- if (merged) {
- 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>`;
- } else {
- html += `<span style="color: #ccc;">-</span>`;
- }
- html += `</td>`;
- });
- html += `</tr>`;
- // 完整选题结果行
- html += `<tr style="background: #f3e5f5; border-top: 2px solid #9c27b0;">`;
- html += `<td style="padding: 10px; text-align: center; font-size: 0.8rem; font-weight: 600; color: #7b1fa2; border-right: 1px solid #e0e0e0;">🎉 完整选题结果</td>`;
- // 灵感点完整选题 - 每个叶子分类占一列
- lingganCombosList.forEach((lingganInfo, lingganIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${lingganIdx < lingganCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- const item = lingganExpandList.find(x => x['叶子分类'] === lingganInfo.leafClass);
- const merged = item ? item['扩展分类合并'] : '';
- // 使用flex布局让内容更紧凑
- html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
- // 显示原始叶子分类 - 使用蓝色(与半选题对应)
- 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>`;
- // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
- if (merged) {
- html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
- 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>`;
- }
- html += `</div>`;
- html += `</td>`;
- });
- // 目的点完整选题 - 每个叶子分类占一列
- mudiCombosList.forEach((mudiInfo, mudiIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${mudiIdx < mudiCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : 'border-right: 1px solid #e0e0e0;'}">`;
- const item = mudiExpandList.find(x => x['叶子分类'] === mudiInfo.leafClass);
- const merged = item ? item['扩展分类合并'] : '';
- // 使用flex布局让内容更紧凑
- html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
- // 显示原始叶子分类 - 使用蓝色(与半选题对应)
- 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>`;
- // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
- if (merged) {
- html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
- 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>`;
- }
- html += `</div>`;
- html += `</td>`;
- });
- // 关键点完整选题 - 每个叶子分类占一列
- guanjianCombosList.forEach((keyInfo, keyIdx) => {
- html += `<td style="padding: 10px; text-align: center; ${keyIdx < guanjianCombosList.length - 1 ? 'border-right: 1px solid #e0e0e0;' : ''}">`;
- const item = guanjianExpandList.find(x => x['叶子分类'] === keyInfo.keyLeafClass);
- const merged = item ? item['扩展分类合并'] : '';
- // 使用flex布局让内容更紧凑
- html += `<div style="display: flex; align-items: center; justify-content: center; flex-wrap: wrap; gap: 6px;">`;
- // 显示原始叶子分类 - 使用蓝色(与半选题对应)
- 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>`;
- // 如果有扩展分类合并,添加加号和扩展分类 - 使用绿色(与组合分类合并对应)
- if (merged) {
- html += `<span style="color: #666; font-weight: bold; font-size: 0.85rem;">+</span>`;
- 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>`;
- }
- html += `</div>`;
- html += `</td>`;
- });
- html += `</tr>`;
- html += `</tbody>`;
- html += `</table>`;
- html += `</div>`;
- html += `</div>`;
- html += `</div>`;
- // 共同帖子详情 - 从聚类结果中获取
- const commonPostIds = result['聚类覆盖帖子ID'] || [];
- if (commonPostIds.length > 0) {
- html += `<div style="background: white; padding: 16px; border-radius: 8px; border-left: 4px solid #ff9800;">`;
- html += `<h4 style="color: #ff9800; margin: 0 0 12px 0; font-size: 0.95rem;">📝 共同帖子详情 (${commonPostIds.length}个)</h4>`;
- commonPostIds.forEach(postId => {
- const postData = enrichedXuantiPointMap ? enrichedXuantiPointMap[postId] : null;
- const cachedPostData = postCache[postId];
- if (postData) {
- html += `<div style="margin-bottom: 12px; padding: 12px; background: #f5f5f5; border-radius: 6px; border-left: 3px solid #ff9800; display: flex; gap: 12px;">`;
- // 显示第一张图片(如果有)
- if (cachedPostData && cachedPostData.images && cachedPostData.images.length > 0) {
- html += `<img src="${escapeHtml(cachedPostData.images[0])}"
- alt="${escapeHtml(postData['选题'] || '帖子')}"
- title="点击查看详情"
- onclick="window.showPostDetail('${postId}');"
- style="width: 100px; height: 100px; object-fit: cover; border-radius: 6px; cursor: pointer; flex-shrink: 0;
- transition: all 0.2s; border: 2px solid #ff9800;"
- onmouseenter="this.style.transform='scale(1.05)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)';"
- onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='none';">`;
- }
- // 右侧信息区域
- html += `<div style="flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px;">`;
- // 标题、选题描述和ID
- html += `<div>`;
- 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>`;
- if (postData['选题描述']) {
- html += `<div style="color: #666; font-size: 0.75rem; line-height: 1.4; margin-bottom: 4px;">${escapeHtml(postData['选题描述'])}</div>`;
- }
- html += `<div style="color: #999; font-size: 0.7rem;">ID: ${escapeHtml(postId)}</div>`;
- html += `</div>`;
- // 显示灵感点、目的点、关键点详情(优化布局)
- // 灵感点
- const lingganList = postData['灵感点列表'] || [];
- if (lingganList.length > 0) {
- html += `<div style="padding: 8px; background: #e8f5e9; border-radius: 4px; border-left: 3px solid #2196f3;">`;
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
- html += `<span style="font-weight: 700; color: #1565c0; font-size: 0.75rem; margin-right: 4px;">💡 灵感点</span>`;
- lingganList.forEach(point => {
- 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>`;
- // 显示该灵感点的特征和叶子分类
- const features = point['提取的特征'] || [];
- features.forEach(f => {
- html += `<span style="background: white; color: #1976d2; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #bbdefb;">`;
- html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
- html += `</span>`;
- });
- });
- html += `</div>`;
- html += `</div>`;
- }
- // 目的点
- const mudiList = postData['目的点'] || [];
- if (mudiList.length > 0) {
- html += `<div style="padding: 8px; background: #fff8e1; border-radius: 4px; border-left: 3px solid #ff9800;">`;
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
- html += `<span style="font-weight: 700; color: #ef6c00; font-size: 0.75rem; margin-right: 4px;">🎯 目的点</span>`;
- mudiList.forEach(point => {
- 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>`;
- // 显示该目的点的特征和叶子分类
- const features = point['提取的特征'] || [];
- features.forEach(f => {
- html += `<span style="background: white; color: #f57c00; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #ffe0b2;">`;
- html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
- html += `</span>`;
- });
- });
- html += `</div>`;
- html += `</div>`;
- }
- // 关键点
- const guanjianList = postData['关键点列表'] || [];
- if (guanjianList.length > 0) {
- html += `<div style="padding: 8px; background: #f3e5f5; border-radius: 4px; border-left: 3px solid #9c27b0;">`;
- html += `<div style="display: flex; flex-wrap: wrap; gap: 4px; align-items: center;">`;
- html += `<span style="font-weight: 700; color: #6a1b9a; font-size: 0.75rem; margin-right: 4px;">🔑 关键点</span>`;
- guanjianList.forEach(point => {
- 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>`;
- // 显示该关键点的特征和叶子分类
- const features = point['提取的特征'] || [];
- features.forEach(f => {
- html += `<span style="background: white; color: #7b1fa2; padding: 2px 6px; border-radius: 2px; font-size: 0.65rem; border: 1px solid #e1bee7;">`;
- html += `${escapeHtml(f['特征名称'])}→${escapeHtml(f['叶子分类'])}`;
- html += `</span>`;
- });
- });
- html += `</div>`;
- html += `</div>`;
- }
- html += `</div>`; // 结束右侧信息区域
- html += `</div>`;
- } else if (cachedPostData && cachedPostData.images && cachedPostData.images.length > 0) {
- // 如果没有enrichedXuantiPointMap数据,仅显示图片
- html += `<img src="${escapeHtml(cachedPostData.images[0])}"
- alt="${escapeHtml(cachedPostData.title || '帖子')}"
- title="点击查看详情"
- onclick="window.showPostDetail('${postId}');"
- style="width: 80px; height: 80px; object-fit: cover; border-radius: 6px; cursor: pointer; margin-right: 8px; margin-bottom: 8px;
- transition: all 0.2s; border: 2px solid #ff9800; display: inline-block;"
- onmouseenter="this.style.transform='scale(1.1)'; this.style.boxShadow='0 4px 12px rgba(0,0,0,0.2)';"
- onmouseleave="this.style.transform='scale(1)'; this.style.boxShadow='none';">`;
- }
- });
- html += `</div>`;
- }
- return html;
- }
- function showOrthogonalResults(lingganPath, matchedResults) {
- const resultsContainer = document.getElementById('orthogonal-results');
- // 设置标题
- const lingganLeafClass = lingganPath.split('/').pop();
- let html = '';
- // 灵感点信息区域 - 滑动置顶
- 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;">`;
- html += `<h3 style="color: #667eea; margin-bottom: 12px;">
- 灵感点「${escapeHtml(lingganLeafClass)}」
- <span style="color: #999; font-size: 0.9rem; font-weight: normal; margin-left: 10px;">(${matchedResults.length}组三维正交组合)</span>
- </h3>`;
- html += `</div>`;
- // 遍历每个匹配结果
- matchedResults.forEach((result, index) => {
- html += `<div style="margin-bottom: 30px; padding: 20px; background: #f8f9fa; border-radius: 8px;">`;
- // 组号标题(显示聚类信息)
- html += `<div style="display: flex; align-items: center; gap: 10px; margin-bottom: 16px;">`;
- html += `<span style="background: #667eea; color: white; padding: 6px 14px; border-radius: 16px; font-weight: 700; font-size: 0.95rem;">聚类 #${result['聚类ID']}</span>`;
- html += `<span style="color: #666; font-size: 0.9rem;">组合数: ${result['聚类大小']}</span>`;
- html += `<span style="color: #666; font-size: 0.9rem;">覆盖帖子: ${result['聚类覆盖帖子数']}</span>`;
- html += `</div>`;
- // 直接调用generateGroupContent生成表格内容
- html += generateGroupContent(result, index);
- html += `</div>`;
- });
- resultsContainer.innerHTML = html;
- }
- // 获取灵感点的所有帖子ID
- function getLingganPostIds(lingganPath) {
- const pathParts = lingganPath.split('/');
- let categoryData = clusteredData['灵感点列表'];
- // 逐级查找到目标分类
- for (const part of pathParts) {
- if (categoryData && categoryData[part]) {
- categoryData = categoryData[part];
- } else {
- return [];
- }
- }
- return categoryData['帖子列表'] || [];
- }
- function highlightThreeDimensionAssociations(lingganPath) {
- if (!expandedOrthogonalCombinations || !expandedOrthogonalCombinations['聚类结果'] || !expandedOrthogonalCombinations['聚类结果']['聚类详情']) {
- console.log('聚类结果数据不存在');
- document.getElementById('orthogonal-results').innerHTML = `
- <div style="text-align: center; padding: 60px 20px; color: #999;">
- <p style="font-size: 1.1rem; margin-bottom: 10px;">未加载扩展正交组合数据</p>
- </div>
- `;
- return;
- }
- const expandedResults = expandedOrthogonalCombinations['聚类结果']['聚类详情'];
- // 从路径中提取叶子分类
- const lingganLeafClass = lingganPath.split('/').pop();
- console.log('查找灵感点叶子分类:', lingganLeafClass);
- // 查找匹配的聚类结果(灵感点维度合并中包含该叶子分类)
- const matchedResults = expandedResults.filter(result => {
- const dimensionMerge = result['维度合并'];
- if (!dimensionMerge || !dimensionMerge['灵感点']) return false;
- return dimensionMerge['灵感点'].some(item => item['叶子分类'] === lingganLeafClass);
- });
- console.log('找到匹配的扩展结果数量:', matchedResults.length);
- if (matchedResults.length === 0) {
- document.getElementById('orthogonal-results').innerHTML = `
- <div style="text-align: center; padding: 60px 20px; color: #999;">
- <p style="font-size: 1.1rem; margin-bottom: 10px;">该灵感点没有三维正交关联</p>
- <p style="font-size: 0.9rem;">(叶子分类: ${escapeHtml(lingganLeafClass)})</p>
- </div>
- `;
- return;
- }
- // 显示正交关系卡片
- showOrthogonalResults(lingganPath, matchedResults);
- console.log('共显示', matchedResults.length, '组三维正交关联');
- }
- function highlightOrthogonalNodeMultiGroup(dimension, path, groups) {
- const headers = document.querySelectorAll('.tab3-node-header');
- headers.forEach(header => {
- if (header.getAttribute('data-dimension') === dimension &&
- header.getAttribute('data-path') === path) {
- // 如果参与多个组,使用渐变边框
- let borderStyle, bgColor, boxShadow;
- if (groups.length === 1) {
- // 只参与一个组,使用单一颜色
- borderStyle = `3px solid ${groups[0].color.border}`;
- bgColor = groups[0].color.bg;
- boxShadow = `0 0 16px ${groups[0].color.border}99`;
- } else {
- // 参与多个组,使用渐变边框
- const colors = groups.map(g => g.color.border).join(', ');
- borderStyle = `3px solid transparent`;
- header.style.backgroundImage = `linear-gradient(white, white), linear-gradient(90deg, ${colors})`;
- header.style.backgroundOrigin = 'padding-box, border-box';
- header.style.backgroundClip = 'padding-box, border-box';
- // 背景色使用第一个组的颜色
- bgColor = groups[0].color.bg;
- // 组合所有组的阴影效果
- boxShadow = groups.map((g, i) =>
- `${i * 2}px ${i * 2}px ${12 + i * 2}px ${g.color.border}66`
- ).join(', ');
- }
- header.style.border = borderStyle;
- header.style.backgroundColor = bgColor;
- header.style.boxShadow = boxShadow;
- header.style.fontWeight = '600';
- // 删除旧的组号标签
- const existingBadges = header.querySelectorAll('.group-badge');
- existingBadges.forEach(b => b.remove());
- // 添加所有组号标签
- const badgeContainer = document.createElement('span');
- badgeContainer.style.cssText = 'display: inline-flex; gap: 4px; margin-right: 6px; flex-wrap: wrap;';
- groups.forEach(group => {
- const badge = document.createElement('span');
- badge.className = 'group-badge';
- badge.textContent = `#${group.groupNumber}`;
- badge.style.cssText = `
- display: inline-block;
- background: ${group.color.border};
- color: white;
- padding: 2px 6px;
- border-radius: 10px;
- font-size: 0.75rem;
- font-weight: 700;
- `;
- badgeContainer.appendChild(badge);
- });
- header.insertBefore(badgeContainer, header.firstChild);
- // 收集所有组的共同帖子(取并集)
- const allPostIds = new Set();
- groups.forEach(group => {
- group.commonPostIds.forEach(id => allPostIds.add(id));
- });
- // 显示所有相关的帖子封面图
- const nodeId = header.getAttribute('data-node-id');
- if (nodeId && allPostIds.size > 0) {
- showCategoryThumbnails(dimension, path, nodeId, Array.from(allPostIds));
- }
- }
- });
- }
- function highlightOrthogonalNode(dimension, path, commonPostIds, borderColor, bgColor, groupNumber) {
- const headers = document.querySelectorAll('.tab3-node-header');
- headers.forEach(header => {
- if (header.getAttribute('data-dimension') === dimension &&
- header.getAttribute('data-path') === path) {
- // 高亮节点
- header.style.borderColor = borderColor;
- header.style.borderWidth = '3px';
- header.style.boxShadow = `0 0 16px ${borderColor}99`;
- header.style.backgroundColor = bgColor;
- header.style.fontWeight = '600';
- // 在节点前添加组号标签
- const existingBadge = header.querySelector('.group-badge');
- if (existingBadge) {
- existingBadge.remove();
- }
- const badge = document.createElement('span');
- badge.className = 'group-badge';
- badge.textContent = `#${groupNumber}`;
- badge.style.cssText = `
- display: inline-block;
- background: ${borderColor};
- color: white;
- padding: 2px 6px;
- border-radius: 10px;
- font-size: 0.75rem;
- font-weight: 700;
- margin-right: 6px;
- `;
- header.insertBefore(badge, header.firstChild);
- // 显示三维共同帖子的封面图
- const nodeId = header.getAttribute('data-node-id');
- if (nodeId && commonPostIds && commonPostIds.length > 0) {
- showCategoryThumbnails(dimension, path, nodeId, commonPostIds);
- }
- }
- });
- }
- function showCategoryThumbnails(dimension, path, nodeId, filterPostIds) {
- // 在clusteredData中查找该分类的数据
- const pathParts = path.split('/');
- let categoryData = clusteredData[dimension];
- // 逐级查找到目标分类
- for (const part of pathParts) {
- if (categoryData && categoryData[part]) {
- categoryData = categoryData[part];
- } else {
- console.log('未找到分类数据:', dimension, path);
- return;
- }
- }
- // 获取帖子列表
- let postIds = categoryData['帖子列表'] || [];
- // 如果提供了过滤列表,只显示过滤列表中的帖子(用于关联分类显示共同帖子)
- if (filterPostIds && filterPostIds.length > 0) {
- postIds = postIds.filter(id => filterPostIds.includes(id));
- }
- console.log('该分类的帖子数量:', postIds.length);
- if (postIds.length === 0) {
- return;
- }
- // 获取封面图容器
- const thumbnailsContainer = document.getElementById(nodeId + '-thumbnails');
- if (!thumbnailsContainer) {
- return;
- }
- // 清空容器
- thumbnailsContainer.innerHTML = '';
- // 为每个帖子添加封面图
- postIds.forEach(postId => {
- const postData = postCache[postId];
- if (postData && postData.images && postData.images.length > 0) {
- const img = document.createElement('img');
- img.src = postData.images[0];
- img.alt = postData.title || '帖子封面';
- img.style.cssText = 'width: 80px; height: 80px; object-fit: cover; border-radius: 6px; cursor: pointer; transition: all 0.2s; border: 2px solid #e0e0e0;';
- // 鼠标悬停效果
- img.addEventListener('mouseenter', function() {
- this.style.transform = 'scale(1.1)';
- this.style.boxShadow = '0 4px 12px rgba(0,0,0,0.2)';
- this.style.borderColor = '#2196F3';
- });
- img.addEventListener('mouseleave', function() {
- this.style.transform = 'scale(1)';
- this.style.boxShadow = 'none';
- this.style.borderColor = '#e0e0e0';
- });
- // 点击封面图显示帖子详情
- img.addEventListener('click', function(e) {
- e.stopPropagation();
- showPostDetail(postId);
- });
- thumbnailsContainer.appendChild(img);
- }
- });
- // 显示容器
- if (thumbnailsContainer.children.length > 0) {
- thumbnailsContainer.style.display = 'flex';
- }
- }
- document.querySelector('.modal-overlay')?.addEventListener('click', closeModal);
- });
- </script>
- '''
- return js_code
- def visualize_classification_tree(
- optimized_data_path: str,
- posts_dir: str,
- xuanti_point_map: Dict[str, Dict[str, Any]],
- output_path: str = None,
- dimension_associations_path: str = None,
- intra_dimension_associations_path: str = None,
- expanded_orthogonal_combinations_path: str = None,
- enriched_xuanti_point_map_path: str = None
- ) -> str:
- """可视化分类树"""
- with open(optimized_data_path, 'r', encoding='utf-8') as f:
- clustered_data = json.load(f)
- # 加载跨维度关联分析数据(如果提供了路径)
- dimension_associations = None
- if dimension_associations_path and os.path.exists(dimension_associations_path):
- try:
- with open(dimension_associations_path, 'r', encoding='utf-8') as f:
- dimension_associations = json.load(f)
- print(f"✅ 已加载跨维度关联分析数据: {dimension_associations_path}")
- except Exception as e:
- print(f"⚠️ 加载跨维度关联分析数据失败: {e}")
- # 加载维度内部关联分析数据(如果提供了路径)
- intra_dimension_associations = None
- if intra_dimension_associations_path and os.path.exists(intra_dimension_associations_path):
- try:
- with open(intra_dimension_associations_path, 'r', encoding='utf-8') as f:
- intra_dimension_associations = json.load(f)
- print(f"✅ 已加载维度内部关联分析数据: {intra_dimension_associations_path}")
- except Exception as e:
- print(f"⚠️ 加载维度内部关联分析数据失败: {e}")
- # 加载扩展正交组合数据(新增)
- expanded_orthogonal_combinations = None
- if expanded_orthogonal_combinations_path and os.path.exists(expanded_orthogonal_combinations_path):
- try:
- with open(expanded_orthogonal_combinations_path, 'r', encoding='utf-8') as f:
- expanded_orthogonal_combinations = json.load(f)
- print(f"✅ 已加载扩展正交组合数据: {expanded_orthogonal_combinations_path}")
- except Exception as e:
- print(f"⚠️ 加载扩展正交组合数据失败: {e}")
- # 加载丰富选题点映射数据(新增)
- enriched_xuanti_point_map = None
- if enriched_xuanti_point_map_path and os.path.exists(enriched_xuanti_point_map_path):
- try:
- with open(enriched_xuanti_point_map_path, 'r', encoding='utf-8') as f:
- enriched_xuanti_point_map = json.load(f)
- print(f"✅ 已加载丰富选题点映射数据: {enriched_xuanti_point_map_path}")
- except Exception as e:
- print(f"⚠️ 加载丰富选题点映射数据失败: {e}")
- visualizer = ClassificationTreeVisualizer()
- html_content = visualizer.generate_html(
- clustered_data,
- Path(posts_dir),
- xuanti_point_map,
- dimension_associations,
- intra_dimension_associations,
- expanded_orthogonal_combinations,
- enriched_xuanti_point_map
- )
- if output_path is None:
- timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
- base_dir = os.path.dirname(optimized_data_path)
- output_path = os.path.join(base_dir, f"visualization/classification_tree_visualization_{timestamp}.html")
- os.makedirs(os.path.dirname(output_path), exist_ok=True)
- with open(output_path, 'w', encoding='utf-8') as f:
- f.write(html_content)
- print(f"✅ 可视化文件已生成: {output_path}")
- return output_path
- if __name__ == "__main__":
- account_name = "阿里多多酱"
- base_dir = "/Users/nieqi/Documents/workspace/python/image_article_comprehension/aiddit/pattern/pattern_from_xuanti_point_label"
- mode = "detail"
- optimized_data_path = f"{base_dir}/result/{account_name}/optimization/{mode}/optimized_clustered_data_gemini-3-pro-preview.json"
- posts_dir = f"/Users/nieqi/Documents/workspace/python/image_article_comprehension/aigc_data/{account_name}"
- dimension_associations_path = f"{base_dir}/result/{account_name}/optimization/{mode}/dimension_associations_analysis.json"
- intra_dimension_associations_path = f"{base_dir}/result/{account_name}/optimization/{mode}/intra_dimension_associations_analysis.json"
- expanded_orthogonal_combinations_path = f"{base_dir}/result/{account_name}/optimization/detail/orthogonal_combinations_clustering.json"
- enriched_xuanti_point_map_path = f"{base_dir}/result/{account_name}/optimization/{mode}/enriched_xuanti_point_map.json"
- from aiddit.pattern.pattern_from_xuanti_point_label import pattern_utils
- xuanti_point_map = pattern_utils.get_xuanti_point__map(account_name)
- output_path = visualize_classification_tree(
- optimized_data_path=optimized_data_path,
- posts_dir=posts_dir,
- xuanti_point_map=xuanti_point_map,
- dimension_associations_path=dimension_associations_path,
- intra_dimension_associations_path=intra_dimension_associations_path,
- expanded_orthogonal_combinations_path=expanded_orthogonal_combinations_path,
- enriched_xuanti_point_map_path=enriched_xuanti_point_map_path
- )
- print(f"🎉 可视化完成!请在浏览器中打开: {output_path}")
|