具有大数据集问题的强制布局气泡图

时间:2019-07-18 06:21:27

标签: javascript d3.js

是否存在一些用于构建带有组(如http://projects.delimited.io/experiments/force-bubbles/)但具有巨大数据集的强制布局气泡图的算法。我的主要问题是选择正确的空格,在本示例中,它们使用一些静态偏移量。但是,如果您的小组中有1万个气泡,而有些气泡只有100个,它们通常会碰撞并覆盖标题。也许您可以使用类似https://en.m.wikipedia.org/wiki/Circle_packing_in_a_circle的方法来决定,但不确定如何处理不同的大小

Example of layout

1 个答案:

答案 0 :(得分:1)

请考虑以下因素:

  1. SVG可以显示有限数量的元素(建议限制为10K)

  2. 人眼可以识别屏幕上的数百个视觉项目(并专注于十二个,而不是更多)

我建议使用Google Maps方法,即定义虚拟空间,并根据视口位置和缩放级别仅显示其中的相关部分。

const space = {
	top: -25000, 
	left: -25000, 
	right: 25000, 
	bottom: 25000
};

const generateRandomCircles = count => {
	let circles = [];
	const spaceWidth  = space.right - space.left;
	const spaceHeight  = space.bottom - space.top;
	for (let id = 1; id <= count; id++) {
		const x = space.left + Math.random() * spaceWidth;
		const y = space.top + Math.random() * spaceHeight;

		const r = 30 + Math.random() * 70;
		const c = () => 128 + Math.floor(Math.random() * 128);
		const color = `rgb(${c()},${c()},${c()})`;
		const circle = {id, x, y, r, color};
		circles.push(circle);
	}
	return circles.sort((a, b) => b.r - a.r);
};

const svg = d3.select('svg')
const width = parseInt(svg.attr('width'));
const height = parseInt(svg.attr('height'));
const circles = generateRandomCircles(50000);

const virtualToScreen = (point, transform) => {
	const x = (point.x + width/2) * transform.k + transform.x;
	const y = (point.y + height/2) * transform.k + transform.y;
	return {x, y};
};

const screenToVirtual = (point, transform) => {
	const x = (point.x - transform.x) / transform.k - width/2;
	const y = (point.y - transform.y) / transform.k - height/2;
	return {x, y};
};

const drawMarker = (pos, label, isVertical, showLabel=true) => {
	svg.append('line')
		.classed('marker-line', true)
		.attr('x1', isVertical ? pos : 0)
		.attr('x2', isVertical ? pos : width)
		.attr('y1', isVertical ? 0 : pos)
		.attr('y2', isVertical ? height : pos)
		.style('stroke', '#789');
	
	if(showLabel) {
		svg.append('text')
			.classed('marker-label', true)
			.text(label)
			.attr('x', isVertical ? pos : 3)
			.attr('y', isVertical ? 14 : pos + 4)
			.attr('text-anchor', isVertical ? 'middle' : 'start')
			.style('fill', '#def');

		svg.append('text')
			.classed('marker-label', true)
			.text(label)
			.attr('x', isVertical ? pos : width - 3)
			.attr('y', isVertical ? height - 4 : pos + 4)
			.attr('text-anchor', isVertical ? 'middle' : 'end')
			.style('fill', '#def');
		}
};

const scaleFactors = () =>  {
	let factors = [1];
	let multiplier = 1;
	for (let index = 0; index < 20; index++) {
		multiplier *= index % 3 == 1 ? 2.5 : 2;
		factors.push(multiplier);
	}
	return factors.reverse();
}

const updateMarkers = transform => {
	svg.selectAll('.marker-label,.marker-line').remove();
		
	const factor = width / 5 / transform.k;
	const step = scaleFactors()
		.find(step => step < factor) || 1;
	
	const stl = {x: 0, y: 0};
	const sbr = {x: width, y: height};
	const vtl = screenToVirtual(stl, transform);
	const vbr = screenToVirtual(sbr, transform);
	
	const fromX = Math.ceil(vtl.x / step) * step;
	const toX = Math.floor(vbr.x / step) * step;
	for (let x = fromX; x <= toX; x += step) {
		const pos = virtualToScreen({x, y: 0}, transform);
		drawMarker(pos.x, x, true, x > fromX && x < toX);
	}
	
	const fromY = Math.ceil(vtl.y / step) * step;
	const toY = Math.floor(vbr.y / step) * step;
	for (let y = fromY; y <= toY; y += step) {
		const pos = virtualToScreen({x: 0, y}, transform);
		drawMarker(pos.y, y, false);
	}
};

const updateView = transform => {
	const mapper = circle => {
		const point = virtualToScreen(circle, transform);
		const r = circle.r * transform.k;
		return {...circle, ...point, r};
	};
	
	const circleIsVisible = circle => 
		(circle.x + circle.r > 0 && 
		circle.x - circle.r < width &&
		circle.y + circle.r > 0 && 
		circle.y - circle.r < height &&
		circle.r > 2); 

	const start = performance.now();
	const filtered = circles.map(mapper)
		.filter(circleIsVisible);
		
	const all = svg.selectAll('circle')
		.data(filtered, d => d.id)
		
	const added = all.enter()
		.append('circle')
		.style('fill', d => d.color)
		.style('opacity', 0.5)
		
	added.merge(all)
		.attr('cx', d => d.x)
		.attr('cy', d => d.y)
		.attr('r', d => d.r);
		
	all.exit().remove();
	
	updateMarkers(transform);
};

const delta = () => {
	return -d3.event.deltaY * (d3.event.deltaMode ? 120 : 1) / 5000;
}

const onZoom = () => updateView(d3.event.transform);
	
const svgZoom = d3.zoom()
		.wheelDelta(delta)
		.clickDistance(10)
		.scaleExtent([0.1, 10])
		.on('zoom', onZoom);
svg.call(svgZoom);

updateView({x: 0, y: 0, k: 1});
html, body {
  margin: 0;
  padding: 0;
}

svg {
  background-color: #123;
	font-family: Calibri;
	font-size: 12pt;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/d3/5.7.0/d3.min.js"></script>
<svg width='500' height='300'>
</svg>