🔬 Cell Type Legend ↔ UMAP Tooltip 偏移问题 — 根因分析与修复方案

📋 一、问题现象

在 Longevity & Aging Cell Atlas 的 speciesinfo.php 页面中:

用户操作 系统响应 实际结果 ───────────────────────────────────────────────────────────────────────── 点击 Legend "Excitatory neurons" → 高亮 typeId=4 的细胞 → Tooltip 显示 "Pericytes" ✗ 点击 Legend "T cells" → 高亮 typeId=6 的细胞 → Tooltip 显示 "Microglia" ✗ Legend 颜色与 UMAP 着色不对应,名称也不匹配

🗂️ 二、数据架构

┌─ Binary 文件 (.bin) ─────────────────────┐ ┌─ JSON Legend ─────────────────────────────┐ │ 每个细胞 12 bytes: │ │ 114 个 cell type 条目, 按类别分组: │ │ float32 x (UMAP 坐标) │ │ { name: "B cells", │ │ float32 y (UMAP 坐标) │ │ index: 0, count: 2345, │ │ uint16 typeId (细胞类型编号) │ │ color: "#e41a1c", category: "Immune" }│ │ uint16 reserved │ │ { name: "T cells", │ │ │ │ index: 1, count: 1890, ... } │ │ 108 个 unique typeId (0 ~ 107) │ │ ...共 114 条 │ └───────────────────────────────────────────┘ └───────────────────────────────────────────┘ ⚠ 核心矛盾: Binary 有 108 个 ID, JSON 有 114 条 — 两者不完全对齐

🔍 三、代码执行流 — 逐步追踪

Step 1: _extractCellTypes() — 提取 JSON Legend

// 从页面 JSON 解析出所有 cell type
this.cellTypes = [];   // 按 JSON 中出现的顺序存入数组
jsonData.forEach(item => {
    this.cellTypes.push({
        name: item.name,
        index: item.index,    // JSON 中的 index 字段 (可能 ≠ 数组下标)
        count: item.count,
        color: item.color,
        category: item.category
    });
});
// 结果: cellTypes[0].index 可能是 0, 也可能是 5, 取决于 JSON 顺序

Step 2: _detectBestOffset() — 自动检测偏移量

// 对比每个 cell type 的 count 来猜测 offset
for (let offset = -20; offset <= 20; offset++) {
    let score = 0;
    this.cellTypes.forEach(ct => {
        let mappedId = ct.index + offset;
        let binCount = binaryCounts[mappedId];  // binary 中该 ID 的细胞数
        if (差异 < 10%) score += 10;
        else score += 1;
    });
}
// 结果: offset=0, score=1140 (看似完美)
// 但这只证明 ct.index 与 binary typeId 的 COUNT 匹配
// 并不能证明 palette 颜色分配是一致的!

Step 3: _applyMapping() — 建立映射

this._idxToName = {};
this.cellTypes.forEach((ct, arrayIndex) => {
    ct.mappedId = ct.index + 0;  // offset=0
    this._idxToName[ct.mappedId] = ct.name;
});
// _idxToName[4] → "Excitatory neurons"  ✓ (name 映射是对的)
// 但 arrayIndex 和 mappedId 的关系被忽略了!

Step 4: Legend 渲染 BUG 所在

this.cellTypes.forEach((ct, arrayIndex) => {
    // 色块颜色 = palette[arrayIndex]
    ctx.fillStyle = this.palette[arrayIndex];  // 用数组下标取色!
    ctx.fillRect(...);
    // 文字
    ctx.fillText(ct.name, ...);
});

// 所以 Legend 中:
// arrayIndex=0 → "B cells"           → palette[0] 🔴
// arrayIndex=1 → "T cells"           → palette[1] 🔵
// arrayIndex=2 → "Excitatory neurons" → palette[2] 🟢
// arrayIndex=4 → "Pericytes"         → palette[4] 🟠

Step 5: UMAP 渲染 BUG 所在

cells.forEach(cell => {
    let typeId = cell.typeId;  // 从 binary 读取, 例如 4
    ctx.fillStyle = this.palette[typeId];  // 用 typeId 取色!
    ctx.fillRect(cell.x, cell.y, ...);
});

// 所以 UMAP 中:
// typeId=4 的细胞 → palette[4] 🟠
// 但 typeId=4 实际是 "Excitatory neurons"
// Legend 里 "Excitatory neurons" 却是 palette[2] 🟢

Step 6: Tooltip 显示

onHover(cell) {
    let name = this._idxToName[cell.typeId];  // 这个是对的: typeId=4 → "Excitatory neurons"
    tooltip.show(name);
}
// 但用户看到的颜色是 palette[typeId]=🟠
// 对应 Legend 中 arrayIndex=4 → "Pericytes" 🟠
// 所以用户 觉得 hover 的是 Pericytes 的颜色区域

🎯 四、根因确认

Legend 取色逻辑

cellTypes[arrayIndex] → palette[arrayIndex] arrayIndex=0 → "B cells" → ■ arrayIndex=1 → "T cells" → ■ arrayIndex=2 → "Excitatory neurons"→ ■ arrayIndex=3 → "Astrocytes" → ■ arrayIndex=4 → "Pericytes" → ■
≠

UMAP 取色逻辑

cell.typeId → palette[typeId] typeId=0 → palette[0] → ■ (但此 ID 是谁?) typeId=1 → palette[1] → ■ typeId=4 → palette[4] → ■ ← "Excitatory neurons" 但 Legend 中 🟠 是 "Pericytes"!

★ 根因: arrayIndex ≠ mappedId(typeId), 但两处都用作 palette 索引

cellTypes 数组的排列顺序(由 JSON 提取顺序决定)与 binary 中的 typeId 编号不是同一套序号。

Legend 用 arrayIndex 索引 palette → 得到颜色 A
UMAP 用 typeId 索引 palette → 得到颜色 B
同一个 cell type,Legend 和 UMAP 显示不同颜色 → 视觉上就是"偏移"。

层面LegendUMAP是否一致
颜色来源palette[arrayIndex]palette[typeId]❌ 不一致
名称来源cellTypes[arrayIndex].name_idxToName[typeId]✅ 各自正确
点击选中找 mappedId == typeId高亮 typeId 对应的细胞✅ ID 正确
视觉效果色块颜色 A细胞颜色 B❌ 用户看到不匹配

✅ 五、修复方案

核心思路:统一使用 mappedId 作为 palette 索引,确保 Legend 和 UMAP 取到相同颜色。

方案 A:修改 Legend 渲染(推荐,改动最小)

让 Legend 色块使用 palette[ct.mappedId] 而非 palette[arrayIndex]

// ============================================
// 修复: Legend 渲染部分
// 搜索 Legend 渲染的 forEach 循环
// ============================================

// ❌ 修改前 (Bug):
this.cellTypes.forEach((ct, i) => {
    ctx.fillStyle = this.palette[i];           // 用 arrayIndex
    // ... 绘制色块和文字
});

// ✅ 修改后 (Fix):
this.cellTypes.forEach((ct, i) => {
    ctx.fillStyle = this.palette[ct.mappedId];  // 用 mappedId, 与 UMAP 一致
    // ... 绘制色块和文字
});
方案 B:修改 UMAP 渲染(备选)

让 UMAP 细胞着色使用 palette[arrayIndex],需要建立 typeId → arrayIndex 反查表

// ============================================
// 修复: 在 _applyMapping() 中新增反查表
// ============================================

// ✅ 新增:
this._typeIdToArrayIndex = {};
this.cellTypes.forEach((ct, arrayIndex) => {
    ct.mappedId = ct.index + this._offset;
    this._idxToName[ct.mappedId] = ct.name;
    this._typeIdToArrayIndex[ct.mappedId] = arrayIndex;  // 新增反查
});

// ============================================
// 修复: UMAP 渲染部分
// ============================================

// ❌ 修改前 (Bug):
ctx.fillStyle = this.palette[cell.typeId];

// ✅ 修改后 (Fix):
let aidx = this._typeIdToArrayIndex[cell.typeId];
ctx.fillStyle = this.palette[aidx !== undefined ? aidx : cell.typeId];
方案 C:重排 cellTypes 数组(最彻底)

在 _applyMapping() 后,按 mappedId 重排数组,使 arrayIndex === mappedId

// ============================================
// 修复: 在 _applyMapping() 末尾添加
// ============================================

// ✅ 按 mappedId 重排,使 arrayIndex === mappedId
let reordered = new Array(this.cellTypes.length);
this.cellTypes.forEach(ct => {
    if (ct.mappedId >= 0 && ct.mappedId < reordered.length) {
        reordered[ct.mappedId] = ct;
    }
});
// 填补空位(mappedId 超出范围或有空洞时)
let spare = this.cellTypes.filter(ct =>
    ct.mappedId < 0 || ct.mappedId >= reordered.length || reordered[ct.mappedId] !== ct
);
let si = 0;
for (let i = 0; i < reordered.length; i++) {
    if (!reordered[i] && si < spare.length) {
        reordered[i] = spare[si++];
    }
}
this.cellTypes = reordered.filter(Boolean);

// 这样之后 palette[arrayIndex] === palette[mappedId],全部代码都不需要改

🔧 六、如何在 speciesinfo.php 中定位需修改的代码

在 speciesinfo.php 中搜索以下关键词来定位具体代码行:

修改目标搜索关键词修改内容
Legend 色块取色 palette[i] 或 palette[idx]
在 legend / celltype 渲染附近
改为 palette[ct.mappedId]
UMAP 细胞取色 palette[typeId] 或 palette[cell.type]
在 canvas drawRect / fillRect 附近
改为 palette[_typeIdToArrayIndex[typeId]]
点击 Legend 选中 selectedType 或 clickedType
在 click handler 中
确保使用 ct.mappedId 而非 arrayIndex
Tooltip 显示 _idxToName 或 tooltip 此处通常是正确的,不需要改
💡 最快的修复方式:在 speciesinfo.php 中全局搜索 palette[, 检查每个出现位置的索引是用的 arrayIndex/i 还是 typeId/mappedId, 确保所有位置统一使用同一种索引即可。

✔️ 七、修复后验证方法

1
在 Legend 中点击任意 cell type(如 "Excitatory neurons")
2
UMAP 应高亮一组细胞,且这些细胞的颜色应与 Legend 色块完全一致
3
Hover 被高亮的细胞,Tooltip 应显示与 Legend 点击的相同名称
4
多测几个不同的 cell type,确保所有类型都一致
修复后预期效果: Legend 点击 UMAP 高亮 Tooltip 显示 "Excitatory neurons" 🟢 → cells colored 🟢 → "Excitatory neurons" ✓ "T cells" 🔵 → cells colored 🔵 → "T cells" ✓ "Pericytes" 🟠 → cells colored 🟠 → "Pericytes" ✓

📦 八、可直接使用的 JavaScript Patch

如果不想修改原始代码,可以在 speciesinfo.php 的 </body> 前插入以下 <script> 标签:

<script>
// =============================================
// Cell Type Legend ↔ UMAP 颜色修复补丁
// 放在页面底部, 在 GeneViewer 初始化之后执行
// =============================================
(function patchCellTypeMapping() {
    // 等待 viewer 初始化完成
    var checkInterval = setInterval(function() {
        // 尝试找到 viewer 实例 (根据实际变量名调整)
        var viewer = window.geneViewer || window.viewer || window.gv;
        if (!viewer || !viewer.cellTypes || viewer.cellTypes.length === 0) return;
        clearInterval(checkInterval);

        console.log('[Patch] 开始修复 cellType 颜色映射...');

        // 方法: 按 mappedId 重新排列 cellTypes 数组
        // 使得 arrayIndex === mappedId, 从而 palette[arrayIndex] === palette[typeId]
        var maxId = 0;
        viewer.cellTypes.forEach(function(ct) {
            var mid = (ct.mappedId !== undefined) ? ct.mappedId : ct.index;
            if (mid > maxId) maxId = mid;
        });

        var reordered = new Array(maxId + 1);
        var unplaced = [];

        viewer.cellTypes.forEach(function(ct) {
            var mid = (ct.mappedId !== undefined) ? ct.mappedId : ct.index;
            if (mid >= 0 && mid <= maxId && !reordered[mid]) {
                reordered[mid] = ct;
            } else {
                unplaced.push(ct);
            }
        });

        // 填补空位
        var ui = 0;
        for (var i = 0; i <= maxId; i++) {
            if (!reordered[i] && ui < unplaced.length) {
                reordered[i] = unplaced[ui++];
            }
        }

        viewer.cellTypes = reordered.filter(Boolean);

        // 验证
        var fixed = 0, total = viewer.cellTypes.length;
        viewer.cellTypes.forEach(function(ct, i) {
            var mid = (ct.mappedId !== undefined) ? ct.mappedId : ct.index;
            if (i === mid) fixed++;
        });

        console.log('[Patch] 修复完成: ' + fixed + '/' + total + ' 个 cellType 的 arrayIndex === mappedId');

        // 触发重绘 (根据实际方法名调整)
        if (viewer.renderLegend) viewer.renderLegend();
        if (viewer.render) viewer.render();
        if (viewer.draw) viewer.draw();
        if (viewer.refresh) viewer.refresh();

    }, 500);
})();
</script>
⚠️ 注意:上面代码中的 window.geneViewer 需要替换为你实际的 viewer 实例变量名。 在 Console 中输入 Object.keys(window).filter(k => window[k] && window[k].cellTypes) 可以找到它。

📝 总结

项目内容
根因Legend 使用 palette[arrayIndex] 取色,UMAP 使用 palette[typeId] 取色。
由于 cellTypes 数组的排列顺序 ≠ typeId 编号顺序,同一个 cell type 在两处显示不同颜色。
为什么 offset=0 没用offset 修正的是 ct.index → typeId 的 数值映射(_idxToName 是对的),
但没有修正 palette 颜色索引 不一致的问题。
最简修复方案 A: Legend 渲染改用 palette[ct.mappedId]
或方案 C: 按 mappedId 重排 cellTypes 数组
改动量方案 A: 改 1 行  |  方案 B: 改 2 处 + 加 3 行  |  方案 C: 加 15 行