heatmap-cluster
Version:
The Unipept visualisation library
339 lines (285 loc) • 14.2 kB
text/typescript
import HeatmapValue from "./HeatmapValue";
/**
* 将颜色名称或HEX转换为标准的6位HEX格式
* @param color 颜色名称或HEX值
* @returns 6位HEX颜色代码
*/
function colorToHex(color: string): string {
// 如果是HEX格式直接返回
if (/^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color)) {
if (color.length === 4) { // 缩写格式如 #fff
return `#${color[1]}${color[1]}${color[2]}${color[2]}${color[3]}${color[3]}`;
}
return color;
}
// 创建临时div元素来获取颜色值 (仅在浏览器环境中有效)
if (typeof document === 'undefined') {
// 在非浏览器环境 (如 Node.js) 可能需要其他方式或预定义的颜色映射
// 这里简化处理,如果不在浏览器环境且不是HEX,则抛错
throw new Error(`无法在当前环境解析颜色名称: ${color}`);
}
const tempDiv = document.createElement('div');
tempDiv.style.color = color;
document.body.appendChild(tempDiv);
// 获取计算后的颜色值
const computedColor = window.getComputedStyle(tempDiv).color;
document.body.removeChild(tempDiv);
// 解析rgb/rgba值
const rgbMatch = computedColor.match(/^rgb(a?)\((\d+),\s*(\d+),\s*(\d+)(?:,\s*\d+\.?\d*)?\)$/i);
if (rgbMatch) {
const r = parseInt(rgbMatch[2]).toString(16).padStart(2, '0');
const g = parseInt(rgbMatch[3]).toString(16).padStart(2, '0');
const b = parseInt(rgbMatch[4]).toString(16).padStart(2, '0');
return `#${r}${g}${b}`.toLowerCase();
}
throw new Error(`无法解析颜色: ${color}`);
}
/**
* 在两个HEX颜色之间插值
* @param startHex 起始颜色
* @param endHex 结束颜色
* @param factor 插值因子 (0-1)
* @returns 插值后的HEX颜色
*/
function interpolateColor(startHex: string, endHex: string, factor: number): string {
// 确保 factor 在 0 到 1 之间
factor = Math.max(0, Math.min(1, factor));
// 去除#号并解析RGB分量
const start = startHex.substring(1).match(/.{2}/g)!.map(c => parseInt(c, 16));
const end = endHex.substring(1).match(/.{2}/g)!.map(c => parseInt(c, 16));
// 计算插值
const r = Math.round(start[0] + factor * (end[0] - start[0]));
const g = Math.round(start[1] + factor * (end[1] - start[1]));
const b = Math.round(start[2] + factor * (end[2] - start[2]));
// 返回HEX格式
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
}
/**
* Creates a color scale function that maps numeric values to colors.
* Supports linear interpolation between multiple color stops and quantization.
* @param minValue 数值范围最小值
* @param maxValue 数值范围最大值
* @param colors 颜色数组,表示渐变的颜色停靠点 (HEX 或颜色名称). At least two colors for a gradient.
* @param numColors 期望的离散颜色数量 ( quantize 标度的值域大小). If > 1, creates a quantized scale. If 1, returns a single color.
* @returns 一个函数,接收一个数值并返回对应的颜色字符串
*/
function createQuantizeColorScale(
minValue: number,
maxValue: number,
colors: string[], // 颜色数组
numColors: number // 期望的离散颜色数量
): (value: number) => string {
// 确保颜色数组至少有两个颜色才能形成渐变,否则使用第一个颜色
if (colors.length < 2) {
const singleColorHex = colors.length > 0 ? colorToHex(colors[0]) : '#000000'; // 默认黑色
// 如果颜色少于2个,或者 numColors 为 1,都只返回一个颜色
return (value: number): string => singleColorHex;
}
// 确保 numColors 是正数
if (numColors <= 0) {
numColors = 1;
}
// 将所有提供的颜色转换为HEX格式
const hexColors = colors.map(colorToHex);
// Create a continuous interpolator based on the provided color stops
// This interpolator maps a factor [0, 1] to a color along the multi-stop gradient.
const multiStopInterpolator = (factor: number): string => {
// Ensure factor is within [0, 1]
factor = Math.max(0, Math.min(1, factor));
// Find which color segment the factor falls into
const segment = factor * (hexColors.length - 1);
const segmentIndex = Math.floor(segment);
const segmentFactor = segment - segmentIndex; // Position within the segment [0, 1]
// Handle the case where factor is exactly 1
if (factor === 1) {
return hexColors[hexColors.length - 1];
}
// Get the start and end colors for the segment
const startColorIndex = Math.max(0, Math.min(hexColors.length - 2, segmentIndex));
const endColorIndex = startColorIndex + 1;
const startColor = hexColors[startColorIndex];
const endColor = hexColors[endColorIndex];
return interpolateColor(startColor, endColor, segmentFactor);
};
// If numColors is 1, return a scale that always returns the middle color of the multi-stop gradient
if (numColors === 1) {
const middleColor = multiStopInterpolator(0.5);
return (value: number): string => middleColor;
}
// Generate numColors discrete colors from the continuous interpolator
const colorRange: string[] = [];
for (let i = 0; i < numColors; i++) {
const factor = i / (numColors - 1);
colorRange.push(multiStopInterpolator(factor));
}
console.log("Generated colorRange:", colorRange);
// Return a quantize-like color scale function
return (value: number): string => {
// Handle boundary cases
if (value <= minValue) return colorRange[0];
if (value >= maxValue) return colorRange[colorRange.length - 1];
// If min and max are the same, return the first color in the quantized range
if (minValue === maxValue) {
return colorRange[0];
}
// Calculate value's position in the domain [minValue, maxValue]
const proportion = (value - minValue) / (maxValue - minValue);
// Determine which of the numColors bins the value falls into
// Map proportion from [0, 1] to [0, numColors - epsilon]
const binIndex = Math.floor(proportion * numColors);
// Ensure index is within bounds [0, numColors - 1]
const colorIndex = Math.max(0, Math.min(numColors - 1, binIndex));
console.log(`Value: ${value}, Proportion: ${proportion.toFixed(2)}, Bin Index: ${binIndex}, Color Index: ${colorIndex}, Color: ${colorRange[colorIndex]}`);
return colorRange[colorIndex];
};
}
/**
* Preprocesses input data to extract numeric values, determine color scale,
* and map each numeric value to a color based on the scale.
* @param data Input data array (can contain numbers or HeatmapValue objects).
* @param colors An array of colors defining the gradient stops. At least two colors are recommended.
* @param colorValues The number of discrete colors in the gradient.
* @param valueRange Optional: A tuple [minValue, maxValue] to manually specify the value range for color mapping. If not provided, the range is calculated from the data.
* @returns A 2D array of HeatmapValue objects with colors assigned.
*/
export function preprocessValues(
data: (number | HeatmapValue)[][],
colors: string[], // 颜色数组
colorValues: number, // 期望的离散颜色数量
valueRange: number[] = [] // 手动指定数值范围, 默认为空数组
): HeatmapValue[][] {
// 确保 colorValues 是正数
if (colorValues <= 0) {
colorValues = 1; // 至少为1
}
const allNumericValues: number[] = [];
data.forEach(row => {
row.forEach(cell => {
if (typeof cell === "number") {
allNumericValues.push(cell);
} else if (cell && typeof cell.value === 'number') { // cell 是一个包含 value 属性的 HeatmapValue 对象
allNumericValues.push(cell.value);
}
});
});
let minValue: number;
let maxValue: number;
// 根据 valueRange 参数确定数值范围
if (valueRange && valueRange.length === 2 && typeof valueRange[0] === 'number' && typeof valueRange[1] === 'number') {
minValue = valueRange[0];
maxValue = valueRange[1];
console.log("Using manual valueRange:", [minValue, maxValue]);
} else {
// 如果没有手动指定范围,则从数据中计算
if (allNumericValues.length === 0) {
// 如果没有数值数据且没有手动指定范围,则默认范围为 [0, 0]
minValue = 0;
maxValue = 0;
} else {
minValue = Math.min(...allNumericValues);
maxValue = Math.max(...allNumericValues);
}
console.log("Using calculated valueRange:", [minValue, maxValue]);
}
// 如果没有数值数据,或者手动指定范围后数据都在范围外,则对任何数字应用默认颜色
// 默认颜色使用颜色数组的第一个颜色 (如果颜色数组不为空)
if (allNumericValues.length === 0 && !(valueRange && valueRange.length === 2)) {
const defaultColor = colors.length > 0 ? colorToHex(colors[0]) : '#000000'; // 默认黑色
console.log("No numeric data and no manual range, using default color:", defaultColor);
return data.map((row, rowIdx) => row.map((cell, colIdx) => {
if (typeof cell === "number") {
return {
value: cell,
rowId: rowIdx,
columnId: colIdx,
color: defaultColor
};
}
// 如果 cell 不是数字,保持原样 (假设已经是 HeatmapValue 或其他类型)
return cell as HeatmapValue;
}));
}
// 创建颜色比例尺函数
const colorScale = createQuantizeColorScale(
minValue,
maxValue,
colors, // 使用颜色数组
colorValues
);
// 映射数据到 HeatmapValue 对象
return data.map((row, rowIdx) => {
return row.map((cell, colIdx) => {
if (typeof cell === "number") {
return {
value: cell,
rowId: rowIdx,
columnId: colIdx,
color: colorScale(cell) // 使用新的颜色比例尺
};
} else if (cell && typeof cell.value === 'number') {
// 如果已经是 HeatmapValue 对象并且有数值 value,则重新计算其颜色
// 同时保留可能已存在的 rowId 和 columnId
return {
...cell,
rowId: cell.rowId !== undefined ? cell.rowId : rowIdx,
columnId: cell.columnId !== undefined ? cell.columnId : colIdx,
color: colorScale(cell.value) // 使用新的颜色比例尺
};
}
// 如果 cell 不是数字,也不是包含数字 value 的 HeatmapValue 对象,则原样返回
return cell as HeatmapValue;
});
});
}
// // 示例用法 (您可以使用这些示例来测试)
// // 示例 1: 使用颜色数组和自动计算范围
// const sampleData1 = [
// [10, 20, 30],
// [40, 50, 60],
// [70, 80, 90]
// ];
// const colors1 = ['#ffffcc', '#a1dab4', '#41b6c4', '#2c7fb8', '#253494']; // 5个颜色
// const colorValues1 = 5; // 期望 5 个离散颜色
// console.log("\n--- 示例 1 (自动范围) ---");
// const processedData1 = preprocessValues(sampleData1, colors1, colorValues1);
// // console.log("示例 1 结果:", processedData1);
// // 示例 2: 使用颜色数组和手动指定范围 (您的场景)
// const sampleData2 = [
// [ 100, 90 ], // 简化您的数据结构,只包含数值
// [ 1000, 0 ]
// ]; // 包含 1000 和 90
// const colors2 = ['blue', 'red', 'yellow']; // 3个颜色
// const colorValues2 = 3; // 期望 3 个离散颜色
// const manualValueRange: [number, number] = [0, 100]; // 手动指定范围 0 到 100
// console.log("\n--- 示例 2 (手动范围 - 您的场景) ---");
// const processedData2 = preprocessValues(sampleData2, colors2, colorValues2, manualValueRange);
// // console.log("示例 2 结果:", processedData2);
// // 示例 3: 只有一种颜色
// const sampleData3 = [
// [1, 2], [3, 4]
// ];
// const colors3 = ['red'];
// const colorValues3 = 3; // 期望 3 个离散颜色,但只提供了一种颜色
// console.log("\n--- 示例 3 (只有一种颜色) ---");
// const processedData3 = preprocessValues(sampleData3, colors3, colorValues3);
// // console.log("示例 3 结果:", processedData3);
// // 示例 4: 没有数值数据,有手动范围
// const sampleData4: (number | HeatmapValue)[][] = [
// [ { label: 'A' }, { label: 'B' } ]
// ];
// const colors4 = ['green', 'purple'];
// const colorValues4 = 2;
// const manualValueRange4: [number, number] = [0, 100];
// console.log("\n--- 示例 4 (没有数值数据,有手动范围) ---");
// const processedData4 = preprocessValues(sampleData4, colors4, colorValues4, manualValueRange4);
// // console.log("示例 4 结果:", processedData4);
// // 示例 5: 没有数值数据,没有手动范围
// const sampleData5: (number | HeatmapValue)[][] = [
// [ { label: 'C' }, { label: 'D' } ]
// ];
// const colors5 = ['orange', 'brown'];
// const colorValues5 = 2;
// // valueRange 默认为空数组
// console.log("\n--- 示例 5 (没有数值数据,没有手动范围) ---");
// const processedData5 = preprocessValues(sampleData5, colors5, colorValues5);
// // console.log("示例 5 结果:", processedData5);