Skip to content

Instantly share code, notes, and snippets.

@lardratboy
Last active September 17, 2025 03:58
Show Gist options
  • Select an option

  • Save lardratboy/f8a7ca1b1c56bd948d64726aa687064b to your computer and use it in GitHub Desktop.

Select an option

Save lardratboy/f8a7ca1b1c56bd948d64726aa687064b to your computer and use it in GitHub Desktop.
Quantized Histogram for images (not robust for high unique counts)
<!DOCTYPE html>
<html>
<head>
<title>Scalable Image Color Frequency Analyzer</title>
<style>
body {
margin: 0;
}
canvas {
width: 100%;
height: 100%;
}
#dropZone {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1000;
display: none;
background: rgba(0, 0, 0, 0.7);
color: white;
justify-content: center;
align-items: center;
font-family: Arial, sans-serif;
font-size: 24px;
}
#instructions {
position: fixed;
bottom: 80px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
background: rgba(0, 0, 0, 0.5);
padding: 10px;
border-radius: 5px;
}
#objectList {
position: fixed;
top: 20px;
right: 20px;
color: white;
font-family: Arial, sans-serif;
background: rgba(0, 0, 0, 0.5);
padding: 15px;
border-radius: 5px;
max-height: 80vh;
overflow-y: auto;
}
.object-item {
margin: 5px 0;
padding: 5px;
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
}
.delete-btn {
background: #ff4444;
border: none;
color: white;
padding: 2px 6px;
border-radius: 3px;
cursor: pointer;
}
.delete-btn:hover {
background: #ff6666;
}
.visibility-btn {
background: none;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
}
.object-info {
flex-grow: 1;
}
#gridControls {
position: fixed;
top: 20px;
left: 20px;
color: white;
font-family: Arial, sans-serif;
background: rgba(0, 0, 0, 0.5);
padding: 15px;
border-radius: 5px;
}
.grid-toggle, .count-toggle {
margin: 5px 0;
cursor: pointer;
}
#fileInput {
position: absolute;
top: 10px;
left: 10px;
z-index: 100;
}
.thumbnail {
width: 50px;
height: 50px;
background-color: #333;
border: 1px solid #555;
cursor: pointer;
object-fit: contain;
}
#imagePopup {
display: none;
position: fixed;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.8);
z-index: 2000;
align-items: center;
justify-content: center;
flex-direction: column;
}
#popupImage {
max-width: 90%;
max-height: 80%;
border: 2px solid white;
background-color: #222;
}
#popupCaption {
color: white;
margin-top: 10px;
font-family: Arial, sans-serif;
}
#closePopup {
position: absolute;
top: 20px;
right: 30px;
font-size: 30px;
color: white;
cursor: pointer;
}
#controlPanel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: rgba(0, 0, 0, 0.8);
color: white;
font-family: Arial, sans-serif;
padding: 15px;
display: flex;
align-items: center;
gap: 20px;
justify-content: center;
}
#quantizationSelect {
background: #333;
color: white;
border: 1px solid #555;
padding: 4px 8px;
border-radius: 3px;
}
.control-group {
display: flex;
align-items: center;
gap: 10px;
}
.control-label {
font-size: 14px;
min-width: 80px;
}
#progressContainer {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.9);
padding: 20px;
border-radius: 10px;
display: none;
z-index: 1500;
color: white;
font-family: Arial, sans-serif;
}
#progressBar {
width: 300px;
height: 20px;
background: #333;
border: 1px solid #555;
border-radius: 10px;
overflow: hidden;
margin: 10px 0;
}
#progressFill {
height: 100%;
background: linear-gradient(90deg, #4CAF50, #45a049);
width: 0%;
transition: width 0.3s ease;
}
#progressText {
text-align: center;
font-size: 14px;
}
.warning {
color: #ffaa00;
font-size: 12px;
margin-top: 5px;
}
</style>
</head>
<body>
<div id="dropZone">Drop images here</div>
<div id="instructions">
Drag and drop images to analyze color frequency<br>
Mouse: Left click + drag to rotate<br>
Mouse wheel to zoom
</div>
<input type="file" id="fileInput" multiple accept="image/*" />
<div id="gridControls">
<label class="grid-toggle"><input type="checkbox" checked onchange="toggleGrid('xy')"> XY Plane (Blue)</label><br>
<label class="count-toggle"><input type="checkbox" checked onchange="toggleCountLines()"> Count Lines</label><br>
<label class="count-toggle"><input type="checkbox" checked onchange="toggleValueColors()"> Value Colors</label><br>
<label class="count-toggle"><input type="checkbox" checked onchange="toggleAutoLOD()"> Auto LOD</label>
</div>
<div id="objectList">
<h3 style="margin-top: 0">Loaded Images</h3>
<div id="objectItems"></div>
</div>
<div id="controlPanel">
<div class="control-group">
<label class="control-label">Quantization:</label>
<select id="quantizationSelect">
<option value="4">4-bit (16³)</option>
<option value="5">5-bit (32³)</option>
<option value="6">6-bit (64³)</option>
<option value="7">7-bit (128³)</option>
<option value="8" selected>8-bit (256³)</option>
</select>
</div>
<div class="control-group">
<label class="control-label">Max Points:</label>
<select id="maxPointsSelect">
<option value="100000">100k</option>
<option value="500000" selected>500k</option>
<option value="1000000">1M</option>
<option value="2000000">2M</option>
<option value="-1">Unlimited</option>
</select>
</div>
</div>
<div id="progressContainer">
<div id="progressText">Processing...</div>
<div id="progressBar">
<div id="progressFill"></div>
</div>
<div id="progressDetails"></div>
</div>
<div id="imagePopup">
<span id="closePopup">&times;</span>
<img id="popupImage">
<div id="popupCaption"></div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/three@0.128/examples/js/controls/OrbitControls.js"></script>
<script>
let scene, camera, renderer, controls;
let pointClouds = new Map();
let countLines = new Map();
let objectCounter = 0;
let grids = {};
let quantizationBits = 8;
let showCountLines = true;
let useValueColors = true;
let autoLOD = true;
let maxPoints = 500000;
// Scalable data structures
class CompactColorCounter {
constructor(quantizationBits) {
this.bits = quantizationBits;
this.range = 1 << quantizationBits;
this.mask = this.range - 1;
this.counts = new Map(); // Use Map for sparse storage
this.maxCount = 0;
}
increment(r, g, b) {
const qr = (r >> (8 - this.bits)) & this.mask;
const qg = (g >> (8 - this.bits)) & this.mask;
const qb = (b >> (8 - this.bits)) & this.mask;
const key = (qb << (this.bits * 2)) | (qg << this.bits) | qr;
const count = (this.counts.get(key) || 0) + 1;
this.counts.set(key, count);
if (count > this.maxCount) {
this.maxCount = count;
}
return count === 1; // Return true if this is a new unique color
}
*entries() {
for (const [key, count] of this.counts) {
const qr = key & this.mask;
const qg = (key >> this.bits) & this.mask;
const qb = key >> (this.bits * 2);
yield { qr, qg, qb, count, key };
}
}
size() {
return this.counts.size;
}
}
function updateProgress(percent, text = 'Processing...', details = '') {
const container = document.getElementById('progressContainer');
const fill = document.getElementById('progressFill');
const textEl = document.getElementById('progressText');
const detailsEl = document.getElementById('progressDetails');
if (percent >= 0) {
container.style.display = 'block';
fill.style.width = percent + '%';
textEl.textContent = text;
detailsEl.textContent = details;
} else {
container.style.display = 'none';
}
}
function init() {
scene = new THREE.Scene();
scene.background = new THREE.Color(0x111111);
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
camera.position.set(2, 2, 2);
camera.lookAt(0, 0, 0);
renderer = new THREE.WebGLRenderer({ antialias: false });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
controls = new THREE.OrbitControls(camera, renderer.domElement);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.5);
scene.add(ambientLight);
setupGrids();
setupDropZone();
setupImagePopup();
setupControls();
animate();
}
async function scalableQuantizeProcess(buffer, quantizationBits = 8, maxColors = -1) {
if (!buffer || !(buffer instanceof ArrayBuffer)) {
throw new Error('Invalid buffer provided');
}
const counter = new CompactColorCounter(quantizationBits);
const view = new DataView(buffer);
const maxTuples = Math.floor(buffer.byteLength / 3);
updateProgress(0, 'Analyzing colors...', '');
// Phase 1: Count unique colors with early termination if needed
const CHUNK_SIZE = 200000;
let processedTuples = 0;
let uniqueColors = 0;
for (let chunkStart = 0; chunkStart < maxTuples; chunkStart += CHUNK_SIZE) {
const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, maxTuples);
for (let tupleIdx = chunkStart; tupleIdx < chunkEnd; tupleIdx++) {
const baseOffset = tupleIdx * 3;
const r = view.getUint8(baseOffset);
const g = view.getUint8(baseOffset + 1);
const b = view.getUint8(baseOffset + 2);
if (counter.increment(r, g, b)) {
uniqueColors++;
// Early termination if we exceed max colors
if (maxColors > 0 && uniqueColors >= maxColors) {
console.log(`Early termination: reached ${maxColors} unique colors limit`);
break;
}
}
}
processedTuples = chunkEnd;
if (chunkStart % (CHUNK_SIZE * 5) === 0) {
const progress = Math.round((processedTuples / maxTuples) * 50);
updateProgress(progress, 'Analyzing colors...',
`${uniqueColors.toLocaleString()} unique colors found`);
await new Promise(resolve => setTimeout(resolve, 1));
}
// Break if we hit max colors
if (maxColors > 0 && uniqueColors >= maxColors) break;
}
updateProgress(50, 'Building geometry...',
`${uniqueColors.toLocaleString()} unique colors to process`);
// Phase 2: Build geometry from counted colors
const actualColors = Math.min(uniqueColors, maxColors > 0 ? maxColors : uniqueColors);
const points = new Float32Array(actualColors * 3);
const colors = new Float32Array(actualColors * 3);
const qToNormalized = 2 / (counter.range - 1);
let pointIndex = 0;
let processed = 0;
for (const { qr, qg, qb, count } of counter.entries()) {
if (pointIndex >= actualColors * 3) break;
const x = qr * qToNormalized - 1;
const y = qg * qToNormalized - 1;
const z = qb * qToNormalized - 1;
points[pointIndex] = x;
points[pointIndex + 1] = y;
points[pointIndex + 2] = z;
colors[pointIndex] = (x + 1) / 2;
colors[pointIndex + 1] = (y + 1) / 2;
colors[pointIndex + 2] = (z + 1) / 2;
pointIndex += 3;
processed++;
if (processed % 50000 === 0) {
const progress = 50 + Math.round((processed / actualColors) * 40);
updateProgress(progress, 'Building geometry...',
`${processed.toLocaleString()}/${actualColors.toLocaleString()} colors processed`);
await new Promise(resolve => setTimeout(resolve, 1));
}
}
updateProgress(100, 'Complete!',
`${actualColors.toLocaleString()} unique colors visualized`);
setTimeout(() => updateProgress(-1), 1000);
return {
points: points,
colors: colors,
numPoints: actualColors,
counter: counter,
truncated: uniqueColors > actualColors
};
}
async function generateScalableCountLines(counter, maxLines = -1) {
const entries = Array.from(counter.entries());
const actualLines = maxLines > 0 ? Math.min(entries.length, maxLines) : entries.length;
updateProgress(0, 'Generating count lines...', '');
const linePositions = new Float32Array(actualLines * 6); // 2 vertices per line
const lineColors = new Float32Array(actualLines * 6);
const qRange = counter.range;
const sqrtQRange = Math.floor(Math.sqrt(qRange));
const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1);
const maxLogCount = Math.log(counter.maxCount);
const qToNormalized = 2 / (qRange - 1);
let lineIndex = 0;
// Sort by count for LOD - show highest frequency colors first
if (maxLines > 0) {
entries.sort((a, b) => b.count - a.count);
}
for (let i = 0; i < actualLines; i++) {
const { qr, qg, qb, count } = entries[i];
// Tiled projection
const col = qb % sqrtQRange;
const row = Math.floor(qb / sqrtQRange);
const tiledX = col * qRange + qr;
const tiledY = row * qRange + qg;
const baseX = (tiledX / maxTiledCoord) * 2 - 1;
const baseY = (tiledY / maxTiledCoord) * 2 - 1;
const baseZ = 0;
const logCount = Math.log(count);
const normalizedHeight = logCount / maxLogCount;
const height = normalizedHeight * 0.5;
// Line vertices
linePositions[lineIndex] = baseX;
linePositions[lineIndex + 1] = baseY;
linePositions[lineIndex + 2] = baseZ;
linePositions[lineIndex + 3] = baseX;
linePositions[lineIndex + 4] = baseY;
linePositions[lineIndex + 5] = height;
// Colors
let r, g, b;
if (useValueColors) {
r = qr * qToNormalized / 2 + 0.5;
g = qg * qToNormalized / 2 + 0.5;
b = qb * qToNormalized / 2 + 0.5;
} else {
const intensity = normalizedHeight;
r = intensity;
g = 0.2;
b = 1 - intensity;
}
lineColors[lineIndex] = r;
lineColors[lineIndex + 1] = g;
lineColors[lineIndex + 2] = b;
lineColors[lineIndex + 3] = r;
lineColors[lineIndex + 4] = g;
lineColors[lineIndex + 5] = b;
lineIndex += 6;
if (i % 25000 === 0) {
const progress = Math.round((i / actualLines) * 100);
updateProgress(progress, 'Generating count lines...',
`${i.toLocaleString()}/${actualLines.toLocaleString()} lines`);
await new Promise(resolve => setTimeout(resolve, 1));
}
}
updateProgress(100, 'Lines complete!');
setTimeout(() => updateProgress(-1), 500);
return {
positions: linePositions,
colors: lineColors,
lineCount: actualLines,
truncated: entries.length > actualLines
};
}
function calculateTiledProjection(points, quantizationBits) {
const qRange = 1 << quantizationBits;
const qMask = qRange - 1;
const sqrtQRange = Math.floor(Math.sqrt(qRange));
const maxTiledCoord = Math.max(sqrtQRange * qRange + qRange - 1, 1);
const projectedPoints = new Float32Array(points.length);
for (let i = 0; i < points.length; i += 3) {
const x = points[i];
const y = points[i + 1];
const z = points[i + 2];
const qr = Math.max(0, Math.min(qMask, Math.floor((x + 1) / 2 * qRange)));
const qg = Math.max(0, Math.min(qMask, Math.floor((y + 1) / 2 * qRange)));
const qb = Math.max(0, Math.min(qMask, Math.floor((z + 1) / 2 * qRange)));
const col = qb % sqrtQRange;
const row = Math.floor(qb / sqrtQRange);
const tiledX = col * qRange + qr;
const tiledY = row * qRange + qg;
projectedPoints[i] = (tiledX / maxTiledCoord) * 2 - 1;
projectedPoints[i + 1] = (tiledY / maxTiledCoord) * 2 - 1;
projectedPoints[i + 2] = 0;
}
return projectedPoints;
}
function setupControls() {
const quantizationSelect = document.getElementById('quantizationSelect');
const maxPointsSelect = document.getElementById('maxPointsSelect');
quantizationSelect.addEventListener('change', (e) => {
quantizationBits = parseInt(e.target.value);
regenerateAllData();
});
maxPointsSelect.addEventListener('change', (e) => {
maxPoints = parseInt(e.target.value);
regenerateAllData();
});
}
function regenerateAllData() {
const originalDataMap = new Map();
pointClouds.forEach((cloud, id) => {
originalDataMap.set(id, {
originalData: cloud.originalData,
name: cloud.name
});
});
pointClouds.forEach((cloud, id) => {
scene.remove(cloud.object);
if (countLines.has(id)) {
scene.remove(countLines.get(id));
countLines.delete(id);
}
});
pointClouds.clear();
originalDataMap.forEach((data, id) => {
createPointCloudFromProcessedData(data.originalData, data.name);
});
updateObjectList();
}
function setupGrids() {
const gridSize = 2;
const gridDivisions = 4;
const gridXY = new THREE.GridHelper(gridSize, gridDivisions, 0x0000ff, 0x0000ff);
gridXY.rotation.x = Math.PI / 2;
scene.add(gridXY);
grids.xy = gridXY;
}
function toggleGrid(plane) {
if (grids[plane]) {
grids[plane].visible = !grids[plane].visible;
}
}
function toggleCountLines() {
showCountLines = !showCountLines;
countLines.forEach(lineSegments => {
lineSegments.visible = showCountLines;
});
}
function toggleValueColors() {
useValueColors = !useValueColors;
regenerateCountLines();
}
function toggleAutoLOD() {
autoLOD = !autoLOD;
// Could regenerate with different LOD settings
}
async function regenerateCountLines() {
const promises = [];
pointClouds.forEach((cloud, id) => {
promises.push((async () => {
if (countLines.has(id)) {
scene.remove(countLines.get(id));
countLines.delete(id);
}
if (cloud.counter && cloud.counter.size() > 0) {
const maxLines = autoLOD && cloud.counter.size() > 100000 ? 100000 : -1;
const lineData = await generateScalableCountLines(cloud.counter, maxLines);
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(lineData.positions, 3));
lineGeometry.setAttribute('color', new THREE.Float32BufferAttribute(lineData.colors, 3));
const lineMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
linewidth: 1
});
const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial);
lineSegments.visible = showCountLines && cloud.object.visible;
countLines.set(id, lineSegments);
scene.add(lineSegments);
}
})());
});
await Promise.all(promises);
}
function removeAlphaChannel(canvas, data) {
const imagePixelCount = canvas.width * canvas.height;
const rgbOnlyBytes = new Uint8ClampedArray(imagePixelCount * 3);
for (let i = 0, j = 0; i < data.length; i += 4, j += 3) {
rgbOnlyBytes[j] = data[i];
rgbOnlyBytes[j + 1] = data[i + 1];
rgbOnlyBytes[j + 2] = data[i + 2];
}
return rgbOnlyBytes;
}
function handleFiles(files) {
for (const file of files) {
if (!file.type.startsWith('image/')) {
console.log(`Skipping non-image file: ${file.name}`);
continue;
}
const reader = new FileReader();
reader.onload = function (e) {
const img = new Image();
img.onload = function () {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
createPointCloudFromProcessedData(removeAlphaChannel(canvas, imageData.data), file.name);
};
img.src = e.target.result;
};
reader.readAsDataURL(file);
}
}
document.addEventListener('paste', (e) => {
e.preventDefault();
const items = (e.clipboardData || window.clipboardData).items;
for (let item of items) {
if (item.kind === 'file') {
const file = item.getAsFile();
handleFiles([file]);
} else {
item.getAsString((text) => {
tryFetchAPI(text);
});
}
}
});
async function tryFetchAPI(src) {
try {
const response = await fetch(src);
if (response.ok) {
handleFiles([await response.blob()]);
}
} catch (_) {
}
}
function handleNonFileDrop(text) {
if (text.startsWith('<meta')) {
try {
const parser = new DOMParser();
const doc = parser.parseFromString(text, 'text/html');
const imgSrc = doc.querySelector('img').getAttribute('src');
tryFetchAPI(imgSrc);
} catch (_) {
}
}
}
function handleDrop(e) {
const dt = e.dataTransfer;
const file = dt.files[0];
if (file) {
handleFiles(e.dataTransfer.files);
return;
}
for (let i = 0; i < dt.items.length; i++) {
const item = dt.items[i];
if (!(item.kind === 'string')) continue;
item.getAsString((s) => {
handleNonFileDrop(s);
});
}
}
function setupDropZone() {
const dropZone = document.getElementById('dropZone');
document.addEventListener('dragenter', (e) => {
e.preventDefault();
dropZone.style.display = 'flex';
});
dropZone.addEventListener('dragleave', (e) => {
e.preventDefault();
dropZone.style.display = 'none';
});
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.style.display = 'none';
handleDrop(e);
});
}
function createThumbnailImage(rgbData) {
const pointCount = rgbData.length / 3;
const side = Math.ceil(Math.sqrt(pointCount));
const canvas = document.createElement('canvas');
canvas.width = side;
canvas.height = side;
const ctx = canvas.getContext('2d');
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, side, side);
const imageData = ctx.createImageData(side, side);
const data = imageData.data;
data.fill(0);
for (let i = 0; i < pointCount && i < side * side; i++) {
const x = i % side;
const y = Math.floor(i / side);
const pixelIndex = (y * side + x) * 4;
const rgbIndex = i * 3;
data[pixelIndex] = rgbData[rgbIndex];
data[pixelIndex + 1] = rgbData[rgbIndex + 1];
data[pixelIndex + 2] = rgbData[rgbIndex + 2];
data[pixelIndex + 3] = 255;
}
ctx.putImageData(imageData, 0, 0);
return {
dataUrl: canvas.toDataURL('image/png'),
width: side,
height: side
};
}
async function createPointCloudFromProcessedData(buffer, fileName) {
const arrayBuffer = buffer instanceof ArrayBuffer ? buffer : buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
const sizeInMB = arrayBuffer.byteLength / (1024 * 1024);
const pixelCount = arrayBuffer.byteLength / 3;
console.log(`Processing ${fileName}: ${sizeInMB.toFixed(1)}MB (${pixelCount.toLocaleString()} pixels)`);
try {
const effectiveMaxPoints = maxPoints > 0 ? maxPoints : -1;
const result = await scalableQuantizeProcess(arrayBuffer, quantizationBits, effectiveMaxPoints);
const vertices = result.points;
const colors = result.colors;
const pointCount = result.numPoints;
const counter = result.counter;
const projectedPositions = calculateTiledProjection(vertices, quantizationBits);
const rgbDataFor255 = new Uint8ClampedArray(colors.length);
for (let i = 0; i < colors.length; i++) {
rgbDataFor255[i] = colors[i] * 255;
}
const thumbnail = createThumbnailImage(rgbDataFor255);
const geometry = new THREE.BufferGeometry();
geometry.setAttribute('position', new THREE.Float32BufferAttribute(projectedPositions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
const material = new THREE.PointsMaterial({
size: 0.01,
vertexColors: true
});
const points = new THREE.Points(geometry, material);
const id = `cloud_${objectCounter++}`;
pointClouds.set(id, {
object: points,
name: fileName || 'unknown',
pointCount: pointCount,
thumbnail: thumbnail,
originalPositions: vertices,
originalData: buffer,
counter: counter,
truncated: result.truncated
});
scene.add(points);
// Generate count lines with LOD
if (counter.size() > 0) {
const maxLines = autoLOD && counter.size() > 100000 ? 100000 : -1;
const lineData = await generateScalableCountLines(counter, maxLines);
const lineGeometry = new THREE.BufferGeometry();
lineGeometry.setAttribute('position', new THREE.Float32BufferAttribute(lineData.positions, 3));
lineGeometry.setAttribute('color', new THREE.Float32BufferAttribute(lineData.colors, 3));
const lineMaterial = new THREE.LineBasicMaterial({
vertexColors: true,
linewidth: 1
});
const lineSegments = new THREE.LineSegments(lineGeometry, lineMaterial);
lineSegments.visible = showCountLines;
countLines.set(id, lineSegments);
scene.add(lineSegments);
console.log(`Created ${lineData.lineCount.toLocaleString()} count lines for ${fileName}${lineData.truncated ? ' (truncated)' : ''}`);
}
updateObjectList();
const statusMsg = result.truncated ? ` (limited to ${pointCount.toLocaleString()} colors)` : '';
console.log(`Completed processing ${fileName}: ${pointCount.toLocaleString()} unique colors${statusMsg}`);
} catch (error) {
console.error(`Error processing ${fileName}:`, error);
updateProgress(-1);
}
}
function setupImagePopup() {
const popup = document.getElementById('imagePopup');
const closeBtn = document.getElementById('closePopup');
closeBtn.addEventListener('click', () => {
popup.style.display = 'none';
});
popup.addEventListener('click', (e) => {
if (e.target === popup) {
popup.style.display = 'none';
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && popup.style.display === 'flex') {
popup.style.display = 'none';
}
});
}
function showImagePopup(id) {
const cloud = pointClouds.get(id);
if (!cloud || !cloud.thumbnail) return;
const popup = document.getElementById('imagePopup');
const img = document.getElementById('popupImage');
const caption = document.getElementById('popupCaption');
img.src = cloud.thumbnail.dataUrl;
const truncatedNote = cloud.truncated ? ' (truncated)' : '';
caption.textContent = `${cloud.name}: ${cloud.pointCount.toLocaleString()} unique colors${truncatedNote} (${cloud.thumbnail.width}×${cloud.thumbnail.height})`;
popup.style.display = 'flex';
}
function updateObjectList() {
const container = document.getElementById('objectItems');
container.innerHTML = '';
pointClouds.forEach((cloud, id) => {
const item = document.createElement('div');
item.className = 'object-item';
if (cloud.thumbnail) {
const thumb = document.createElement('img');
thumb.className = 'thumbnail';
thumb.src = cloud.thumbnail.dataUrl;
thumb.title = 'Click to view full image';
thumb.onclick = () => showImagePopup(id);
item.appendChild(thumb);
}
const info = document.createElement('div');
info.className = 'object-info';
const truncatedNote = cloud.truncated ? ' (truncated)' : '';
info.innerHTML = `${cloud.name}<br><small>${cloud.pointCount.toLocaleString()} colors${truncatedNote}</small>`;
const visibilityBtn = document.createElement('button');
visibilityBtn.className = 'visibility-btn';
visibilityBtn.innerHTML = cloud.object.visible ? '👁️' : '👁️‍🗨️';
visibilityBtn.onclick = () => toggleVisibility(id);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'delete-btn';
deleteBtn.textContent = '×';
deleteBtn.onclick = () => deletePointCloud(id);
item.appendChild(info);
item.appendChild(visibilityBtn);
item.appendChild(deleteBtn);
container.appendChild(item);
});
}
function toggleVisibility(id) {
const cloud = pointClouds.get(id);
if (cloud) {
cloud.object.visible = !cloud.object.visible;
if (countLines.has(id)) {
countLines.get(id).visible = cloud.object.visible && showCountLines;
}
updateObjectList();
}
}
function deletePointCloud(id) {
const cloud = pointClouds.get(id);
if (cloud) {
scene.remove(cloud.object);
pointClouds.delete(id);
if (countLines.has(id)) {
scene.remove(countLines.get(id));
countLines.delete(id);
}
updateObjectList();
}
}
function animate() {
requestAnimationFrame(animate);
if (controls) controls.update();
renderer.render(scene, camera);
}
init();
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
document.getElementById('fileInput').addEventListener('change', function (e) {
handleFiles(e.target.files);
});
</script>
</body>
</html>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment