提交 9aa80bba authored 作者: 祖铭松's avatar 祖铭松

feat: 热图

上级 037b9a5a
import distance from 'distancejs'
import extend from 'extend'
// hcluster
var hcluster = function () {
var data,
clusters,
clustersGivenK,
treeRoot,
posKey = 'position',
distanceName = 'angular',
distanceFn = distance.angular,
linkage = 'avg',
verbose = false;
//
// simple constructor
function clust() { }
//
// getters, setters a la D3
// return data or set data and build tree
clust.data = function (value) {
if (!arguments.length) return data;
// dataset will be mutated
data = value;
clust._buildTree();
return clust;
};
clust.posKey = function (value) {
if (!arguments.length) return posKey;
posKey = value;
return clust;
};
clust.linkage = function (value) {
if (!arguments.length) return linkage;
linkage = value;
return clust;
};
clust.verbose = function (value) {
if (!arguments.length) return verbose;
verbose = value;
return clust;
};
clust.distance = function (value) {
if (!arguments.length) return distanceName;
distanceName = value;
distanceFn = {
angular: distance.angular,
euclidean: distance.euclidean
}[value] || distance.angular;
return clust;
}
//
// get tree properties
clust.orderedNodes = function () {
if (!treeRoot) throw new Error('Need to passin data and build tree first.');
return treeRoot.indexes.map(function (ndx) {
return data[ndx];
});
};
clust.tree = function () {
if (!treeRoot) throw new Error('Need to passin data and build tree first.');
return treeRoot;
};
clust.getClusters = function (n) {
if (!treeRoot) throw new Error('Need to passin data and build tree first.');
if (n > data.length) throw new Error('n must be less than the size of the dataset');
return clustersGivenK[data.length - n]
.map(function (indexes) {
return indexes.map(function (ndx) { return data[ndx]; });
});
};
//
// math, matrix utility fn's
// return unique pairs of indexes on n x n matrix above the diagonal
clust._squareMatrixPairs = function (n) {
var pairs = [];
for (var row = 0; row < n; row++) {
for (var col = row + 1; col < n; col++) {
pairs.push([row, col]);
}
}
return pairs;
};
// average distance between set of cluster indexes
clust._avgDistance = function (setA, setB) {
var distance = 0;
for (var ndxA = 0; ndxA < setA.length; ndxA++) {
for (var ndxB = 0; ndxB < setB.length; ndxB++) {
distance += data[setA[ndxA]]._distances[setB[ndxB]];
}
}
return distance / setA.length / setB.length;
};
// min distance between set of cluster indexes
clust._minDistance = function (setA, setB) {
var distances = [];
for (var ndxA = 0; ndxA < setA.length; ndxA++) {
for (var ndxB = 0; ndxB < setB.length; ndxB++) {
distances.push(data[setA[ndxA]]._distances[setB[ndxB]]);
}
}
return distances.sort()[0];
};
// max distance between set of cluster indexes
clust._maxDistance = function (setA, setB) {
var distances = [];
for (var ndxA = 0; ndxA < setA.length; ndxA++) {
for (var ndxB = 0; ndxB < setB.length; ndxB++) {
distances.push(data[setA[ndxA]]._distances[setB[ndxB]]);
}
}
return distances.sort()[distances.length - 1];
};
//
// tree construction
//
clust._buildTree = function () {
if (!data || !data.length) throw new Error('Need `data` to build tree');
//
var node, clusterPairs, nearestPair, newCluster;
clusters = [];
clustersGivenK = [];
treeRoot = {};
// calculate distances and build single datum clusters
data.forEach(function (d, ndx) {
d._distances = data.map(function (compareTo) {
return distanceFn(d[posKey], compareTo[posKey]);
});
clusters.push(extend(d, {
height: 0,
indexes: [ndx]
}));
});
// for tree of n leafs, n-1 linkages
for (var iter = 0; iter < data.length - 1; iter++) {
verbose && console.log(iter + ': ' +
clusters.map(function (c) { return c.indexes; }).join('|'));
// find closest pair of clusters, pair[2] is distance
clusterPairs = clust._squareMatrixPairs(clusters.length);
clusterPairs.forEach(function (pair) {
pair[2] = clust['_' + linkage + 'Distance'](
clusters[pair[0]].indexes,
clusters[pair[1]].indexes);
});
nearestPair = clusterPairs
.reduce(function (pairA, pairB) { return pairA[2] <= pairB[2] ? pairA : pairB; },
[0, 0, Infinity]);
newCluster = {
name: 'Node ' + iter,
height: nearestPair[2],
indexes: clusters[nearestPair[0]].indexes.concat(clusters[nearestPair[1]].indexes),
children: [clusters[nearestPair[0]], clusters[nearestPair[1]]],
};
verbose && console.log(newCluster);
clustersGivenK.push(clusters.map(function (c) { return c.indexes; }));
// remove merged nodes and push new node
clusters.splice(Math.max(nearestPair[0], nearestPair[1]), 1);
clusters.splice(Math.min(nearestPair[0], nearestPair[1]), 1);
clusters.push(newCluster);
}
treeRoot = clusters[0];
// clust._rebalanceTree(treeRoot);
};
// TODO: better rebalancing algo? ... this is just for presentation
// rebalance after tree is built (b/c it is top down operation)
// clust._rebalanceTree = function(node) {
// if(node.parent && node.parent.children && node.parent.children.length &&
// node.children && node.children.length) {
// var rightDistance = clust['_'+linkage+'Distance'](
// node.parent.children[1].indexes,
// node.children[0].indexes);
// var leftDistance = clust['_'+linkage+'Distance'](
// node.parent.children[1].indexes,
// node.children[1].indexes);
// // switch order of node.children
// if(leftDistance > rightDistance) {
// node.children = [ node.children[1], node.children[0] ];
// node.indexes = node.children[0].indexes.concat(node.children[1].indexes);
// }
// }
// if(node.children) {
// clust._rebalanceTree(node.children[0]);
// clust._rebalanceTree(node.children[1]);
// }
// };
return clust;
};
// module.exports = hcluster;
export default hcluster
<template>
<div ref="chartRef"></div>
<div ref="chartRef" class="chart-box">
<div class="tip"></div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { data, verticalData, normalizedData, rootData, vertRootData } from './mock.js';
import { chartMackData } from './mock.js';
import * as d3 from 'https://cdn.jsdelivr.net/npm/d3@7/+esm';
import hclust from 'hclusterjs';
import hcluster from './hcluster';
const chartRef = ref(null);
// console.log(data)
// 配置项数据
const options = ref({});
// 生成图表
const generateChart = () => {
// console.log(hclust);
const generateChart = (chartData) => {
const ret = options.value;
// return;
let width = 700;
let height = 800;
let margin = 10;
let radius = 3;
let dendWidth = 150;
let heatWidth = 462;
let heatHeight = 600;
let n = 100;
let fullWidth = 632;
let fullHeight = 770;
let nonMeasurements = ['MouseID', 'Genotype', 'Treatment', 'Behavior', 'class'];
const titleConfig = {
label: !!ret.title ? ret.title : null,
color: ret.title_font_color || '#333333',
fontSize: ret.title_font_size || 20,
fontStyle: ret.title_font_style ? 'oblique' : 'normal',
fontFamily: ret.title_font_family || 'Arial',
fontWeight: ret.title_font_weight ? 'bold' : 'normal'
};
let maxNumArr = [];
for (let i = 0; i < chartData.length; i++) {
maxNumArr.push(d3.max(chartData[i].values));
}
// 最大值
const maxNumber = Math.round(d3.max(maxNumArr));
const color_ls = setColorConfig();
const color = (max, range_count, d) => {
return d3.scaleQuantize().domain([0, max]).range(color_ls);
};
const normalizedData = () => {
let minmaxes = d3
.range(chartData[0].values.length)
.map((d) => [d3.min(chartData.map((el) => el.values[d])), d3.max(chartData.map((el) => el.values[d]))]);
return chartData.map((el) => ({
...el,
normalized: el.values.map((n, i) => (n - minmaxes[i][0]) / (minmaxes[i][1] - minmaxes[i][0]))
}));
};
const verticalData = () => {
return d3.range(chartData[0].values.length).map((d) => ({ normalized: normalizedData().map((el) => el.normalized[d]) }));
};
const clustering = hcluster().distance('angular').linkage('avg').posKey('normalized').data(normalizedData());
const verticalClustering = hcluster().distance('angular').linkage('avg').posKey('normalized').data(verticalData());
const tree = (data) => {
const root = d3.hierarchy(data).sort((a, b) => d3.ascending(a.height, b.height));
root.dx = radius;
......@@ -33,18 +76,6 @@ const generateChart = () => {
.size([heatHeight, dendWidth])
.separation(() => 2)(root);
};
// const clustering = hclust()
// .distance('angular') // support for 'euclidean' and 'angular'
// .linkage('avg') // support for 'avg', 'max' and 'min'
// .posKey('normalized') // 'position' by default
// .data(normalizedData);
// const verticalClustering = hclust()
// .distance('angular') // support for 'euclidean' and 'angular'
// .linkage('avg') // support for 'avg', 'max' and 'min'
// .posKey('normalized') // 'position' by default
// .data(verticalData);
const vertTree = (data) => {
const root = d3.hierarchy(data).sort((a, b) => d3.ascending(a.height, b.height));
root.dy = radius;
......@@ -55,20 +86,20 @@ const generateChart = () => {
.separation(() => 2)(root);
};
// const root = tree(clustering.tree());
// const vertRoot = vertTree(verticalClustering.tree());
const root = tree(rootData);
const vertRoot = tree(vertRootData);
const root = tree(clustering.tree());
const vertRoot = vertTree(verticalClustering.tree());
const svg = d3
.create('svg')
.attr('width', 800)
.attr('height', 800)
.attr('viewBox', [0, 0, fullWidth, fullHeight]);
.attr('width', width)
.attr('height', height + 50)
.attr('viewBox', [0, 0, width, height])
.attr('style', 'max-width: 100%; height: auto; font: 10px sans-serif;');
// left tree
svg.append('g')
.attr('fill', 'none')
.attr('stroke', '#555')
.attr('stroke', '#000')
.attr('stroke-width', 1)
.attr('transform', `translate(${margin}, ${margin + dendWidth})`)
.selectAll('path')
......@@ -84,6 +115,7 @@ const generateChart = () => {
`
);
// top tree
svg.append('g')
.attr('fill', 'none')
.attr('stroke', '#555')
......@@ -103,26 +135,148 @@ const generateChart = () => {
);
let orderingx = vertRoot.leaves().map((el) => el.data.indexes[0]);
svg.append('g')
let fcolors = color(maxNumber, orderingx.length);
const rects = svg
.append('g')
.attr('transform', `translate(${margin + dendWidth}, ${margin + dendWidth})`)
.selectAll('g')
.data(root.leaves())
.join('g')
.attr('transform', (d) => `translate(0, ${d.x - radius})`)
.attr('transform', (d, i) => `translate(0, ${d.x - radius})`)
.selectAll('rect')
.data((d) => d.data.normalized.map((e, i) => d.data.normalized[orderingx[i]]))
.data((d) => d.data.values.map((e, i) => d.data.values[orderingx[i]]))
.join('rect')
.attr('x', (d, i) => i * 2 * radius)
.attr('y', 0)
.attr('width', 2 * radius)
.attr('height', 2 * radius)
.attr('fill', (d) => d3.interpolateViridis(d));
.attr('fill', (d) => fcolors(d));
setTips(rects);
const configObj = {
width,
height,
radius,
margin,
dendWidth,
titleConfig,
rootData: root.leaves(),
vertRootLength: orderingx.length
};
// axisLabel
setAxisTitle(configObj, svg);
// label
setLabel(configObj, svg);
chartRef.value.append(svg.node());
};
// 标签
const setAxisTitle = (config, svg) => {
const labelList = config.rootData.map((item) => item.data.id);
svg.append('g')
.selectAll('g')
.data(labelList)
.join('g')
.attr(
'transform',
(d, i) =>
`translate(${config.margin + config.dendWidth + config.vertRootLength * 2 * config.radius + 3},${
config.margin + config.dendWidth + i * 2 * config.radius + 3
})`
)
.call((g) =>
g
.append('text')
.attr('x', 0)
.attr('y', 0)
.attr('dy', '0.35em')
.style('font', `6px sans-serif`)
.style('fill', `#000`)
.text((d) => d)
);
return svg.node();
};
// 标题
const setLabel = (config, svg) => {
svg.append('text')
.attr('x', 200)
.attr('y', 0)
.attr('fill', config.titleConfig.color)
.attr('font-size', `${config.titleConfig.fontSize}px`)
.attr('font-family', `${config.titleConfig.fontFamily}`)
.attr('font-style', `${config.titleConfig.fontStyle}`)
.attr('font-weight', `${config.titleConfig.fontWeight}`)
.style('text-anchor', 'middle')
.text('热图热图热图热图热图热图热图热图热图');
};
// 提示
const setTips = (rect) => {
const tips = d3
.select('.tip')
.style('visibility', 'hidden')
.style('position', 'relative')
.style('background-color', 'rgba(0, 0, 0, .8)')
.style('color', '#fff')
.style('border-radius', '5px')
.style('width', '200px')
.style('text-align', 'center')
.style('height', '20px')
.style('padding', '5px');
rect.on('mouseover', (event, d) => {
tips.style('visibility', 'visible');
})
.on('mousemove', function (event, d) {
tips.style('visibility', 'visible')
.style('left', event.offsetX + 15 + 'px')
.style('top', event.offsetY + 50 + 'px')
.html(`${d}`);
})
.on('mouseleave', function () {
tips.style('visibility', 'hidden');
});
};
const setColorConfig = (color) => {
let colors = [
'#313695',
'#436FB1',
'#6BA2CB',
'#9BCCE2',
'#F0F9D8',
'#F0F9D8',
'#FEF0A9',
'#FDCD7E',
'#FA9C58',
'#EE613D',
'#D22B26',
'#A50026'
];
if (Array.isArray(color)) {
colors = !!color.length ? color : colors;
} else if (!!color) {
colors = color.split(',');
}
return colors;
};
onMounted(() => {
generateChart();
const chartData = [...chartMackData];
generateChart(chartData);
});
</script>
<style scoped></style>
<style scoped>
.chart-box {
position: relative;
}
</style>
No preview for this file type
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论