|
<!DOCTYPE html> |
|
<html> |
|
<head> |
|
<meta charset=utf-8> |
|
<style> |
|
html,body{ |
|
height: 100%; |
|
margin: 0; |
|
} |
|
.axis path { |
|
display: none; |
|
} |
|
.axis line { |
|
stroke-opacity: 0.1; |
|
shape-rendering: crispEdges; |
|
} |
|
svg, |
|
#canvas { |
|
position: absolute; |
|
width: 100%; |
|
height: 100%; |
|
} |
|
#debug { |
|
z-index: 10; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<div id="canvas"></div> |
|
<div id='debug' style='padding:4px;background-color:#fffc;position:absolute;right:0;top:0;color:#0af;font-family:courier;font-size:12px;user-select:none'></div> |
|
</body> |
|
<script src="https://npmcdn.com/regl/dist/regl.min.js"></script> |
|
<script src="https://d3js.org/d3.v7.min.js"></script> |
|
|
|
<script language="javascript"> |
|
const pointSize = 6, nTick = 10, tickPadding = {x: 12, y: 6}; |
|
const BYTESIZE = 1 // GL_UNSIGNED_BYTE |
|
const FLOATSIZE = 4 // GL_FLOAT |
|
const THOUSAND = 1000; |
|
|
|
const container = "#canvas"; |
|
const canvas = document.querySelector(container); |
|
const svg = d3.select(container).append('svg'); |
|
const gX = svg.append("g").attr("class", "axis axis--x"); |
|
const gY = svg.append("g").attr("class", "axis axis--y"); |
|
const regl = createREGL({container: canvas}); |
|
|
|
let d3Transform = d3.zoomIdentity; |
|
let [widthLatest, heightLatest] = widthHeight(canvas); |
|
|
|
const maxPoints = 10 * THOUSAND * THOUSAND |
|
const XYSIZE = FLOATSIZE * 2; // vec2 (x, y); position |
|
const RGBASIZE = BYTESIZE * 4; // vec4 (r, g, b, a); uint8 is normalized and converted to float in regl(). |
|
const reglData = { |
|
count: 0, // the number of points to draw |
|
offset: 0, |
|
position: regl.buffer({ |
|
usage: 'dynamic', |
|
type: 'float', |
|
length: XYSIZE * maxPoints |
|
}), |
|
color: regl.buffer({ |
|
usage: 'dynamic', |
|
type: 'uint8', |
|
length: RGBASIZE * maxPoints |
|
}), |
|
transform: {}, // reglTransform |
|
} |
|
|
|
const addPoints = (points) => { |
|
const nPoints = points.length; |
|
if (nPoints > maxPoints) return; |
|
if (nPoints + reglData.offset > maxPoints) { |
|
reglData.count = reglData.offset; |
|
reglData.offset = 0; |
|
} |
|
const max = (a, b) => a>b?a:b; |
|
const position = points.map(v=>[v[0],v[1]]); |
|
const color = points.map(v=>hex2rgb(v[2])); |
|
reglData.position.subdata(position, reglData.offset * XYSIZE); |
|
reglData.color.subdata(color, reglData.offset * RGBASIZE); |
|
reglData.offset += nPoints |
|
reglData.count = max(reglData.count, reglData.offset); |
|
showDebug(); |
|
} |
|
|
|
const reglTransform = () => { |
|
const [width, height] = widthHeight(canvas); |
|
const scale = [d3Transform.k/width*2, d3Transform.k/height*2]; |
|
const offset = [d3Transform.x/width*2 + (d3Transform.k-1), -d3Transform.y/height*2 - (d3Transform.k-1)]; |
|
return {scale: scale, offset: offset}; |
|
} |
|
|
|
const drawPoints = regl({ |
|
profile: true, |
|
depth: {enable: false}, |
|
stencil: {enable: false}, |
|
primitive: 'points', |
|
count: regl.prop('count'), |
|
uniforms: { |
|
scale: regl.prop('transform.scale'), |
|
offset: regl.prop('transform.offset'), |
|
pointSize: pointSize, |
|
}, |
|
attributes: { |
|
position: { // vec2 |
|
buffer: regl.prop('position'), |
|
}, |
|
color: { // vec4 |
|
buffer: regl.prop('color'), |
|
normalized: true, // uint8 is normalized and converted to float |
|
} |
|
}, |
|
frag: ` |
|
precision mediump float; |
|
varying vec4 fill; |
|
void main() { |
|
gl_FragColor = fill; |
|
} |
|
`, |
|
vert: ` |
|
precision mediump float; |
|
attribute vec2 position; |
|
attribute vec4 color; |
|
uniform vec2 scale; |
|
uniform vec2 offset; |
|
uniform float pointSize; |
|
varying vec4 fill; |
|
void main() { |
|
gl_PointSize = pointSize; |
|
gl_Position = vec4(position*scale+offset, 0, 1); |
|
fill = color; |
|
} |
|
`, |
|
}); |
|
|
|
|
|
let redrawRequested = false; |
|
|
|
regl.frame(()=>{ |
|
if (redrawRequested) {drawPoints(reglData); redrawRequested=false;} |
|
}); |
|
|
|
const drawNewPoints = (newPoints) => { |
|
addPoints(newPoints); |
|
redrawRequested = true |
|
} |
|
|
|
const render = () => { |
|
const [width, height] = widthHeight(canvas); |
|
const xScale = d3.scaleLinear() |
|
.domain([-width / 2, width / 2]) |
|
.range([0, width]); |
|
const yScale = d3.scaleLinear() |
|
.domain([-height / 2, height / 2]) |
|
.range([height, 0]); |
|
|
|
const xAxis = d3.axisBottom(xScale) |
|
.ticks(width / height * nTick) |
|
.tickSize(height) |
|
.tickPadding(-tickPadding.x); |
|
const yAxis = d3.axisRight(yScale) |
|
.ticks(nTick) |
|
.tickSize(width) |
|
.tickPadding(-width + tickPadding.y); |
|
|
|
const zoomed = (event, d) => { |
|
d3Transform = event.transform; |
|
gX.call(xAxis.scale(d3Transform.rescaleX(xScale))); |
|
gY.call(yAxis.scale(d3Transform.rescaleY(yScale))); |
|
reglData.transform = reglTransform(); |
|
redrawRequested = true; |
|
} |
|
|
|
const zoom = d3.zoom().on("zoom", zoomed); |
|
svg.call(zoom).call(zoom.transform, d3Transform); |
|
}; |
|
|
|
const resizeRender = () => { |
|
const [width, height] = widthHeight(canvas); |
|
const v = (1/d3Transform.k-1)/2; |
|
d3Transform = d3Transform.translate((width-widthLatest)*v, (height-heightLatest)*v); |
|
[widthLatest, heightLatest] = [width, height]; |
|
regl.poll(); |
|
render(); |
|
} |
|
|
|
render(); |
|
window.addEventListener('resize', resizeRender); |
|
|
|
|
|
|
|
const dropArea = document.getElementById('canvas'); |
|
dropArea.ondragover = () => false; |
|
dropArea.ondragend = () => false; |
|
dropArea.ondrop = (e) => { |
|
[...e.dataTransfer.files].forEach(readLines); |
|
e.preventDefault(); |
|
return false; |
|
}; |
|
|
|
const transformStream = (file) => { |
|
const name = file.name.split('.'); |
|
const ext = name.pop(); |
|
const compressed = (ext == 'gz'); |
|
return compressed?new DecompressionStream('gzip'):new TransformStream(); |
|
} |
|
|
|
const readLines = (file) => { |
|
const stream = file.stream().pipeThrough(transformStream(file)); |
|
const reader = stream.getReader(); |
|
const decoder = new TextDecoder(); |
|
let remaining = ""; |
|
reader.read().then(function doChunk({value, done}) { |
|
if (done) return; |
|
const lines = (remaining + decoder.decode(value)).split("\n"); |
|
remaining = lines.pop(); |
|
(async () => { |
|
drawNewPoints(lines.map(line2array)); |
|
await wait(); |
|
reader.read().then(doChunk); |
|
})(); |
|
}); |
|
} |
|
|
|
function wait() {return new Promise(resolve => setTimeout(resolve, 0))}; |
|
|
|
function line2array(line) {return line.split(" ").map(w => {const v = Number(w); return isNaN(v)?w:v})}; |
|
|
|
function hex2rgb (hex) { |
|
if (!hex) return randomColor(); |
|
if (hex.charAt(0)=="#") hex=hex.slice(1); |
|
const rgb = [...Array(hex.length/2).keys()].map(i=>parseInt(hex.slice(i*2,(i+1)*2),16)); |
|
if (rgb.length == 3) {rgb.push(255)} |
|
return rgb; |
|
} |
|
|
|
function randomColor() { |
|
const rand = n => Math.floor(Math.random() * n); |
|
return [rand(256), rand(256), 0, 255] |
|
} |
|
|
|
function widthHeight(canvas) { |
|
const r = canvas.getBoundingClientRect(); |
|
return [r.width, r.height]; |
|
}; |
|
|
|
function showDebug() { |
|
const str = `total = ${reglData.count.toLocaleString()}`; |
|
document.getElementById("debug").innerText = str; |
|
} |
|
|
|
|
|
const data_initial = [[0,0,"ff0000"], [100,100,"ff0000"]]; |
|
drawNewPoints(data_initial); |
|
</script> |
|
</html> |