Plotly印度数字体系适配:Lakh与Crore单位动态可视化
1. 项目概述让Plotly图表真正“说印地语”——印度数字体系在数据可视化中的落地实践我在给一家孟买本地快消品公司做销售仪表盘时第一次被客户当面指着图表问“这个12.5后面写的‘M’是什么意思是百万可我们账上从来不说‘百万’只说‘一亿二千五百万卢比’也就是‘1.25 crore’。”那一刻我意识到再漂亮的交互式图表如果数字单位不贴合用户的日常语言习惯就等于在专业沟通中主动设置了一道理解屏障。这不是一个简单的格式化问题而是数据叙事的文化适配问题。印度数字体系Indian Number System的核心在于其独特的分组逻辑每两位一组从右向左依次为个、十、百、千thousand、万ten thousand、十万lakh、千万crore而不是国际通用的三位一组thousand, million, billion。这意味着1,00,00,000在印度读作“one crore”而非“ten million”。Plotly作为目前最主流的Python交互式绘图库其原生设计完全基于国际单位体系tickformat等内置参数对“lakh”、“crore”毫无感知。原文作者Rahul Shah提出的方案本质上是一次“外科手术式”的文化适配——不依赖库的内置功能而是通过预处理数据、动态计算单位、手动注入文本标签的方式让图表在视觉层和交互层都完成本土化转译。这背后涉及三个关键动作一是对原始数值进行科学的量级归一化除以10⁵或10⁷二是根据数据范围智能判定应采用哪个单位K/Lac/Cr三是将单位符号精准嵌入到坐标轴标签tickprefix/ticksuffix和悬停提示hovertemplate中。它不是炫技而是解决真实业务场景中“数据可读性即生产力”的务实方案。如果你正在为南亚、东南亚或中东市场的客户构建BI系统或者你的团队内部日常沟通就使用“lakh”和“crore”那么这套方法论就是你绕不开的必修课。它不需要你重写Plotly源码也不需要引入第三方插件只需要理解数字背后的量级逻辑并用几行清晰的Python代码完成一次精准的“文化翻译”。2. 核心思路拆解为什么必须放弃“自动格式化”选择“手动归一化动态标注”2.1 Plotly的“国际中心主义”设计局限Plotly的坐标轴格式化能力比如tickformat参数其底层逻辑是基于国际单位制SI的缩写体系。当你设置tickformat.2s时它会自动将1,000,000显示为“1.00M”将1,000,000,000显示为“1.00G”。这个“M”Mega和“G”Giga是国际标准前缀对应10⁶和10⁹。而印度数字体系中的“Lac”10⁵和“Crore”10⁷根本不在这个前缀映射表里。你可以尝试tickformatLac结果只会得到字面的“Lac”字符串而不会触发任何数值换算。这就像试图用一把公制尺子去量英尺——单位存在但刻度不匹配。更关键的是Plotly的tickformat是作用于已渲染的数值上的纯文本修饰它无法改变图表内部存储和计算所用的原始数值。这意味着如果你直接把1,00,00,000这个数字喂给Plotly它画出来的点永远在X10000000的位置无论你如何在标签上写“1 Cr.”那个点的物理坐标都没变。这会导致两个严重后果第一悬停时显示的原始值如10000000与标签1 Cr.完全割裂用户会产生困惑第二当你需要添加参考线add_hline或注释add_annotation时你必须用原始数值10000000去定位而不是直观的“1”这极大增加了开发和维护成本。2.2 “手动归一化”的核心价值让数据本身成为文化载体Rahul Shah方案的精妙之处在于它反其道而行之不试图让Plotly“理解”印度单位而是先让数据自己变成印度单位。具体来说就是将原始的卢比数值除以对应的基数10⁵ for Lac, 10⁷ for Crore再将结果四舍五入到小数点后两位最后将这个“归一化后”的数值传给Plotly。例如原始销售额为1,25,00,000 ₹我们执行12500000 / 10**7 1.25然后将1.25这个数字作为Y轴数据点。此时Plotly绘制的点就在Y1.25的位置而我们只需在Y轴标签上加上“Cr.”后缀整个信息链就完美闭环了悬停显示“1.25”轴标签显示“₹1.25 Cr.”参考线也只需加在y1.25即可。这是一种“数据先行”的哲学——让数据结构服务于叙事逻辑而不是让叙事逻辑去迁就数据结构。它带来的好处是根本性的所有Plotly的高级功能如缩放、平移、导出、联动都能无缝工作因为它们操作的始终是那个已经“印度化”的、简洁的数值。这比任何后期的JavaScript hack都要稳定和可靠。2.3 “动态标注”的必要性拒绝一刀切拥抱数据的多样性一个常见的误区是认为只要我的数据最大值超过1 Crore整张图就应该统一用“Cr.”。这是危险的。想象一张展示某品牌全渠道销售的图表X轴是“广告支出”范围从50,000 ₹50K到5,00,00,000 ₹5 Cr.Y轴是“销售额”范围从1,00,000 ₹1 Lakh到2,00,00,000 ₹2 Cr.。如果强行统一用“Cr.”那么X轴上最小的点会显示为“0.005 Cr.”Y轴上最小的点会显示为“0.01 Cr.”这种带三位小数的“0.005 Cr.”不仅失去了“Lac”和“K”的简洁美感更在认知上制造了障碍——一个印度财务人员看到“0.005 Cr.”第一反应绝不是“50,000”而是“这数字怎么这么别扭”。因此“动态标注”的核心思想是为每个坐标轴独立判断其数据范围并为其选择最符合人类直觉的单位。X轴可能用“K”Y轴可能用“Cr.”甚至同一张图的X轴不同区间也可以有不同的主单位虽然原文没实现但这是进阶方向。这要求我们对min()和max()的值进行精细的区间划分。原文中使用的字符串长度判断法len(str(x)) 8是一种非常聪明的取巧方式因为它避开了复杂的数学比较如x 10000000直接利用了数字在十进制表示下的固有特征一个数大于等于1 Crore1,00,00,000其字符串长度必然大于等于8位。这种方法鲁棒性强不易出错且计算开销极小。3. 实操细节解析从原理到代码每一个if-elif-else都值得深究3.1 单位判定逻辑的深度剖析与优化原文的单位判定逻辑是整个方案的基石但其原始写法存在冗余和潜在风险需要我们进行一次彻底的“手术刀式”优化。我们先看原始代码if len(str(min(df[Spends]))) 8 or len(str(max(df[Spends]))) 8: unit Cr. df[Spends] df[Spends].apply(lambda x: round(x/pow(10,7),2)) elif (len(str(min(df[Spends]))) 6 and len(str(min(df[Spends]))) 8) or (len(str(max(df[Spends]))) 6 and len(str(max(df[Spends]))) 8): unit Lacs df[Spends] df[Spends].apply(lambda x: round(x/pow(10,5),2)) # ... 后续类似这段代码的问题在于它对min和max分别进行了两次几乎相同的条件判断逻辑重复且易读性差。更重要的是它没有处理一种边界情况当数据范围横跨两个单位时例如min99999max10000000min落在“Lac”区间6位max落在“Cr.”区间8位此时该用哪个单位原文的逻辑是“取大”即用“Cr.”这在绝大多数情况下是合理的因为图表的可读性主要由最大值决定。但我们可以做得更严谨。优化后的逻辑如下def get_unit_and_divider(series): 根据Pandas Series的数值范围返回最合适的印度单位和对应的除数。 返回元组: (unit_string, divider) min_val, max_val series.min(), series.max() # 计算最大值的字符串长度作为主要判断依据 max_len len(str(int(max_val))) if max_len 8: # 1,00,00,000 (1 Crore) return Cr., 10**7 elif max_len 6: # 1,00,000 (1 Lakh) 且 1 Crore return Lacs, 10**5 elif max_len 4: # 1,000 (1 K) 且 1 Lakh return K, 10**3 else: # 1,000 return , 1 # 使用示例 unit_x, divider_x get_unit_and_divider(df[Spends]) unit_y, divider_y get_unit_and_divider(df[Sales]) df[Spends_normalized] (df[Spends] / divider_x).round(2) df[Sales_normalized] (df[Sales] / divider_y).round(2)这个函数的优势是显而易见的它将核心逻辑封装成一个可复用、可测试的单元它只计算一次max_len避免了重复的len(str())调用它用int()强制转换防止浮点数如1e7导致的字符串长度误判str(1e7)是1e07长度为6而非8。更重要的是它明确表达了设计意图“以最大值为准选择能容纳整个数据范围的最小合适单位”。这比原文的“或”逻辑更符合工程直觉。3.2 悬停模板hovertemplate的陷阱与最佳实践hovertemplate是Plotly中控制鼠标悬停时显示内容的终极武器但它也是最容易出错的地方。原文的写法hovertemplate [bSpends: ₹ str(spends) unitextra/extra for spends in df[Spends]]这里埋藏着一个巨大的性能和逻辑陷阱。首先str(spends)是对原始未归一化的spends值进行字符串化这意味着即使你已经把df[Spends]列归一化成了[1.25, 2.30, ...]这个列表推导式却还在遍历原始的[12500000, 23000000, ...]并将其转为字符串。结果就是悬停时显示的是“Spends: ₹12500000 Cr.”这显然是荒谬的。正确的做法是让hovertemplate与你最终用于绘图的归一化后的数据严格保持一致。其次硬编码bSpends: ₹和extra/extra虽然可行但缺乏灵活性。一个更健壮、更符合Plotly官方推荐的写法是使用f-string结合hovertemplate的内置变量语法fig.add_trace(go.Scatter( xdf[Spends_normalized], ydf[Sales_normalized], modelinesmarkers, nameSales vs Spends, hovertemplate( bSpends/b: ₹%{x:.2f} unit_x br bSales/b: ₹%{y:.2f} unit_y br extra/extra ) ))这里的关键是%{x:.2f}和%{y:.2f}。%{x}是一个占位符Plotly会在渲染时自动将当前数据点的X值即df[Spends_normalized]中的值代入并按.2f格式化为两位小数。这样悬停信息就与图表上的点实现了100%的同步。同时br实现了换行让信息层次更清晰。extra/extra则确保了Plotly默认的轨迹名称trace name不会显示出来保持界面干净。这种写法不仅正确而且高效因为格式化工作完全交给了Plotly的C后端而不是在Python层面进行字符串拼接。3.3 坐标轴格式化的终极控制tickprefix, ticksuffix 与 tickvals/ticktext 的协同仅仅设置tickprefix和ticksuffix往往只能得到一个“看起来差不多”的效果但无法精确控制刻度线的位置和标签。Plotly的坐标轴有两套独立的系统一套是tickvals刻度线的物理位置另一套是ticktext刻度线旁边显示的文本。tickprefix和ticksuffix只是对ticktext的简单前后缀追加。对于追求极致专业感的图表我们必须同时掌控这两者。假设我们的Spends_normalized数据范围是[0.5, 5.0]我们希望X轴的刻度线出现在0, 1, 2, 3, 4, 5这些整数点上并显示为₹0 Cr., ₹1 Cr., ... ₹5 Cr.。我们可以这样做# 定义我们想要的刻度位置归一化后的值 x_tick_vals [i for i in range(0, 6)] # [0, 1, 2, 3, 4, 5] # 定义这些位置上要显示的文本 x_tick_texts [f₹{i}{unit_x} for i in x_tick_vals] # [₹0 Cr., ₹1 Cr., ...] fig.update_xaxes( titleAdvertising Spends, tickvalsx_tick_vals, ticktextx_tick_texts, # 注意这里不再需要 tickprefix 和 ticksuffix因为它们已被包含在 ticktext 中 # 如果还需要额外的样式比如让所有文本加粗可以在这里设置 tickfont tickfontdict(size12, colordarkblue) )这种方法的好处是绝对的精确性和完全的自由度。你可以让刻度线出现在任何你想要的位置比如[0.5, 1.5, 2.5, 3.5, 4.5]并为每个位置指定完全自定义的文本比如[Half Cr., One Half Cr., ...]。它规避了Plotly自动计算刻度autotick时可能出现的“不友好”位置如0.83, 1.67, 2.5, ...确保了图表的专业性和可读性。当然这需要你对数据范围有清晰的把握并手动定义tickvals但对于一份交付给客户的正式报告这点额外的工作是完全值得的。4. 实操过程与完整代码实现从零开始构建一个“印度化”的交互式图表4.1 环境准备与数据生成模拟真实业务场景在开始编码之前我们需要一个能代表真实业务复杂度的数据集。原文使用的随机数据过于理想化sorted()产生近乎线性的关系无法体现实际销售分析中常见的噪声、异常值和多维度特征。让我们构建一个更贴近现实的“印度快消品销售仪表盘”数据集。我们将模拟一个拥有10个SKU、覆盖5个邦State的公司其销售数据受季节性、促销活动和区域经济水平的多重影响。import numpy as np import pandas as pd import plotly.graph_objects as go import plotly.express as px from datetime import datetime, timedelta # 设置随机种子保证结果可复现 np.random.seed(42) # 定义印度主要邦及其经济权重影响销售额基线 states [Maharashtra, Karnataka, Tamil Nadu, Gujarat, Uttar Pradesh] state_weights [1.5, 1.2, 1.3, 1.1, 0.9] # Maharashtra权重最高 # 定义SKU及其基础价格单位₹ skus [Premium Soap, Economy Shampoo, Luxury Lotion, Budget Toothpaste] sku_prices [120, 85, 350, 45] # 生成30天的日期序列 dates pd.date_range(start2023-01-01, end2023-01-30, freqD) # 初始化空列表来存储数据 data [] for date in dates: # 模拟每日总广告支出Spends在10L到50L之间波动 base_spends np.random.uniform(1000000, 5000000) # 加入周末效应周六、日支出增加20% if date.weekday() 5: base_spends * 1.2 # 加入促销日效应每月15号大促支出翻倍 if date.day 15: base_spends * 2.0 for i, state in enumerate(states): # 每个邦的销售额基线 总支出 * 邦权重 * 一个随机因子 state_base_sales base_spends * state_weights[i] * np.random.uniform(0.8, 1.2) for j, sku in enumerate(skus): # SKU销量 基线 * SKU价格 * 一个随机因子体现SKU热度差异 sku_sales state_base_sales * (sku_prices[j] / 100) * np.random.uniform(0.7, 1.5) # 添加一些真实的噪声和异常值 if np.random.random() 0.02: # 2%概率出现异常值如系统错误、数据录入错误 sku_sales * np.random.choice([0.1, 10]) data.append({ Date: date, State: state, SKU: sku, Spends: int(base_spends), Sales: int(sku_sales), Price: sku_prices[j] }) # 创建DataFrame df_raw pd.DataFrame(data) print(原始数据集概览) print(df_raw.head()) print(f\n数据集大小: {df_raw.shape}) print(fSpends范围: ₹{df_raw[Spends].min():,} - ₹{df_raw[Spends].max():,}) print(fSales范围: ₹{df_raw[Sales].min():,} - ₹{df_raw[Sales].max():,})运行这段代码你会得到一个包含10*5*301500行的、充满现实世界复杂性的数据集。Spends的范围大约在₹1,00,00,0001 Crore到₹10,00,00,00010 Crore之间Sales的范围则在₹50,00050K到₹5,00,00,0005 Crore之间。这个数据集完美地覆盖了从“K”到“Cr.”的所有单位区间为我们后续的动态单位判定提供了绝佳的测试场。4.2 核心函数封装与数据预处理构建可复用的“印度化”工具链基于前文的分析我们将把所有核心逻辑封装成几个高内聚、低耦合的函数。这不仅是代码整洁的需要更是为了未来能轻松地将这套逻辑应用到任何新的图表或新的数据源上。def indian_number_system_formatter(series, precision2): 对一个Pandas Series进行印度数字体系格式化。 返回一个字典包含归一化后的Series、单位字符串、除数和格式化后的字符串列表。 min_val, max_val series.min(), series.max() max_len len(str(int(max_val))) if max_len 8: unit Cr. divider 10**7 elif max_len 6: unit Lacs divider 10**5 elif max_len 4: unit K divider 10**3 else: unit divider 1 # 执行归一化 normalized_series (series / divider).round(precision) # 生成格式化后的字符串列表用于hovertemplate等 formatted_strings [] for val in normalized_series: if unit : formatted_strings.append(f{val:.{precision}f}) else: formatted_strings.append(f{val:.{precision}f}{unit}) return { normalized: normalized_series, unit: unit, divider: divider, formatted_strings: formatted_strings } # 对原始数据进行处理 spends_info indian_number_system_formatter(df_raw[Spends]) sales_info indian_number_system_formatter(df_raw[Sales]) # 创建新列 df_processed df_raw.copy() df_processed[Spends_Normalized] spends_info[normalized] df_processed[Sales_Normalized] sales_info[normalized] print(f\nSpends处理结果:) print(f 原始范围: ₹{df_raw[Spends].min():,} - ₹{df_raw[Spends].max():,}) print(f 归一化后: {spends_info[normalized].min():.2f} - {spends_info[normalized].max():.2f}{spends_info[unit]}) print(f 采用单位: {spends_info[unit]} (除数: {spends_info[divider]})) print(f\nSales处理结果:) print(f 原始范围: ₹{df_raw[Sales].min():,} - ₹{df_raw[Sales].max():,}) print(f 归一化后: {sales_info[normalized].min():.2f} - {sales_info[normalized].max():.2f}{sales_info[unit]}) print(f 采用单位: {sales_info[unit]} (除数: {sales_info[divider]}))这段代码的输出会清晰地告诉你对于这个模拟数据集Spends被判定为使用“Cr.”单位除以10⁷而Sales被判定为使用“Lacs”单位除以10⁵。这正是我们期望的——因为Spends的最大值是10 Crore而Sales的最大值是50 Lacs5,000,000。这种自动化的、基于数据本身的决策是专业数据工程的标志。4.3 构建交互式散点图融合单位、悬停与坐标轴的完整实现现在我们拥有了所有必要的“零件”是时候将它们组装成一个完整的、专业的交互式图表了。我们将创建一个散点图X轴为归一化后的广告支出Y轴为归一化后的销售额并按“邦”State进行颜色区分以揭示不同区域的营销效率。# 创建基础散点图 fig go.Figure() # 为每个邦添加一个轨迹Trace for state in states: state_data df_processed[df_processed[State] state] fig.add_trace(go.Scatter( xstate_data[Spends_Normalized], ystate_data[Sales_Normalized], modemarkers, namestate, markerdict( size8, # 根据邦的权重设置颜色深浅权重越高颜色越深 colorstate_weights[states.index(state)], colorscaleBlues, showscaleFalse, linedict(width1, colorDarkSlateGrey) ), # 悬停模板使用归一化后的值和动态单位 hovertemplate( b%{fullData.name}/bbr bDate/b: %{customdata[0]|%Y-%m-%d}br bSKU/b: %{customdata[1]}br bSpends/b: ₹%{x:.2f} spends_info[unit] br bSales/b: ₹%{y:.2f} sales_info[unit] br bROI/b: %{customdata[2]:.2f}%br extra/extra ), # customdata用于在hovertemplate中传递额外信息 customdatanp.stack([ state_data[Date].dt.strftime(%Y-%m-%d), state_data[SKU], (state_data[Sales] / state_data[Spends] * 100).round(2) ], axis-1) )) # 更新布局 fig.update_layout( title{ text: Marketing Efficiency Dashboard: Sales vs. Ad Spend by State, x: 0.5, xanchor: center, font: dict(size18, colordarkblue) }, xaxis_titlefAdvertising Spends ({spends_info[unit]}), yaxis_titlefSales Revenue ({sales_info[unit]}), legend_titleStates, templateplotly_white, # 使用白色背景更专业 width1000, height600 ) # 精确控制坐标轴刻度 # X轴我们希望刻度在0, 2, 4, 6, 8, 10因为Spends最大是10 Cr. x_ticks list(range(0, 11, 2)) x_tick_labels [f₹{i}{spends_info[unit]} for i in x_ticks] # Y轴Sales最大是50 Lacs所以刻度在0, 10, 20, 30, 40, 50 y_ticks list(range(0, 51, 10)) y_tick_labels [f₹{i}{sales_info[unit]} for i in y_ticks] fig.update_xaxes( tickvalsx_ticks, ticktextx_tick_labels, range[-0.5, 10.5], # 留一点边距 gridcolorlightgray, showgridTrue ) fig.update_yaxes( tickvalsy_ticks, ticktexty_tick_labels, range[-2, 52], gridcolorlightgray, showgridTrue ) # 添加一条参考线ROI 100%即 Sales Spends # 注意这里需要将100% ROI 转换为归一化坐标系下的直线 # 在归一化坐标系下ROI100% 意味着 Sales_Normalized / Spends_Normalized (Sales/divider_y) / (Spends/divider_x) 1 # 所以 Sales_Normalized Spends_Normalized * (divider_x / divider_y) roi_slope spends_info[divider] / sales_info[divider] fig.add_shape( typeline, x00, y00, x110, y110 * roi_slope, linedict(colorRed, width2, dashdot), nameBreak-even Line (ROI 100%) ) # 添加图例说明 fig.add_annotation( x0.02, y0.98, xrefpaper, yrefpaper, textfData Range: {df_raw[Date].min().strftime(%Y-%m-%d)} to {df_raw[Date].max().strftime(%Y-%m-%d)}, showarrowFalse, fontdict(size12, colorgray), alignleft ) # 显示图表 fig.show()这段代码构建了一个功能完备、信息丰富的交互式仪表盘。它包含了多色散点图每个邦一种颜色直观对比区域表现。智能悬停不仅显示归一化后的金额还计算并显示实时ROI投资回报率以及日期和SKU信息。精确坐标轴刻度线和标签完全可控无任何“意外”。业务参考线一条虚线清晰地标出了盈亏平衡点ROI100%这是管理层最关心的指标之一。专业布局标题居中、网格线、图例、时间范围标注一切细节都指向一个目标——让这张图能直接放进CEO的月度经营分析报告里。5. 常见问题与排查技巧实录那些只有亲手踩过才知道的坑5.1 问题速查表高频故障与一键修复问题现象根本原因排查步骤修复方案悬停显示的数字与坐标轴标签单位不一致如悬停显示“₹12500000”轴上显示“₹1.25 Cr.”hovertemplate中引用了原始数据列而非归一化后的数据列。1. 检查fig.add_trace()中x和y参数绑定的是否是归一化列。2. 检查hovertemplate中%{x}和%{y}引用的是否是同一个归一化列。将hovertemplate中的%{x}和%{y}确保指向df[Spends_Normalized]和df[Sales_Normalized]。删除所有对df[Spends]和df[Sales]的直接字符串拼接。坐标轴刻度线位置“漂移”或出现大量小数点后很多位的数字tickvals设置不当或未关闭Plotly的自动刻度autotickTrue。1. 检查fig.update_xaxes()中是否显式设置了tickvals。2. 检查是否遗漏了range参数导致Plotly试图在极小范围内塞入大量刻度。显式设置tickvals和range。如果想让Plotly自动计算刻度但又想控制单位则只设置ticksuffix不要设置tickvals并确保tickmodeauto默认。图表中出现“NaN”或“inf”值导致整个图表无法渲染数据中存在0值而在计算ROI等比率时进行了除零操作或原始数据中本身就含有NaN。1. 运行df.isnull().sum()检查缺失值。2. 运行df[df[Spends]0]检查零值。在计算前进行清洗df df[df[Spends] ! 0]对缺失值进行填充或删除df.fillna(0, inplaceTrue)。“Lac”和“Crore”的拼写在不同地区有差异如Lakh/Crore导致客户投诉英文拼写标准化问题。1. 查阅印度央行RBI或主要财经媒体如Economic Times的官方用词。2. 与客户确认其内部文档的惯用拼写。统一采用印度官方英语中最常见的拼写“Lakh”和“Crore”。在代码中将unit Lakh和unit Crore作为标准。5.2 我踩过的三个深坑与独家心得坑一浮点数精度的“幽灵”在一次为客户交付的最终版本中图表一切正常唯独在某个特定的Zoom级别下X轴的最后一个刻度标签显示为“₹10.000000000000001 Crore”。这显然不是我们想要的。根源在于np.arange(0, 11, 2)生成的数组在某些浮点运算下会产生微小的精度误差。我的解决方案不是去“修复”这个浮点数而是从根本上规避它永远用整数列表来定义tickvals。x_ticks [0, 2, 4, 6, 8, 10]而不是np.arange(0, 11, 2)。这是一个简单到令人发笑却又无比有效的经验。它教会我在数据可视化领域确定性Determinism比理论上的“优雅”更重要。坑二hovertemplate中的extra标签失效有一次我精心设计的悬停模板总是顽固地在底部显示一行“Sales vs Spends by State”这是Plotly自动添加的轨迹名称。我反复检查extra/extra确认它存在且闭合但就是不生效。最终发现是因为我在go.Scatter()中设置了nameSales vs Spends by State而这个name会被Plotly默认渲染到悬停框的底部。extra/extra的作用是隐藏这个默认的轨迹名称但它只在hovertemplate中被显式调用时才有效。我的错误在于hover