|
|
@@ -0,0 +1,1389 @@
|
|
|
+#!/usr/bin/env python
|
|
|
+# coding=utf-8
|
|
|
+"""
|
|
|
+头部品类分析 - 整合可视化
|
|
|
+Tab 1: 头部品类矩阵 - 原始矩阵+下钻表格
|
|
|
+Tab 2: 品类一致性 - 同品类vs跨品类vov对比
|
|
|
+Tab 3: 品类亲和性矩阵 - 热力图
|
|
|
+Tab 4: 品类组合排名 - Top高低vov组合
|
|
|
+"""
|
|
|
+import pandas as pd
|
|
|
+import numpy as np
|
|
|
+import json
|
|
|
+from pathlib import Path
|
|
|
+
|
|
|
+task_dir = Path(__file__).parent
|
|
|
+output_dir = task_dir / "output"
|
|
|
+
|
|
|
+# 找到最新的原始数据文件
|
|
|
+csv_files = [f for f in output_dir.glob("query_*.csv") if not f.name.endswith('.html')]
|
|
|
+if not csv_files:
|
|
|
+ print("没有找到数据文件,请先运行 query.sql")
|
|
|
+ exit(1)
|
|
|
+
|
|
|
+latest_file = max(csv_files, key=lambda x: x.stat().st_mtime)
|
|
|
+df = pd.read_csv(latest_file)
|
|
|
+
|
|
|
+print(f"分析文件: {latest_file.name}")
|
|
|
+print(f"时间范围: {df['dt'].min()} ~ {df['dt'].max()}")
|
|
|
+
|
|
|
+# 日期列表
|
|
|
+all_dates = sorted([str(d) for d in df['dt'].unique()])
|
|
|
+date_options = ['all'] + all_dates
|
|
|
+latest_date = all_dates[-1] if all_dates else 'all'
|
|
|
+print(f"日期数: {len(all_dates)}")
|
|
|
+
|
|
|
+# 人群列表
|
|
|
+crowd_list = ['内部', '外部0层', '外部裂变']
|
|
|
+print(f"人群: {crowd_list}")
|
|
|
+
|
|
|
+# 曝光阈值
|
|
|
+EXP_THRESHOLD = 1000
|
|
|
+EXP_THRESHOLD_TOTAL = 10000 # 亲和性矩阵的全部日期阈值
|
|
|
+
|
|
|
+# ========== Tab1: 头部品类矩阵数据 ==========
|
|
|
+def calc_matrix_data(crowd, date=None):
|
|
|
+ ch_df = df[df['crowd'] == crowd].copy()
|
|
|
+ if date and date != 'all':
|
|
|
+ ch_df = ch_df[ch_df['dt'].astype(str) == str(date)]
|
|
|
+ if len(ch_df) == 0:
|
|
|
+ return None
|
|
|
+
|
|
|
+ row_col = 'head_cate2'
|
|
|
+ col_col = 'rec_cate2'
|
|
|
+
|
|
|
+ matrix = ch_df.groupby([row_col, col_col]).agg({
|
|
|
+ 'exp': 'sum',
|
|
|
+ 'share_cnt': 'sum',
|
|
|
+ 'return_n_uv': 'sum',
|
|
|
+ 'new_exposure_cnt': 'sum',
|
|
|
+ }).reset_index()
|
|
|
+
|
|
|
+ matrix = matrix[matrix['exp'] >= EXP_THRESHOLD]
|
|
|
+ if len(matrix) == 0:
|
|
|
+ return None
|
|
|
+
|
|
|
+ matrix['str'] = matrix['share_cnt'] / (matrix['exp'] + 1)
|
|
|
+ matrix['ros'] = matrix['return_n_uv'] / (matrix['share_cnt'] + 1)
|
|
|
+ matrix['rovn'] = matrix['return_n_uv'] / (matrix['exp'] + 1)
|
|
|
+ matrix['vov'] = matrix['new_exposure_cnt'] / (matrix['exp'] + 1)
|
|
|
+
|
|
|
+ exp_pivot = matrix.pivot(index=row_col, columns=col_col, values='exp').fillna(0)
|
|
|
+ str_pivot = matrix.pivot(index=row_col, columns=col_col, values='str').fillna(0)
|
|
|
+ ros_pivot = matrix.pivot(index=row_col, columns=col_col, values='ros').fillna(0)
|
|
|
+ rovn_pivot = matrix.pivot(index=row_col, columns=col_col, values='rovn').fillna(0)
|
|
|
+ vov_pivot = matrix.pivot(index=row_col, columns=col_col, values='vov').fillna(0)
|
|
|
+
|
|
|
+ row_order = exp_pivot.sum(axis=1).sort_values(ascending=False).index.tolist()
|
|
|
+ col_order = exp_pivot.sum(axis=0).sort_values(ascending=False).index.tolist()
|
|
|
+
|
|
|
+ def to_dict(pivot, is_int=False):
|
|
|
+ return {str(r): {str(c): int(pivot.loc[r, c]) if is_int else round(float(pivot.loc[r, c]), 4) if c in pivot.columns else 0 for c in col_order} for r in row_order}
|
|
|
+
|
|
|
+ total_exp = int(ch_df['exp'].sum())
|
|
|
+ total_share = int(ch_df['share_cnt'].sum())
|
|
|
+ total_return = int(ch_df['return_n_uv'].sum())
|
|
|
+
|
|
|
+ return {
|
|
|
+ 'rows': row_order,
|
|
|
+ 'cols': col_order,
|
|
|
+ 'exp': to_dict(exp_pivot, is_int=True),
|
|
|
+ 'str': to_dict(str_pivot),
|
|
|
+ 'ros': to_dict(ros_pivot),
|
|
|
+ 'rovn': to_dict(rovn_pivot),
|
|
|
+ 'vov': to_dict(vov_pivot),
|
|
|
+ 'total_exp': total_exp,
|
|
|
+ 'total_str': round(total_share / (total_exp + 1), 4),
|
|
|
+ 'total_rovn': round(total_return / (total_exp + 1), 4),
|
|
|
+ }
|
|
|
+
|
|
|
+# 计算头部品类下钻数据
|
|
|
+def calc_head_drill_data(date=None):
|
|
|
+ ch_df = df.copy()
|
|
|
+ if date and date != 'all':
|
|
|
+ ch_df = ch_df[ch_df['dt'].astype(str) == str(date)]
|
|
|
+ if len(ch_df) == 0:
|
|
|
+ return None
|
|
|
+
|
|
|
+ agg = ch_df.groupby(['head_cate2', 'crowd', 'rec_cate2']).agg({
|
|
|
+ 'exp': 'sum',
|
|
|
+ 'share_cnt': 'sum',
|
|
|
+ 'return_n_uv': 'sum',
|
|
|
+ 'new_exposure_cnt': 'sum',
|
|
|
+ }).reset_index()
|
|
|
+
|
|
|
+ agg['str'] = agg['share_cnt'] / (agg['exp'] + 1)
|
|
|
+ agg['ros'] = agg['return_n_uv'] / (agg['share_cnt'] + 1)
|
|
|
+ agg['rovn'] = agg['return_n_uv'] / (agg['exp'] + 1)
|
|
|
+ agg['vov'] = agg['new_exposure_cnt'] / (agg['exp'] + 1)
|
|
|
+
|
|
|
+ result = {}
|
|
|
+
|
|
|
+ # all 选项
|
|
|
+ agg_all = ch_df.groupby(['crowd', 'rec_cate2']).agg({
|
|
|
+ 'exp': 'sum', 'share_cnt': 'sum', 'return_n_uv': 'sum', 'new_exposure_cnt': 'sum',
|
|
|
+ }).reset_index()
|
|
|
+ agg_all['str'] = agg_all['share_cnt'] / (agg_all['exp'] + 1)
|
|
|
+ agg_all['ros'] = agg_all['return_n_uv'] / (agg_all['share_cnt'] + 1)
|
|
|
+ agg_all['rovn'] = agg_all['return_n_uv'] / (agg_all['exp'] + 1)
|
|
|
+ agg_all['vov'] = agg_all['new_exposure_cnt'] / (agg_all['exp'] + 1)
|
|
|
+
|
|
|
+ result['all'] = {}
|
|
|
+ for crowd in crowd_list:
|
|
|
+ crowd_df = agg_all[agg_all['crowd'] == crowd]
|
|
|
+ result['all'][crowd] = {}
|
|
|
+ total_exp = int(crowd_df['exp'].sum())
|
|
|
+ total_share = crowd_df['share_cnt'].sum()
|
|
|
+ total_return = crowd_df['return_n_uv'].sum()
|
|
|
+ total_new_exp = crowd_df['new_exposure_cnt'].sum()
|
|
|
+ result['all'][crowd]['_total'] = {
|
|
|
+ 'exp': total_exp,
|
|
|
+ 'str': round(total_share / (total_exp + 1), 4),
|
|
|
+ 'ros': round(total_return / (total_share + 1), 4),
|
|
|
+ 'rovn': round(total_return / (total_exp + 1), 4),
|
|
|
+ 'vov': round(total_new_exp / (total_exp + 1), 4),
|
|
|
+ }
|
|
|
+ for _, row in crowd_df.iterrows():
|
|
|
+ result['all'][crowd][row['rec_cate2']] = {
|
|
|
+ 'exp': int(row['exp']),
|
|
|
+ 'str': round(row['str'], 4),
|
|
|
+ 'ros': round(row['ros'], 4),
|
|
|
+ 'rovn': round(row['rovn'], 4),
|
|
|
+ 'vov': round(row['vov'], 4),
|
|
|
+ }
|
|
|
+
|
|
|
+ for head_cate in agg['head_cate2'].unique():
|
|
|
+ result[head_cate] = {}
|
|
|
+ for crowd in crowd_list:
|
|
|
+ crowd_df = agg[(agg['head_cate2'] == head_cate) & (agg['crowd'] == crowd)]
|
|
|
+ result[head_cate][crowd] = {}
|
|
|
+ total_exp = int(crowd_df['exp'].sum())
|
|
|
+ total_share = crowd_df['share_cnt'].sum()
|
|
|
+ total_return = crowd_df['return_n_uv'].sum()
|
|
|
+ total_new_exp = crowd_df['new_exposure_cnt'].sum()
|
|
|
+ result[head_cate][crowd]['_total'] = {
|
|
|
+ 'exp': total_exp,
|
|
|
+ 'str': round(total_share / (total_exp + 1), 4),
|
|
|
+ 'ros': round(total_return / (total_share + 1), 4),
|
|
|
+ 'rovn': round(total_return / (total_exp + 1), 4),
|
|
|
+ 'vov': round(total_new_exp / (total_exp + 1), 4),
|
|
|
+ }
|
|
|
+ for _, row in crowd_df.iterrows():
|
|
|
+ result[head_cate][crowd][row['rec_cate2']] = {
|
|
|
+ 'exp': int(row['exp']),
|
|
|
+ 'str': round(row['str'], 4),
|
|
|
+ 'ros': round(row['ros'], 4),
|
|
|
+ 'rovn': round(row['rovn'], 4),
|
|
|
+ 'vov': round(row['vov'], 4),
|
|
|
+ }
|
|
|
+
|
|
|
+ head_exp = ch_df.groupby('head_cate2')['exp'].sum().sort_values(ascending=False)
|
|
|
+ head_list = head_exp.index.tolist()
|
|
|
+
|
|
|
+ return {'heads': ['all'] + head_list, 'data': result}
|
|
|
+
|
|
|
+# 预计算Tab1数据
|
|
|
+all_data = {}
|
|
|
+for crowd in crowd_list:
|
|
|
+ all_data[crowd] = {}
|
|
|
+ for dt in date_options:
|
|
|
+ matrix = calc_matrix_data(crowd, dt)
|
|
|
+ if matrix:
|
|
|
+ all_data[crowd][dt] = matrix
|
|
|
+
|
|
|
+head_drill_data = {}
|
|
|
+for dt in date_options:
|
|
|
+ drill = calc_head_drill_data(dt)
|
|
|
+ if drill:
|
|
|
+ head_drill_data[dt] = drill
|
|
|
+
|
|
|
+# ========== Tab2: 品类一致性数据 ==========
|
|
|
+df_valid = df[~df['head_cate2'].isin(['headvideoid为空', '未匹配品类'])].copy()
|
|
|
+df_valid['is_same_cate'] = df_valid['head_cate2'] == df_valid['rec_cate2']
|
|
|
+df_valid['cate_pair'] = df_valid['head_cate2'] + ' → ' + df_valid['rec_cate2']
|
|
|
+
|
|
|
+consistency_data = {'crowds': crowd_list, 'same': [], 'diff': [], 'ratio': []}
|
|
|
+for crowd in crowd_list:
|
|
|
+ crowd_df = df_valid[df_valid['crowd'] == crowd]
|
|
|
+ same = crowd_df[crowd_df['is_same_cate']]
|
|
|
+ diff = crowd_df[~crowd_df['is_same_cate']]
|
|
|
+ same_vov = same['new_exposure_cnt'].sum() / same['exp'].sum() if same['exp'].sum() > 0 else 0
|
|
|
+ diff_vov = diff['new_exposure_cnt'].sum() / diff['exp'].sum() if diff['exp'].sum() > 0 else 0
|
|
|
+ consistency_data['same'].append(round(same_vov, 4))
|
|
|
+ consistency_data['diff'].append(round(diff_vov, 4))
|
|
|
+ consistency_data['ratio'].append(round(same_vov / diff_vov, 2) if diff_vov > 0 else 0)
|
|
|
+
|
|
|
+same_all = df_valid[df_valid['is_same_cate']]
|
|
|
+diff_all = df_valid[~df_valid['is_same_cate']]
|
|
|
+consistency_data['total_same'] = round(same_all['new_exposure_cnt'].sum() / same_all['exp'].sum(), 4)
|
|
|
+consistency_data['total_diff'] = round(diff_all['new_exposure_cnt'].sum() / diff_all['exp'].sum(), 4)
|
|
|
+consistency_data['total_ratio'] = round(consistency_data['total_same'] / consistency_data['total_diff'], 2)
|
|
|
+consistency_data['same_exp'] = [int(df_valid[(df_valid['crowd'] == c) & df_valid['is_same_cate']]['exp'].sum()) for c in crowd_list]
|
|
|
+consistency_data['diff_exp'] = [int(df_valid[(df_valid['crowd'] == c) & ~df_valid['is_same_cate']]['exp'].sum()) for c in crowd_list]
|
|
|
+
|
|
|
+# ========== Tab3: 品类亲和性矩阵 ==========
|
|
|
+date_list_aff = ['全部'] + all_dates
|
|
|
+
|
|
|
+def calc_affinity_matrix(data_df, exp_threshold):
|
|
|
+ head_baseline = data_df.groupby('head_cate2').apply(
|
|
|
+ lambda x: x['new_exposure_cnt'].sum() / x['exp'].sum(), include_groups=False
|
|
|
+ ).to_dict()
|
|
|
+
|
|
|
+ affinity_list = []
|
|
|
+ for (head, rec), grp in data_df.groupby(['head_cate2', 'rec_cate2']):
|
|
|
+ if grp['exp'].sum() >= exp_threshold:
|
|
|
+ pair_vov = grp['new_exposure_cnt'].sum() / grp['exp'].sum()
|
|
|
+ baseline = head_baseline.get(head, 1)
|
|
|
+ affinity = pair_vov / baseline if baseline > 0 else 0
|
|
|
+ affinity_list.append({
|
|
|
+ 'head': head, 'rec': rec,
|
|
|
+ 'vov': round(pair_vov, 4),
|
|
|
+ 'affinity': round(affinity, 2),
|
|
|
+ 'exp': int(grp['exp'].sum())
|
|
|
+ })
|
|
|
+
|
|
|
+ if not affinity_list:
|
|
|
+ return None
|
|
|
+
|
|
|
+ aff_df = pd.DataFrame(affinity_list)
|
|
|
+ head_exp = aff_df.groupby('head')['exp'].sum()
|
|
|
+ rec_exp = aff_df.groupby('rec')['exp'].sum()
|
|
|
+ all_cates = set(head_exp.index) | set(rec_exp.index)
|
|
|
+ cate_total_exp = {c: head_exp.get(c, 0) + rec_exp.get(c, 0) for c in all_cates}
|
|
|
+ cate_list = sorted(cate_total_exp.keys(), key=lambda x: cate_total_exp[x], reverse=True)[:30]
|
|
|
+
|
|
|
+ result = {'rows': cate_list, 'cols': cate_list, 'affinity': {}, 'vov': {}, 'exp': {}}
|
|
|
+ for head in cate_list:
|
|
|
+ result['affinity'][head] = {}
|
|
|
+ result['vov'][head] = {}
|
|
|
+ result['exp'][head] = {}
|
|
|
+ for rec in cate_list:
|
|
|
+ row = aff_df[(aff_df['head'] == head) & (aff_df['rec'] == rec)]
|
|
|
+ if len(row) > 0:
|
|
|
+ result['affinity'][head][rec] = float(row.iloc[0]['affinity'])
|
|
|
+ result['vov'][head][rec] = float(row.iloc[0]['vov'])
|
|
|
+ result['exp'][head][rec] = int(row.iloc[0]['exp'])
|
|
|
+ else:
|
|
|
+ result['affinity'][head][rec] = 0
|
|
|
+ result['vov'][head][rec] = 0
|
|
|
+ result['exp'][head][rec] = 0
|
|
|
+ return result
|
|
|
+
|
|
|
+# 固定行列顺序
|
|
|
+base_matrix = calc_affinity_matrix(df_valid, EXP_THRESHOLD_TOTAL)
|
|
|
+fixed_cate_list = base_matrix['rows'] if base_matrix else []
|
|
|
+
|
|
|
+def calc_affinity_matrix_fixed(data_df, exp_threshold, fixed_list):
|
|
|
+ head_baseline = data_df.groupby('head_cate2').apply(
|
|
|
+ lambda x: x['new_exposure_cnt'].sum() / x['exp'].sum(), include_groups=False
|
|
|
+ ).to_dict()
|
|
|
+
|
|
|
+ affinity_dict = {}
|
|
|
+ for (head, rec), grp in data_df.groupby(['head_cate2', 'rec_cate2']):
|
|
|
+ if grp['exp'].sum() >= exp_threshold:
|
|
|
+ pair_vov = grp['new_exposure_cnt'].sum() / grp['exp'].sum()
|
|
|
+ baseline = head_baseline.get(head, 1)
|
|
|
+ affinity = pair_vov / baseline if baseline > 0 else 0
|
|
|
+ affinity_dict[(head, rec)] = {
|
|
|
+ 'vov': round(pair_vov, 4),
|
|
|
+ 'affinity': round(affinity, 2),
|
|
|
+ 'exp': int(grp['exp'].sum())
|
|
|
+ }
|
|
|
+
|
|
|
+ result = {'rows': fixed_list, 'cols': fixed_list, 'affinity': {}, 'vov': {}, 'exp': {}}
|
|
|
+ for head in fixed_list:
|
|
|
+ result['affinity'][head] = {}
|
|
|
+ result['vov'][head] = {}
|
|
|
+ result['exp'][head] = {}
|
|
|
+ for rec in fixed_list:
|
|
|
+ if (head, rec) in affinity_dict:
|
|
|
+ result['affinity'][head][rec] = float(affinity_dict[(head, rec)]['affinity'])
|
|
|
+ result['vov'][head][rec] = float(affinity_dict[(head, rec)]['vov'])
|
|
|
+ result['exp'][head][rec] = int(affinity_dict[(head, rec)]['exp'])
|
|
|
+ else:
|
|
|
+ result['affinity'][head][rec] = 0
|
|
|
+ result['vov'][head][rec] = 0
|
|
|
+ result['exp'][head][rec] = 0
|
|
|
+ return result
|
|
|
+
|
|
|
+affinity_matrix_data = {}
|
|
|
+for date in date_list_aff:
|
|
|
+ affinity_matrix_data[date] = {}
|
|
|
+ if date == '全部':
|
|
|
+ date_df = df_valid
|
|
|
+ threshold = EXP_THRESHOLD_TOTAL
|
|
|
+ else:
|
|
|
+ date_df = df_valid[df_valid['dt'].astype(str) == date]
|
|
|
+ threshold = EXP_THRESHOLD
|
|
|
+
|
|
|
+ affinity_matrix_data[date]['整体'] = calc_affinity_matrix_fixed(date_df, threshold, fixed_cate_list)
|
|
|
+ for crowd in crowd_list:
|
|
|
+ affinity_matrix_data[date][crowd] = calc_affinity_matrix_fixed(
|
|
|
+ date_df[date_df['crowd'] == crowd], threshold, fixed_cate_list
|
|
|
+ )
|
|
|
+
|
|
|
+# ========== Tab4: 品类组合排名 ==========
|
|
|
+def calc_ranking(data_df, min_exp=1000):
|
|
|
+ pair_vov = data_df.groupby('cate_pair').apply(
|
|
|
+ lambda x: pd.Series({
|
|
|
+ 'vov': x['new_exposure_cnt'].sum() / x['exp'].sum(),
|
|
|
+ 'exp': int(x['exp'].sum()),
|
|
|
+ }), include_groups=False
|
|
|
+ )
|
|
|
+ pair_vov = pair_vov[pair_vov['exp'] >= min_exp]
|
|
|
+ if len(pair_vov) == 0:
|
|
|
+ return {'high': [], 'low': []}
|
|
|
+
|
|
|
+ all_high = pair_vov.sort_values('vov', ascending=False).head(100)
|
|
|
+ all_low = pair_vov.sort_values('vov', ascending=True).head(100)
|
|
|
+
|
|
|
+ def is_same_cate(pair_str):
|
|
|
+ # pair format: "head_cate2 → rec_cate2"
|
|
|
+ parts = pair_str.split(' → ')
|
|
|
+ return len(parts) == 2 and parts[0] == parts[1]
|
|
|
+
|
|
|
+ return {
|
|
|
+ 'high': [{'pair': idx, 'vov': float(round(row['vov'], 4)), 'exp': int(row['exp']), 'same': is_same_cate(idx)} for idx, row in all_high.iterrows()],
|
|
|
+ 'low': [{'pair': idx, 'vov': float(round(row['vov'], 4)), 'exp': int(row['exp']), 'same': is_same_cate(idx)} for idx, row in all_low.iterrows()]
|
|
|
+ }
|
|
|
+
|
|
|
+ranking_data = {}
|
|
|
+for date in date_list_aff:
|
|
|
+ ranking_data[date] = {}
|
|
|
+ if date == '全部':
|
|
|
+ date_df = df_valid
|
|
|
+ min_exp = 1000
|
|
|
+ else:
|
|
|
+ date_df = df_valid[df_valid['dt'].astype(str) == date]
|
|
|
+ min_exp = 100
|
|
|
+
|
|
|
+ ranking_data[date]['整体'] = calc_ranking(date_df, min_exp)
|
|
|
+ for crowd in crowd_list:
|
|
|
+ ranking_data[date][crowd] = calc_ranking(date_df[date_df['crowd'] == crowd], min_exp)
|
|
|
+
|
|
|
+# 转为JSON
|
|
|
+data_json = json.dumps(all_data, ensure_ascii=False)
|
|
|
+head_drill_json = json.dumps(head_drill_data, ensure_ascii=False)
|
|
|
+crowd_list_json = json.dumps(crowd_list, ensure_ascii=False)
|
|
|
+dates_json = json.dumps(date_options)
|
|
|
+consistency_json = json.dumps(consistency_data, ensure_ascii=False)
|
|
|
+affinity_json = json.dumps(affinity_matrix_data, ensure_ascii=False)
|
|
|
+ranking_json = json.dumps(ranking_data, ensure_ascii=False)
|
|
|
+date_list_aff_json = json.dumps(date_list_aff, ensure_ascii=False)
|
|
|
+
|
|
|
+# 日期选项HTML
|
|
|
+date_options_html = "".join([
|
|
|
+ f'<option value="{dt}" {"selected" if dt == latest_date else ""}>'
|
|
|
+ f'{"all" if dt == "all" else dt}</option>'
|
|
|
+ for dt in date_options
|
|
|
+])
|
|
|
+date_options_aff_html = "".join([f'<option value="{d}" {"selected" if d == "全部" else ""}>{d}</option>' for d in date_list_aff])
|
|
|
+crowd_options_html = "".join([f'<option value="{c}">{c}</option>' for c in crowd_list])
|
|
|
+
|
|
|
+html_content = f"""<!DOCTYPE html>
|
|
|
+<html>
|
|
|
+<head>
|
|
|
+ <meta charset="utf-8">
|
|
|
+ <title>进入品类 × 推荐品类 效果分析</title>
|
|
|
+ <style>
|
|
|
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
|
|
|
+ body {{ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
|
+ background: #f5f5f5; padding: 20px; }}
|
|
|
+ .container {{ max-width: 1600px; margin: 0 auto; background: white;
|
|
|
+ border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); }}
|
|
|
+ h1 {{ font-size: 24px; margin-bottom: 10px; color: #333; }}
|
|
|
+ .subtitle {{ color: #666; margin-bottom: 20px; font-size: 14px; }}
|
|
|
+
|
|
|
+ /* Tabs */
|
|
|
+ .tabs {{ display: flex; gap: 5px; margin-bottom: 20px; border-bottom: 2px solid #e0e0e0; }}
|
|
|
+ .tab {{ padding: 10px 20px; cursor: pointer; border: none; background: none;
|
|
|
+ font-size: 14px; color: #666; border-bottom: 2px solid transparent; margin-bottom: -2px; }}
|
|
|
+ .tab:hover {{ color: #333; }}
|
|
|
+ .tab.active {{ color: #1976D2; border-bottom-color: #1976D2; font-weight: 500; }}
|
|
|
+ .tab-content {{ display: none; }}
|
|
|
+ .tab-content.active {{ display: block; }}
|
|
|
+
|
|
|
+ /* Controls */
|
|
|
+ .controls {{ display: flex; gap: 20px; margin-bottom: 20px; align-items: center; flex-wrap: wrap; }}
|
|
|
+ .controls .date-switcher {{ margin-left: auto; }}
|
|
|
+ .control-group {{ display: flex; align-items: center; gap: 8px; }}
|
|
|
+ .control-group label {{ font-weight: 500; color: #666; font-size: 13px; }}
|
|
|
+ select {{ padding: 8px 12px; border: 1px solid #ddd; border-radius: 4px; font-size: 14px; min-width: 120px; }}
|
|
|
+ .date-switcher {{ display: flex; align-items: center; gap: 5px; }}
|
|
|
+ .date-switcher button {{ padding: 5px 10px; border: 1px solid #ddd; background: white;
|
|
|
+ cursor: pointer; border-radius: 3px; }}
|
|
|
+ .date-switcher button:hover {{ background: #f0f0f0; }}
|
|
|
+ .play-btn {{ background: #4CAF50; color: white; border: none; border-radius: 4px; padding: 6px 12px; font-size: 14px; cursor: pointer; }}
|
|
|
+ .play-btn:hover {{ background: #45a049; }}
|
|
|
+ .play-btn.playing {{ background: #f44336; }}
|
|
|
+
|
|
|
+ /* Summary cards */
|
|
|
+ .summary {{ display: flex; gap: 15px; margin-bottom: 20px; flex-wrap: wrap; }}
|
|
|
+ .stat-card {{ background: #f8f9fa; padding: 15px 20px; border-radius: 6px; text-align: center; }}
|
|
|
+ .stat-card h4 {{ font-size: 24px; color: #28a745; margin-bottom: 5px; }}
|
|
|
+ .stat-card p {{ font-size: 12px; color: #666; }}
|
|
|
+ .stat-card.green {{ background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%); color: white; }}
|
|
|
+ .stat-card.green h4 {{ color: white; }}
|
|
|
+ .stat-card.green p {{ color: rgba(255,255,255,0.9); }}
|
|
|
+ .stat-card.orange {{ background: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); color: white; }}
|
|
|
+ .stat-card.orange h4 {{ color: white; }}
|
|
|
+ .stat-card.orange p {{ color: rgba(255,255,255,0.9); }}
|
|
|
+ .stat-card.blue {{ background: linear-gradient(135deg, #4facfe 0%, #00f2fe 100%); color: white; }}
|
|
|
+ .stat-card.blue h4 {{ color: white; }}
|
|
|
+ .stat-card.blue p {{ color: rgba(255,255,255,0.9); }}
|
|
|
+
|
|
|
+ /* Matrix */
|
|
|
+ .matrix-container {{ overflow-x: auto; max-height: 600px; overflow-y: auto; }}
|
|
|
+ table {{ border-collapse: collapse; font-size: 11px; }}
|
|
|
+ th, td {{ border: 1px solid #e0e0e0; padding: 4px 6px; text-align: center; white-space: nowrap; }}
|
|
|
+ th {{ background: #f5f5f5; font-weight: 600; position: sticky; top: 0; z-index: 1; }}
|
|
|
+ th:first-child {{ position: sticky; left: 0; z-index: 3; }}
|
|
|
+ td:first-child {{ background: #f5f5f5; font-weight: 500; position: sticky; left: 0; z-index: 1; text-align: left; }}
|
|
|
+ .corner-cell {{
|
|
|
+ position: relative; width: 100px; height: 50px;
|
|
|
+ background: linear-gradient(to top right, #f5f5f5 49.5%, #ccc 49.5%, #ccc 50.5%, #f5f5f5 50.5%);
|
|
|
+ }}
|
|
|
+ .corner-cell .row-label {{ position: absolute; bottom: 4px; left: 4px; font-size: 10px; color: #666; }}
|
|
|
+ .corner-cell .col-label {{ position: absolute; top: 4px; right: 4px; font-size: 10px; color: #666; }}
|
|
|
+ .legend {{ font-size: 12px; color: #666; margin-bottom: 10px; }}
|
|
|
+
|
|
|
+ /* Highlight */
|
|
|
+ th.highlight, td.row-header.highlight {{ background: #bbdefb !important; }}
|
|
|
+
|
|
|
+ /* Drill section */
|
|
|
+ .compare-section {{ display: flex; gap: 20px; }}
|
|
|
+ .crowd-block {{ flex: 1; min-width: 250px; }}
|
|
|
+ .crowd-block table {{ width: 100%; border-collapse: collapse; }}
|
|
|
+ .crowd-block th {{ background: #f0f0f0; padding: 8px; border: 1px solid #ddd; }}
|
|
|
+ .crowd-block td {{ padding: 6px 8px; border: 1px solid #eee; }}
|
|
|
+ .crowd-block .rn {{ width: 40px; text-align: center; color: #666; }}
|
|
|
+ .crowd-block .cat {{ text-align: left; cursor: pointer; transition: all 0.2s; }}
|
|
|
+ .crowd-block .val {{ text-align: right; font-family: monospace; }}
|
|
|
+ .crowd-block .cat.highlight {{ font-weight: bold; }}
|
|
|
+ .crowd-block tr.row-highlight {{ outline: 2px solid #1565C0; outline-offset: -1px; }}
|
|
|
+
|
|
|
+ /* Bar chart */
|
|
|
+ .chart-section {{ margin-bottom: 30px; }}
|
|
|
+ .chart-title {{ font-size: 16px; font-weight: 500; margin-bottom: 15px; color: #333; }}
|
|
|
+ .bar-chart {{ display: flex; gap: 30px; align-items: flex-end; justify-content: center; padding: 20px; }}
|
|
|
+ .bar-group {{ text-align: center; }}
|
|
|
+ .bar-pair {{ display: flex; gap: 8px; align-items: flex-end; height: 200px; }}
|
|
|
+ .bar {{ width: 50px; border-radius: 4px 4px 0 0; transition: all 0.3s; cursor: pointer; position: relative; }}
|
|
|
+ .bar:hover {{ opacity: 0.8; }}
|
|
|
+ .bar-value {{ position: absolute; top: -25px; left: 50%; transform: translateX(-50%); font-size: 12px; font-weight: 500; white-space: nowrap; }}
|
|
|
+ .bar-label {{ margin-top: 10px; font-size: 13px; color: #333; }}
|
|
|
+ .bar-ratio {{ font-size: 11px; color: #666; margin-top: 3px; }}
|
|
|
+ .chart-legend {{ display: flex; gap: 20px; justify-content: center; margin-bottom: 15px; }}
|
|
|
+ .legend-item {{ display: flex; align-items: center; gap: 6px; font-size: 13px; }}
|
|
|
+ .legend-color {{ width: 16px; height: 16px; border-radius: 3px; }}
|
|
|
+
|
|
|
+ /* Insight box */
|
|
|
+ .insight-box {{ background: #e3f2fd; border-left: 4px solid #1976D2; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; }}
|
|
|
+ .insight-box h5 {{ color: #1565C0; margin-bottom: 8px; font-size: 14px; }}
|
|
|
+ .insight-box p {{ color: #333; font-size: 13px; line-height: 1.6; }}
|
|
|
+
|
|
|
+ /* Ranking */
|
|
|
+ .ranking-section {{ display: flex; gap: 30px; }}
|
|
|
+ .ranking-box {{ flex: 1; }}
|
|
|
+ .ranking-box h4 {{ font-size: 14px; margin-bottom: 10px; padding: 8px; border-radius: 4px; }}
|
|
|
+ .ranking-box.high h4 {{ background: #e8f5e9; color: #2e7d32; }}
|
|
|
+ .ranking-box.low h4 {{ background: #ffebee; color: #c62828; }}
|
|
|
+ .ranking-table {{ width: 100%; border-collapse: collapse; }}
|
|
|
+ .ranking-table th {{ background: #f5f5f5; padding: 8px; text-align: left; font-size: 12px; }}
|
|
|
+ .ranking-table td {{ padding: 6px 8px; border-bottom: 1px solid #eee; font-size: 12px; }}
|
|
|
+ .ranking-table .rn {{ width: 30px; color: #999; }}
|
|
|
+ .ranking-table .vov {{ font-family: monospace; text-align: right; }}
|
|
|
+ .ranking-table .exp {{ color: #666; text-align: right; }}
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+ <div class="container">
|
|
|
+ <h1>进入品类 × 推荐品类 效果分析</h1>
|
|
|
+ <p class="subtitle">用户从某个品类进入后,推荐不同品类的裂变效果对比</p>
|
|
|
+
|
|
|
+ <div class="tabs">
|
|
|
+ <button class="tab active" onclick="switchTab('matrix')">品类组合总览</button>
|
|
|
+ <button class="tab" onclick="switchTab('consistency')">同品类 vs 跨品类</button>
|
|
|
+ <button class="tab" onclick="switchTab('affinity')">哪些品类更搭</button>
|
|
|
+ <button class="tab" onclick="switchTab('ranking')">最佳/最差组合</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Tab 1: 头部品类矩阵 -->
|
|
|
+ <div id="tab-matrix" class="tab-content active">
|
|
|
+ <div class="controls">
|
|
|
+ <div class="control-group">
|
|
|
+ <label>人群:</label>
|
|
|
+ <select id="crowd-select" onchange="updateMatrix1()">{crowd_options_html}</select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>指标:</label>
|
|
|
+ <select id="metric-select" onchange="updateMatrix1()">
|
|
|
+ <option value="exp">exp</option>
|
|
|
+ <option value="str">str</option>
|
|
|
+ <option value="ros">ros</option>
|
|
|
+ <option value="rovn">rovn</option>
|
|
|
+ <option value="vov" selected>vov</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group date-switcher">
|
|
|
+ <label>日期:</label>
|
|
|
+ <button onclick="switchDate1(-1)">◀</button>
|
|
|
+ <select id="date-select" onchange="updateMatrix1()">{date_options_html}</select>
|
|
|
+ <button onclick="switchDate1(1)">▶</button>
|
|
|
+ <button id="play-btn1" class="play-btn" onclick="togglePlay1()">▶</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="summary" id="summary1"></div>
|
|
|
+
|
|
|
+ <div class="legend">
|
|
|
+ 行=头部品类,列=推荐品类 | 颜色越深=数值越高 | 点击表头排序
|
|
|
+ <button onclick="resetSort1()" style="margin-left:15px;padding:3px 10px;cursor:pointer;">重置</button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="matrix-container">
|
|
|
+ <table id="matrix-table1"><thead id="matrix-header1"></thead><tbody id="matrix-body1"></tbody></table>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- 下钻表格 -->
|
|
|
+ <div style="margin-top: 30px; border-top: 2px solid #e0e0e0; padding-top: 20px;">
|
|
|
+ <h3 style="margin-bottom: 15px; font-size: 16px; color: #333;">用户从某品类进入后,推荐了哪些品类?</h3>
|
|
|
+ <div class="controls">
|
|
|
+ <div class="control-group">
|
|
|
+ <label>头部品类:</label>
|
|
|
+ <select id="drill-head" onchange="updateHeadDrill()"></select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>排序:</label>
|
|
|
+ <select id="drill-sort" onchange="updateHeadDrill()">
|
|
|
+ <option value="exp" selected>exp</option>
|
|
|
+ <option value="str">str</option>
|
|
|
+ <option value="ros">ros</option>
|
|
|
+ <option value="rovn">rovn</option>
|
|
|
+ <option value="vov">vov</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>展示:</label>
|
|
|
+ <select id="drill-metric" onchange="updateHeadDrill()">
|
|
|
+ <option value="exp">exp</option>
|
|
|
+ <option value="str">str</option>
|
|
|
+ <option value="ros">ros</option>
|
|
|
+ <option value="rovn">rovn</option>
|
|
|
+ <option value="vov" selected>vov</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>Top:</label>
|
|
|
+ <select id="drill-topn" onchange="updateHeadDrill()">
|
|
|
+ <option value="5">5</option>
|
|
|
+ <option value="10" selected>10</option>
|
|
|
+ <option value="15">15</option>
|
|
|
+ <option value="20">20</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group date-switcher">
|
|
|
+ <label>日期:</label>
|
|
|
+ <button onclick="switchDrillDate(-1)">◀</button>
|
|
|
+ <select id="drill-date" onchange="initHeadDrill()">{date_options_html}</select>
|
|
|
+ <button onclick="switchDrillDate(1)">▶</button>
|
|
|
+ <button id="drill-play-btn" class="play-btn" onclick="toggleDrillPlay()">▶</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="compare-section" id="drill-section"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Tab 2: 品类一致性 -->
|
|
|
+ <div id="tab-consistency" class="tab-content">
|
|
|
+ <div class="summary">
|
|
|
+ <div class="stat-card green"><h4 id="same-vov">-</h4><p>同品类承接 vov</p></div>
|
|
|
+ <div class="stat-card orange"><h4 id="diff-vov">-</h4><p>跨品类承接 vov</p></div>
|
|
|
+ <div class="stat-card blue"><h4 id="vov-ratio">-</h4><p>同/跨品类比值</p></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="insight-box">
|
|
|
+ <h5>核心发现</h5>
|
|
|
+ <p>同品类承接(进入品类=承接品类)的裂变率显著高于跨品类承接,约为 <strong id="insight-ratio">-</strong> 倍。
|
|
|
+ 这说明用户对同类内容有更强的分享意愿,推荐系统在品类匹配上有优化空间。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="chart-section">
|
|
|
+ <div class="chart-title">各人群同品类 vs 跨品类 vov 对比</div>
|
|
|
+ <div class="chart-legend">
|
|
|
+ <div class="legend-item"><div class="legend-color" style="background:#4CAF50"></div>同品类承接</div>
|
|
|
+ <div class="legend-item"><div class="legend-color" style="background:#2196F3"></div>跨品类承接</div>
|
|
|
+ </div>
|
|
|
+ <div class="bar-chart" id="consistency-chart"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="chart-section">
|
|
|
+ <div class="chart-title">同品类曝光占比</div>
|
|
|
+ <div id="exp-ratio-chart" style="display:flex;gap:20px;justify-content:center;"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Tab 3: 品类亲和性矩阵 -->
|
|
|
+ <div id="tab-affinity" class="tab-content">
|
|
|
+ <div class="insight-box">
|
|
|
+ <h5>亲和性 = 这个组合的表现 / 进入品类的平均表现</h5>
|
|
|
+ <p>
|
|
|
+ <strong>举例</strong>:用户从「搞笑段子」进入,平均裂变率 0.4<br>
|
|
|
+ • 推荐「搞笑段子→搞笑段子」裂变率 0.8,亲和性 = 0.8/0.4 = <span style="color:#2e7d32;font-weight:bold">2.0 ✓ 更对味</span><br>
|
|
|
+ • 推荐「搞笑段子→历史名人」裂变率 0.2,亲和性 = 0.2/0.4 = <span style="color:#c62828;font-weight:bold">0.5 ✗ 不对味</span><br><br>
|
|
|
+ <strong>颜色</strong>:<span style="background:#c8e6c9;padding:2px 6px;border-radius:3px">绿色=高亲和</span>
|
|
|
+ <span style="background:#ffcdd2;padding:2px 6px;border-radius:3px;margin-left:10px">红色=低亲和</span>
|
|
|
+ </p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="controls">
|
|
|
+ <div class="control-group date-switcher">
|
|
|
+ <label>日期:</label>
|
|
|
+ <button onclick="switchAffDate(-1)">◀</button>
|
|
|
+ <select id="aff-date" onchange="updateAffMatrix()">{date_options_aff_html}</select>
|
|
|
+ <button onclick="switchAffDate(1)">▶</button>
|
|
|
+ <button id="aff-play-btn" class="play-btn" onclick="toggleAffPlay()">▶ 播放</button>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>人群:</label>
|
|
|
+ <select id="aff-crowd" onchange="updateAffMatrix()">
|
|
|
+ <option value="整体" selected>整体</option>
|
|
|
+ <option value="内部">内部</option>
|
|
|
+ <option value="外部0层">外部0层</option>
|
|
|
+ <option value="外部裂变">外部裂变</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>显示指标:</label>
|
|
|
+ <select id="aff-metric" onchange="updateAffMatrix()">
|
|
|
+ <option value="affinity" selected>亲和性 (affinity)</option>
|
|
|
+ <option value="vov">裂变率 (vov)</option>
|
|
|
+ <option value="exp">曝光量 (exp)</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="matrix-container">
|
|
|
+ <table id="aff-table"><thead id="aff-header"></thead><tbody id="aff-body"></tbody></table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <!-- Tab 4: 品类组合排名 -->
|
|
|
+ <div id="tab-ranking" class="tab-content">
|
|
|
+ <div class="insight-box">
|
|
|
+ <h5>筛选条件</h5>
|
|
|
+ <p>仅展示曝光量 ≥1000 的品类组合,确保结果稳定可靠。</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="controls">
|
|
|
+ <div class="control-group date-switcher">
|
|
|
+ <label>日期:</label>
|
|
|
+ <button onclick="switchRankDate(-1)">◀</button>
|
|
|
+ <select id="rank-date" onchange="initRanking()">{date_options_aff_html}</select>
|
|
|
+ <button onclick="switchRankDate(1)">▶</button>
|
|
|
+ <button id="rank-play-btn" class="play-btn" onclick="toggleRankPlay()">▶ 播放</button>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>人群:</label>
|
|
|
+ <select id="rank-crowd" onchange="initRanking()">
|
|
|
+ <option value="整体" selected>整体</option>
|
|
|
+ <option value="内部">内部</option>
|
|
|
+ <option value="外部0层">外部0层</option>
|
|
|
+ <option value="外部裂变">外部裂变</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="control-group">
|
|
|
+ <label>展示数量:</label>
|
|
|
+ <select id="rank-topn" onchange="initRanking()">
|
|
|
+ <option value="20">Top 20</option>
|
|
|
+ <option value="50">Top 50</option>
|
|
|
+ <option value="100">Top 100</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="ranking-section">
|
|
|
+ <div class="ranking-box high"><h4>Top 20 高裂变品类组合</h4><table class="ranking-table" id="high-ranking"></table></div>
|
|
|
+ <div class="ranking-box low"><h4>Top 20 低裂变品类组合</h4><table class="ranking-table" id="low-ranking"></table></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <script>
|
|
|
+ // Data
|
|
|
+ const allData = {data_json};
|
|
|
+ const headDrillData = {head_drill_json};
|
|
|
+ const crowdList = {crowd_list_json};
|
|
|
+ const dates = {dates_json};
|
|
|
+ const consistencyData = {consistency_json};
|
|
|
+ const affinityData = {affinity_json};
|
|
|
+ const rankingData = {ranking_json};
|
|
|
+ const dateListAff = {date_list_aff_json};
|
|
|
+
|
|
|
+ const crowdColors = {{ '内部': '#4CAF50', '外部0层': '#2196F3', '外部裂变': '#FF9800' }};
|
|
|
+ let playInterval1 = null, drillPlayInterval = null, affPlayInterval = null, rankPlayInterval = null;
|
|
|
+ let currentRowOrder = null, currentColOrder = null;
|
|
|
+ let sortState = {{ row: null, col: null, asc: true }};
|
|
|
+ let lastCrowd = null, lastDate = null;
|
|
|
+
|
|
|
+ // Tab switching
|
|
|
+ function switchTab(tabId) {{
|
|
|
+ document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
|
+ document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
|
|
+ document.querySelector(`[onclick="switchTab('${{tabId}}')"]`).classList.add('active');
|
|
|
+ document.getElementById('tab-' + tabId).classList.add('active');
|
|
|
+ }}
|
|
|
+
|
|
|
+ // Gradient
|
|
|
+ function getGradient(val, maxVal, minVal = 0) {{
|
|
|
+ if (val <= minVal || maxVal <= minVal) return '#f8f9fa';
|
|
|
+ const ratio = Math.min((val - minVal) / (maxVal - minVal), 1);
|
|
|
+ const r = Math.round(255 - ratio * 215);
|
|
|
+ const g = Math.round(255 - ratio * 88);
|
|
|
+ const b = Math.round(255 - ratio * 186);
|
|
|
+ return `rgb(${{r}},${{g}},${{b}})`;
|
|
|
+ }}
|
|
|
+
|
|
|
+ // ========== Tab 1: 头部品类矩阵 ==========
|
|
|
+ function updateMatrix1() {{
|
|
|
+ const crowd = document.getElementById('crowd-select').value;
|
|
|
+ const metric = document.getElementById('metric-select').value;
|
|
|
+ const date = document.getElementById('date-select').value;
|
|
|
+
|
|
|
+ if (!allData[crowd] || !allData[crowd][date]) {{
|
|
|
+ document.getElementById('summary1').innerHTML = '<div class="stat-card"><h4>-</h4><p>no data</p></div>';
|
|
|
+ document.getElementById('matrix-header1').innerHTML = '';
|
|
|
+ document.getElementById('matrix-body1').innerHTML = '';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const data = allData[crowd][date];
|
|
|
+
|
|
|
+ document.getElementById('summary1').innerHTML = `
|
|
|
+ <div class="stat-card"><h4>${{data.total_exp.toLocaleString()}}</h4><p>总 exp</p></div>
|
|
|
+ <div class="stat-card"><h4>${{data.total_str.toFixed(4)}}</h4><p>总 str</p></div>
|
|
|
+ <div class="stat-card"><h4>${{data.total_rovn.toFixed(4)}}</h4><p>总 rovn</p></div>
|
|
|
+ <div class="stat-card"><h4>${{data.rows.length}}</h4><p>头部品类数</p></div>
|
|
|
+ <div class="stat-card"><h4>${{data.cols.length}}</h4><p>推荐品类数</p></div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ const metricData = data[metric];
|
|
|
+ const allVals = [];
|
|
|
+ data.rows.forEach(r => data.cols.forEach(c => {{
|
|
|
+ const val = metricData[r]?.[c] || 0;
|
|
|
+ if (val > 0) allVals.push(val);
|
|
|
+ }}));
|
|
|
+ allVals.sort((a, b) => a - b);
|
|
|
+
|
|
|
+ const p95Idx = Math.floor(allVals.length * 0.95);
|
|
|
+ let maxVal = allVals.length > 0 ? allVals[Math.min(p95Idx, allVals.length - 1)] : 0;
|
|
|
+ const thresholds = {{ exp: 10000, str: 0.1, ros: 0.5, rovn: 0.05, vov: 0.3 }};
|
|
|
+ maxVal = Math.max(maxVal, thresholds[metric] || 0.1);
|
|
|
+
|
|
|
+ if (crowd !== lastCrowd || date !== lastDate) {{
|
|
|
+ currentRowOrder = null;
|
|
|
+ currentColOrder = null;
|
|
|
+ sortState = {{ row: null, col: null, asc: true }};
|
|
|
+ lastCrowd = crowd;
|
|
|
+ lastDate = date;
|
|
|
+ }}
|
|
|
+
|
|
|
+ if (!currentRowOrder) currentRowOrder = [...data.rows];
|
|
|
+ if (!currentColOrder) currentColOrder = [...data.cols];
|
|
|
+
|
|
|
+ const rows = currentRowOrder.filter(r => data.rows.includes(r));
|
|
|
+ const cols = currentColOrder.filter(c => data.cols.includes(c));
|
|
|
+
|
|
|
+ const expData = data.exp;
|
|
|
+ const rowExpTotals = {{}};
|
|
|
+ const colExpTotals = {{}};
|
|
|
+ rows.forEach(r => {{ rowExpTotals[r] = cols.reduce((sum, c) => sum + (expData[r]?.[c] || 0), 0); }});
|
|
|
+ cols.forEach(c => {{ colExpTotals[c] = rows.reduce((sum, r) => sum + (expData[r]?.[c] || 0), 0); }});
|
|
|
+
|
|
|
+ const origRowOrder = [...data.rows];
|
|
|
+ const origColOrder = [...data.cols];
|
|
|
+
|
|
|
+ document.getElementById('matrix-header1').innerHTML = `
|
|
|
+ <tr>
|
|
|
+ <th class="corner-cell" style="cursor:pointer" onclick="sortByRowSum1()">
|
|
|
+ <span class="row-label">头部品类 ↓</span>
|
|
|
+ <span class="col-label">推荐品类 →</span>
|
|
|
+ </th>
|
|
|
+ ${{cols.map((c, i) => {{
|
|
|
+ const origRank = origColOrder.indexOf(c) + 1;
|
|
|
+ return `<th style="cursor:pointer" onclick="sortByCol1('${{c}}')" title="推荐品类: ${{c}} exp排名: #${{origRank}} exp: ${{colExpTotals[c].toLocaleString()}}">#${{origRank}} ${{c}}</th>`;
|
|
|
+ }}).join('')}}
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+
|
|
|
+ document.getElementById('matrix-body1').innerHTML = rows.map((r, ri) => {{
|
|
|
+ const origRowRank = origRowOrder.indexOf(r) + 1;
|
|
|
+ const cells = cols.map(c => {{
|
|
|
+ const val = metricData[r]?.[c] || 0;
|
|
|
+ const cellExp = expData[r]?.[c] || 0;
|
|
|
+ const bg = getGradient(val, maxVal);
|
|
|
+ const display = metric === 'exp' ? parseInt(val).toLocaleString() : val.toFixed(4);
|
|
|
+ const rowPct = rowExpTotals[r] > 0 ? (cellExp / rowExpTotals[r] * 100).toFixed(1) : '0.0';
|
|
|
+ const colPct = colExpTotals[c] > 0 ? (cellExp / colExpTotals[c] * 100).toFixed(1) : '0.0';
|
|
|
+ return `<td style="background:${{bg}}" title="头部: ${{r}} 推荐: ${{c}} ${{metric}}: ${{display}} exp: ${{cellExp.toLocaleString()}} 横向占比: ${{rowPct}}% 纵向占比: ${{colPct}}%">${{display}}</td>`;
|
|
|
+ }}).join('');
|
|
|
+ return `<tr><td style="cursor:pointer;background:#f5f5f5" onclick="sortByRow1('${{r}}')" title="头部品类: ${{r}} exp排名: #${{origRowRank}} exp: ${{rowExpTotals[r].toLocaleString()}}">#${{origRowRank}} ${{r}}</td>${{cells}}</tr>`;
|
|
|
+ }}).join('');
|
|
|
+ }}
|
|
|
+
|
|
|
+ function switchDate1(delta) {{
|
|
|
+ const select = document.getElementById('date-select');
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
+ const newIdx = idx + delta;
|
|
|
+ if (newIdx >= 0 && newIdx < dates.length) {{
|
|
|
+ select.value = dates[newIdx];
|
|
|
+ updateMatrix1();
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function togglePlay1() {{
|
|
|
+ const btn = document.getElementById('play-btn1');
|
|
|
+ if (playInterval1) {{
|
|
|
+ clearInterval(playInterval1);
|
|
|
+ playInterval1 = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶';
|
|
|
+ }} else {{
|
|
|
+ btn.classList.add('playing');
|
|
|
+ btn.textContent = '⏸';
|
|
|
+ let idx = 0;
|
|
|
+ const play = () => {{
|
|
|
+ if (idx >= dates.length) {{
|
|
|
+ clearInterval(playInterval1);
|
|
|
+ playInterval1 = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+ document.getElementById('date-select').value = dates[idx];
|
|
|
+ updateMatrix1();
|
|
|
+ idx++;
|
|
|
+ }};
|
|
|
+ play();
|
|
|
+ playInterval1 = setInterval(play, 1500);
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function getCurrentData1() {{
|
|
|
+ const crowd = document.getElementById('crowd-select').value;
|
|
|
+ const date = document.getElementById('date-select').value;
|
|
|
+ const metric = document.getElementById('metric-select').value;
|
|
|
+ if (!allData[crowd] || !allData[crowd][date]) return null;
|
|
|
+ return {{ data: allData[crowd][date], metric }};
|
|
|
+ }}
|
|
|
+
|
|
|
+ function sortByRowSum1() {{
|
|
|
+ const result = getCurrentData1();
|
|
|
+ if (!result) return;
|
|
|
+ const {{ data, metric }} = result;
|
|
|
+ const metricData = data[metric];
|
|
|
+ const rowSums = {{}};
|
|
|
+ data.rows.forEach(r => {{ rowSums[r] = data.cols.reduce((sum, c) => sum + (metricData[r]?.[c] || 0), 0); }});
|
|
|
+ sortState.asc = sortState.row === 'sum' ? !sortState.asc : false;
|
|
|
+ sortState.row = 'sum';
|
|
|
+ currentRowOrder = [...data.rows].sort((a, b) => sortState.asc ? rowSums[a] - rowSums[b] : rowSums[b] - rowSums[a]);
|
|
|
+ updateMatrix1();
|
|
|
+ }}
|
|
|
+
|
|
|
+ function sortByCol1(colName) {{
|
|
|
+ const result = getCurrentData1();
|
|
|
+ if (!result) return;
|
|
|
+ const {{ data, metric }} = result;
|
|
|
+ const metricData = data[metric];
|
|
|
+ sortState.asc = sortState.col === colName ? !sortState.asc : false;
|
|
|
+ sortState.col = colName;
|
|
|
+ currentRowOrder = [...data.rows].sort((a, b) => {{
|
|
|
+ const va = metricData[a]?.[colName] || 0;
|
|
|
+ const vb = metricData[b]?.[colName] || 0;
|
|
|
+ return sortState.asc ? va - vb : vb - va;
|
|
|
+ }});
|
|
|
+ updateMatrix1();
|
|
|
+ }}
|
|
|
+
|
|
|
+ function sortByRow1(rowName) {{
|
|
|
+ const result = getCurrentData1();
|
|
|
+ if (!result) return;
|
|
|
+ const {{ data, metric }} = result;
|
|
|
+ const metricData = data[metric];
|
|
|
+ sortState.asc = sortState.row === rowName ? !sortState.asc : false;
|
|
|
+ sortState.row = rowName;
|
|
|
+ currentColOrder = [...data.cols].sort((a, b) => {{
|
|
|
+ const va = metricData[rowName]?.[a] || 0;
|
|
|
+ const vb = metricData[rowName]?.[b] || 0;
|
|
|
+ return sortState.asc ? va - vb : vb - va;
|
|
|
+ }});
|
|
|
+ updateMatrix1();
|
|
|
+ }}
|
|
|
+
|
|
|
+ function resetSort1() {{
|
|
|
+ currentRowOrder = null;
|
|
|
+ currentColOrder = null;
|
|
|
+ sortState = {{ row: null, col: null, asc: true }};
|
|
|
+ updateMatrix1();
|
|
|
+ }}
|
|
|
+
|
|
|
+ // 下钻
|
|
|
+ function initHeadDrill() {{
|
|
|
+ const date = document.getElementById('drill-date').value;
|
|
|
+ const headSelect = document.getElementById('drill-head');
|
|
|
+
|
|
|
+ if (!headDrillData[date]) {{
|
|
|
+ headSelect.innerHTML = '<option value="">无数据</option>';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const heads = headDrillData[date].heads;
|
|
|
+ headSelect.innerHTML = heads.map((h, i) => {{
|
|
|
+ const label = h === 'all' ? '全部(不区分头部品类)' : `#${{i}} ${{h}}`;
|
|
|
+ return `<option value="${{h}}">${{label}}</option>`;
|
|
|
+ }}).join('');
|
|
|
+
|
|
|
+ updateHeadDrill();
|
|
|
+ }}
|
|
|
+
|
|
|
+ function updateHeadDrill() {{
|
|
|
+ const date = document.getElementById('drill-date').value;
|
|
|
+ const headCate = document.getElementById('drill-head').value;
|
|
|
+ const sortBy = document.getElementById('drill-sort').value;
|
|
|
+ const showMetric = document.getElementById('drill-metric').value;
|
|
|
+ const topN = parseInt(document.getElementById('drill-topn').value);
|
|
|
+
|
|
|
+ if (!headDrillData[date] || !headCate) {{
|
|
|
+ document.getElementById('drill-section').innerHTML = '<p>无数据</p>';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const data = headDrillData[date].data[headCate];
|
|
|
+ if (!data) {{
|
|
|
+ document.getElementById('drill-section').innerHTML = '<p>该头部品类无数据</p>';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const crowdTopN = {{}};
|
|
|
+ const crowdTotal = {{}};
|
|
|
+ crowdList.forEach(crowd => {{
|
|
|
+ const items = [];
|
|
|
+ if (data[crowd]) {{
|
|
|
+ for (const cat in data[crowd]) {{
|
|
|
+ if (cat === '_total') {{
|
|
|
+ crowdTotal[crowd] = {{ exp: data[crowd][cat].exp || 0, showVal: data[crowd][cat][showMetric] || 0 }};
|
|
|
+ }} else {{
|
|
|
+ items.push({{
|
|
|
+ cat: cat,
|
|
|
+ sortVal: data[crowd][cat][sortBy] || 0,
|
|
|
+ showVal: data[crowd][cat][showMetric] || 0,
|
|
|
+ exp: data[crowd][cat].exp || 0
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+ items.sort((a, b) => b.sortVal - a.sortVal);
|
|
|
+ crowdTopN[crowd] = items.slice(0, topN);
|
|
|
+ }});
|
|
|
+
|
|
|
+ const allCats = new Set();
|
|
|
+ crowdList.forEach(crowd => {{ crowdTopN[crowd].forEach(item => allCats.add(item.cat)); }});
|
|
|
+ const catList = Array.from(allCats);
|
|
|
+
|
|
|
+ const catColors = {{}};
|
|
|
+ const colorPalette = ['#FFCDD2', '#F8BBD0', '#E1BEE7', '#D1C4E9', '#C5CAE9', '#BBDEFB', '#B3E5FC', '#B2EBF2', '#B2DFDB', '#C8E6C9', '#DCEDC8', '#F0F4C3', '#FFF9C4', '#FFECB3', '#FFE0B2', '#FFCCBC', '#D7CCC8', '#CFD8DC', '#BCAAA4', '#B0BEC5'];
|
|
|
+ catList.forEach((cat, i) => {{ catColors[cat] = colorPalette[i % colorPalette.length]; }});
|
|
|
+
|
|
|
+ let maxVal = 0, minVal = Infinity;
|
|
|
+ crowdList.forEach(crowd => {{
|
|
|
+ crowdTopN[crowd].forEach(item => {{
|
|
|
+ if (item.showVal > maxVal) maxVal = item.showVal;
|
|
|
+ if (item.showVal < minVal) minVal = item.showVal;
|
|
|
+ }});
|
|
|
+ }});
|
|
|
+ if (minVal === Infinity) minVal = 0;
|
|
|
+
|
|
|
+ function getValueColor(val) {{
|
|
|
+ if (maxVal === minVal) return '#C8E6C9';
|
|
|
+ const ratio = (val - minVal) / (maxVal - minVal);
|
|
|
+ const r = Math.round(200 - ratio * 120);
|
|
|
+ const g = Math.round(230 - ratio * 80);
|
|
|
+ const b = Math.round(201 - ratio * 120);
|
|
|
+ return `rgb(${{r}},${{g}},${{b}})`;
|
|
|
+ }}
|
|
|
+
|
|
|
+ let html = '';
|
|
|
+ crowdList.forEach(crowd => {{
|
|
|
+ const colSpan = showMetric === 'exp' ? 3 : 4;
|
|
|
+ html += `<div class="crowd-block">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr><th colspan="${{colSpan}}" style="background:${{crowdColors[crowd]}};color:white">${{crowd}}</th></tr>
|
|
|
+ <tr><th class="rn">rn</th><th>推荐品类</th><th>exp</th>${{showMetric !== 'exp' ? `<th>${{showMetric}}</th>` : ''}}</tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>`;
|
|
|
+
|
|
|
+ if (crowdTopN[crowd].length === 0) {{
|
|
|
+ html += `<tr><td colspan="${{colSpan}}" style="color:#999">无数据</td></tr>`;
|
|
|
+ }} else {{
|
|
|
+ if (crowdTotal[crowd]) {{
|
|
|
+ const totalExp = parseInt(crowdTotal[crowd].exp).toLocaleString();
|
|
|
+ const totalMetric = (crowdTotal[crowd].showVal * 100).toFixed(1) + '%';
|
|
|
+ html += `<tr style="background:#f5f5f5;font-weight:bold">
|
|
|
+ <td class="rn">0</td>
|
|
|
+ <td class="cat" style="background:#e0e0e0">整体</td>
|
|
|
+ <td class="val">${{totalExp}}</td>
|
|
|
+ ${{showMetric !== 'exp' ? `<td class="val">${{totalMetric}}</td>` : ''}}
|
|
|
+ </tr>`;
|
|
|
+ }}
|
|
|
+ crowdTopN[crowd].forEach((item, i) => {{
|
|
|
+ const expDisplay = parseInt(item.exp).toLocaleString();
|
|
|
+ const metricDisplay = (item.showVal * 100).toFixed(1) + '%';
|
|
|
+ const valColor = getValueColor(item.showVal);
|
|
|
+ const catColor = catColors[item.cat];
|
|
|
+ const catAttr = item.cat.replace(/"/g, '"');
|
|
|
+ html += `<tr>
|
|
|
+ <td class="rn">${{i + 1}}</td>
|
|
|
+ <td class="cat" style="background:${{catColor}}" data-cat="${{catAttr}}" onmouseenter="highlightCat(this)" onmouseleave="unhighlightCat()">${{item.cat}}</td>
|
|
|
+ <td class="val">${{expDisplay}}</td>
|
|
|
+ ${{showMetric !== 'exp' ? `<td class="val" style="background:${{valColor}}">${{metricDisplay}}</td>` : ''}}
|
|
|
+ </tr>`;
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+ html += `</tbody></table></div>`;
|
|
|
+ }});
|
|
|
+
|
|
|
+ document.getElementById('drill-section').innerHTML = html;
|
|
|
+ }}
|
|
|
+
|
|
|
+ function switchDrillDate(delta) {{
|
|
|
+ const select = document.getElementById('drill-date');
|
|
|
+ const idx = dates.indexOf(select.value);
|
|
|
+ const newIdx = idx + delta;
|
|
|
+ if (newIdx >= 0 && newIdx < dates.length) {{
|
|
|
+ select.value = dates[newIdx];
|
|
|
+ initHeadDrill();
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function toggleDrillPlay() {{
|
|
|
+ const btn = document.getElementById('drill-play-btn');
|
|
|
+ if (drillPlayInterval) {{
|
|
|
+ clearInterval(drillPlayInterval);
|
|
|
+ drillPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶';
|
|
|
+ }} else {{
|
|
|
+ btn.classList.add('playing');
|
|
|
+ btn.textContent = '⏸';
|
|
|
+ let idx = 0;
|
|
|
+ const play = () => {{
|
|
|
+ if (idx >= dates.length) {{
|
|
|
+ clearInterval(drillPlayInterval);
|
|
|
+ drillPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+ document.getElementById('drill-date').value = dates[idx];
|
|
|
+ initHeadDrill();
|
|
|
+ idx++;
|
|
|
+ }};
|
|
|
+ play();
|
|
|
+ drillPlayInterval = setInterval(play, 1500);
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function highlightCat(el) {{
|
|
|
+ const cat = el.getAttribute('data-cat');
|
|
|
+ document.querySelectorAll('.cat[data-cat]').forEach(cell => {{
|
|
|
+ if (cell.getAttribute('data-cat') === cat) {{
|
|
|
+ cell.classList.add('highlight');
|
|
|
+ cell.closest('tr').classList.add('row-highlight');
|
|
|
+ }}
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+
|
|
|
+ function unhighlightCat() {{
|
|
|
+ document.querySelectorAll('.cat.highlight').forEach(cell => {{
|
|
|
+ cell.classList.remove('highlight');
|
|
|
+ cell.closest('tr').classList.remove('row-highlight');
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+
|
|
|
+ // ========== Tab 2: 品类一致性 ==========
|
|
|
+ function initConsistency() {{
|
|
|
+ const data = consistencyData;
|
|
|
+ document.getElementById('same-vov').textContent = data.total_same.toFixed(4);
|
|
|
+ document.getElementById('diff-vov').textContent = data.total_diff.toFixed(4);
|
|
|
+ document.getElementById('vov-ratio').textContent = data.total_ratio.toFixed(2) + 'x';
|
|
|
+ document.getElementById('insight-ratio').textContent = data.total_ratio.toFixed(2);
|
|
|
+
|
|
|
+ const maxVov = Math.max(...data.same, ...data.diff);
|
|
|
+ const chartHtml = data.crowds.map((crowd, i) => {{
|
|
|
+ const sameH = Math.round(data.same[i] / maxVov * 180);
|
|
|
+ const diffH = Math.round(data.diff[i] / maxVov * 180);
|
|
|
+ return `
|
|
|
+ <div class="bar-group">
|
|
|
+ <div class="bar-pair">
|
|
|
+ <div class="bar" style="height:${{sameH}}px;background:#4CAF50">
|
|
|
+ <span class="bar-value">${{data.same[i].toFixed(4)}}</span>
|
|
|
+ </div>
|
|
|
+ <div class="bar" style="height:${{diffH}}px;background:#2196F3">
|
|
|
+ <span class="bar-value">${{data.diff[i].toFixed(4)}}</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="bar-label">${{crowd}}</div>
|
|
|
+ <div class="bar-ratio">${{data.ratio[i]}}x</div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }}).join('');
|
|
|
+ document.getElementById('consistency-chart').innerHTML = chartHtml;
|
|
|
+
|
|
|
+ const expHtml = data.crowds.map((crowd, i) => {{
|
|
|
+ const total = data.same_exp[i] + data.diff_exp[i];
|
|
|
+ const sameRatio = total > 0 ? (data.same_exp[i] / total * 100).toFixed(1) : 0;
|
|
|
+ return `
|
|
|
+ <div style="text-align:center">
|
|
|
+ <div style="font-size:13px;margin-bottom:5px">${{crowd}}</div>
|
|
|
+ <div style="width:150px;height:20px;background:#e0e0e0;border-radius:10px;overflow:hidden">
|
|
|
+ <div style="width:${{sameRatio}}%;height:100%;background:#4CAF50"></div>
|
|
|
+ </div>
|
|
|
+ <div style="font-size:11px;color:#666;margin-top:3px">同品类占比: ${{sameRatio}}%</div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }}).join('');
|
|
|
+ document.getElementById('exp-ratio-chart').innerHTML = expHtml;
|
|
|
+ }}
|
|
|
+
|
|
|
+ // ========== Tab 3: 品类亲和性矩阵 ==========
|
|
|
+ function updateAffMatrix() {{
|
|
|
+ const date = document.getElementById('aff-date').value;
|
|
|
+ const crowd = document.getElementById('aff-crowd').value;
|
|
|
+ const metric = document.getElementById('aff-metric').value;
|
|
|
+
|
|
|
+ if (!affinityData[date] || !affinityData[date][crowd]) {{
|
|
|
+ document.getElementById('aff-header').innerHTML = '<tr><th>无数据</th></tr>';
|
|
|
+ document.getElementById('aff-body').innerHTML = '';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const data = affinityData[date][crowd];
|
|
|
+ const metricData = data[metric];
|
|
|
+
|
|
|
+ const allVals = [];
|
|
|
+ data.rows.forEach(r => data.cols.forEach(c => {{
|
|
|
+ const val = metricData[r]?.[c] || 0;
|
|
|
+ if (val > 0) allVals.push(val);
|
|
|
+ }}));
|
|
|
+
|
|
|
+ let maxVal, minVal = 0;
|
|
|
+ if (metric === 'affinity') {{
|
|
|
+ maxVal = 2; minVal = 0.5;
|
|
|
+ }} else if (metric === 'vov') {{
|
|
|
+ allVals.sort((a, b) => a - b);
|
|
|
+ maxVal = allVals[Math.floor(allVals.length * 0.95)] || 1;
|
|
|
+ }} else {{
|
|
|
+ allVals.sort((a, b) => a - b);
|
|
|
+ maxVal = allVals[Math.floor(allVals.length * 0.9)] || 100000;
|
|
|
+ }}
|
|
|
+
|
|
|
+ function getColor(val) {{
|
|
|
+ if (metric === 'affinity') {{
|
|
|
+ if (val >= 1) {{
|
|
|
+ const ratio = Math.min((val - 1) / (maxVal - 1), 1);
|
|
|
+ return `rgb(${{Math.round(200 - ratio * 200)}}, ${{Math.round(230 - ratio * 30)}}, ${{Math.round(200 - ratio * 200)}})`;
|
|
|
+ }} else {{
|
|
|
+ const ratio = Math.min((1 - val) / (1 - minVal), 1);
|
|
|
+ return `rgb(${{Math.round(230 - ratio * 30)}}, ${{Math.round(200 - ratio * 200)}}, ${{Math.round(200 - ratio * 200)}})`;
|
|
|
+ }}
|
|
|
+ }} else {{
|
|
|
+ const ratio = Math.min(val / maxVal, 1);
|
|
|
+ return `rgb(${{Math.round(255 - ratio * 215)}}, ${{Math.round(255 - ratio * 88)}}, ${{Math.round(255 - ratio * 186)}})`;
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ const expData = data.exp;
|
|
|
+ const rowTotals = {{}};
|
|
|
+ const colTotals = {{}};
|
|
|
+ data.rows.forEach(r => {{ rowTotals[r] = data.cols.reduce((sum, c) => sum + (expData[r]?.[c] || 0), 0); }});
|
|
|
+ data.cols.forEach(c => {{ colTotals[c] = data.rows.reduce((sum, r) => sum + (expData[r]?.[c] || 0), 0); }});
|
|
|
+
|
|
|
+ document.getElementById('aff-header').innerHTML = `
|
|
|
+ <tr>
|
|
|
+ <th class="corner-cell" style="width:120px">进入↓ 承接→</th>
|
|
|
+ ${{data.cols.map((c, ci) => `<th data-col="${{ci}}" title="${{c}}\\nexp: ${{colTotals[c].toLocaleString()}}">${{c.length > 6 ? c.substring(0,6) + '..' : c}}</th>`).join('')}}
|
|
|
+ </tr>
|
|
|
+ `;
|
|
|
+
|
|
|
+ document.getElementById('aff-body').innerHTML = data.rows.map((r, ri) => {{
|
|
|
+ const cells = data.cols.map((c, ci) => {{
|
|
|
+ const val = metricData[r]?.[c] || 0;
|
|
|
+ const exp = expData[r]?.[c] || 0;
|
|
|
+ const bg = val > 0 ? getColor(val) : '#f8f9fa';
|
|
|
+ const isDiagonal = (r === c);
|
|
|
+ let display;
|
|
|
+ if (metric === 'exp') {{
|
|
|
+ display = val > 0 ? (val >= 10000 ? Math.round(val/1000) + 'k' : val) : '-';
|
|
|
+ }} else {{
|
|
|
+ display = val > 0 ? val.toFixed(2) : '-';
|
|
|
+ }}
|
|
|
+ const rowPct = rowTotals[r] > 0 ? (exp / rowTotals[r] * 100).toFixed(1) : '0.0';
|
|
|
+ const colPct = colTotals[c] > 0 ? (exp / colTotals[c] * 100).toFixed(1) : '0.0';
|
|
|
+ const tooltip = `进入: ${{r}}\\n承接: ${{c}}\\n${{metric}}: ${{val}}\\nexp: ${{exp.toLocaleString()}}\\n横向占比: ${{rowPct}}%\\n纵向占比: ${{colPct}}%${{isDiagonal ? '\\n★ 同品类承接' : ''}}`;
|
|
|
+ const border = isDiagonal ? 'border:2px solid #1565C0;' : '';
|
|
|
+ return `<td data-row="${{ri}}" data-col="${{ci}}" style="background:${{bg}};${{border}}" title="${{tooltip}}" onmouseenter="highlightAffCell(${{ri}},${{ci}})" onmouseleave="unhighlightAffCell()">${{display}}</td>`;
|
|
|
+ }}).join('');
|
|
|
+ return `<tr><td class="row-header" data-row="${{ri}}" title="${{r}}\\nexp: ${{rowTotals[r].toLocaleString()}}">${{r.length > 10 ? r.substring(0,10) + '..' : r}}</td>${{cells}}</tr>`;
|
|
|
+ }}).join('');
|
|
|
+ }}
|
|
|
+
|
|
|
+ function highlightAffCell(row, col) {{
|
|
|
+ document.querySelectorAll('#aff-header th[data-col]').forEach(th => {{
|
|
|
+ if (parseInt(th.dataset.col) === col) th.classList.add('highlight');
|
|
|
+ }});
|
|
|
+ document.querySelectorAll('#aff-body .row-header').forEach(td => {{
|
|
|
+ if (parseInt(td.dataset.row) === row) td.classList.add('highlight');
|
|
|
+ }});
|
|
|
+ }}
|
|
|
+
|
|
|
+ function unhighlightAffCell() {{
|
|
|
+ document.querySelectorAll('.highlight').forEach(el => el.classList.remove('highlight'));
|
|
|
+ }}
|
|
|
+
|
|
|
+ function switchAffDate(delta) {{
|
|
|
+ const select = document.getElementById('aff-date');
|
|
|
+ const idx = dateListAff.indexOf(select.value);
|
|
|
+ const newIdx = idx + delta;
|
|
|
+ if (newIdx >= 0 && newIdx < dateListAff.length) {{
|
|
|
+ select.value = dateListAff[newIdx];
|
|
|
+ updateAffMatrix();
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function toggleAffPlay() {{
|
|
|
+ const btn = document.getElementById('aff-play-btn');
|
|
|
+ if (affPlayInterval) {{
|
|
|
+ clearInterval(affPlayInterval);
|
|
|
+ affPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶ 播放';
|
|
|
+ }} else {{
|
|
|
+ btn.classList.add('playing');
|
|
|
+ btn.textContent = '⏸ 停止';
|
|
|
+ let idx = 1;
|
|
|
+ const play = () => {{
|
|
|
+ if (idx >= dateListAff.length) {{
|
|
|
+ clearInterval(affPlayInterval);
|
|
|
+ affPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶ 播放';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+ document.getElementById('aff-date').value = dateListAff[idx];
|
|
|
+ updateAffMatrix();
|
|
|
+ idx++;
|
|
|
+ }};
|
|
|
+ play();
|
|
|
+ affPlayInterval = setInterval(play, 1500);
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ // ========== Tab 4: 品类组合排名 ==========
|
|
|
+ function initRanking() {{
|
|
|
+ const date = document.getElementById('rank-date').value;
|
|
|
+ const crowd = document.getElementById('rank-crowd').value;
|
|
|
+ const topN = parseInt(document.getElementById('rank-topn').value);
|
|
|
+
|
|
|
+ if (!rankingData[date] || !rankingData[date][crowd]) {{
|
|
|
+ document.getElementById('high-ranking').innerHTML = '<tbody><tr><td>无数据</td></tr></tbody>';
|
|
|
+ document.getElementById('low-ranking').innerHTML = '<tbody><tr><td>无数据</td></tr></tbody>';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const data = rankingData[date][crowd];
|
|
|
+
|
|
|
+ function renderTable(items, tableId) {{
|
|
|
+ const sliced = items.slice(0, topN);
|
|
|
+ const html = `
|
|
|
+ <thead><tr><th class="rn">#</th><th>品类组合</th><th class="vov">vov</th><th class="exp">曝光</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${{sliced.map((item, i) => `
|
|
|
+ <tr>
|
|
|
+ <td class="rn">${{i + 1}}</td>
|
|
|
+ <td style="background:${{item.same ? '#e8f5e9' : '#fff3e0'}}">
|
|
|
+ <span style="color:${{item.same ? '#2e7d32' : '#e65100'}};font-size:10px;margin-right:4px">${{item.same ? '●同' : '○跨'}}</span>
|
|
|
+ ${{item.pair}}
|
|
|
+ </td>
|
|
|
+ <td class="vov">${{item.vov.toFixed(4)}}</td>
|
|
|
+ <td class="exp">${{item.exp.toLocaleString()}}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}}
|
|
|
+ </tbody>
|
|
|
+ `;
|
|
|
+ document.getElementById(tableId).innerHTML = html;
|
|
|
+ }}
|
|
|
+
|
|
|
+ const dateLabel = date === '全部' ? '' : ` [${{date}}]`;
|
|
|
+ const crowdLabel = crowd === '整体' ? '' : ` (${{crowd}})`;
|
|
|
+ document.querySelector('.ranking-box.high h4').textContent = `Top ${{topN}} 高裂变品类组合${{crowdLabel}}${{dateLabel}}`;
|
|
|
+ document.querySelector('.ranking-box.low h4').textContent = `Top ${{topN}} 低裂变品类组合${{crowdLabel}}${{dateLabel}}`;
|
|
|
+
|
|
|
+ renderTable(data.high, 'high-ranking');
|
|
|
+ renderTable(data.low, 'low-ranking');
|
|
|
+ }}
|
|
|
+
|
|
|
+ function switchRankDate(delta) {{
|
|
|
+ const select = document.getElementById('rank-date');
|
|
|
+ const idx = dateListAff.indexOf(select.value);
|
|
|
+ const newIdx = idx + delta;
|
|
|
+ if (newIdx >= 0 && newIdx < dateListAff.length) {{
|
|
|
+ select.value = dateListAff[newIdx];
|
|
|
+ initRanking();
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ function toggleRankPlay() {{
|
|
|
+ const btn = document.getElementById('rank-play-btn');
|
|
|
+ if (rankPlayInterval) {{
|
|
|
+ clearInterval(rankPlayInterval);
|
|
|
+ rankPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶ 播放';
|
|
|
+ }} else {{
|
|
|
+ btn.classList.add('playing');
|
|
|
+ btn.textContent = '⏸ 停止';
|
|
|
+ let idx = 1;
|
|
|
+ const play = () => {{
|
|
|
+ if (idx >= dateListAff.length) {{
|
|
|
+ clearInterval(rankPlayInterval);
|
|
|
+ rankPlayInterval = null;
|
|
|
+ btn.classList.remove('playing');
|
|
|
+ btn.textContent = '▶ 播放';
|
|
|
+ return;
|
|
|
+ }}
|
|
|
+ document.getElementById('rank-date').value = dateListAff[idx];
|
|
|
+ initRanking();
|
|
|
+ idx++;
|
|
|
+ }};
|
|
|
+ play();
|
|
|
+ rankPlayInterval = setInterval(play, 1500);
|
|
|
+ }}
|
|
|
+ }}
|
|
|
+
|
|
|
+ // Initialize
|
|
|
+ updateMatrix1();
|
|
|
+ initHeadDrill();
|
|
|
+ initConsistency();
|
|
|
+ updateAffMatrix();
|
|
|
+ initRanking();
|
|
|
+ </script>
|
|
|
+</body>
|
|
|
+</html>
|
|
|
+"""
|
|
|
+
|
|
|
+html_file = output_dir / "品类组合分析.html"
|
|
|
+with open(html_file, 'w', encoding='utf-8') as f:
|
|
|
+ f.write(html_content)
|
|
|
+
|
|
|
+print(f"\nHTML 报告已生成: {html_file}")
|