When i zoom in enough to have the candlestick bodies render, on high numbers of candlesticks, this becomes unbearably slow and laggy
Lets take this step by step and start with a simple solution and expand upon it.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Three.js Interactive Line with Candlesticks - Enhanced Line Grabbing</title>
  <style>
    body { margin: 0; }
    canvas { display: block; }
  </style>
</head>
<body>
  <script type="module">

import * as THREE from './three.module.js';

// Scene setup
const scene = new THREE.Scene();
scene.background = new THREE.Color(0x000000);

// Camera setup
const aspectRatio = window.innerWidth / window.innerHeight;
const cameraSize = 200;
const camera = new THREE.OrthographicCamera(
  -cameraSize * aspectRatio,
  cameraSize * aspectRatio,
  cameraSize,
  -cameraSize,
  1,
  1000
);
camera.position.set(0, 0, 500);

// Renderer setup
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);

// Function to generate random OHLC data
function generateOHLCData(count) {
  const data = new Array(count);
  let open = 50; // Starting price
  for (let i = 0; i < count; i++) {
    const high = open + Math.random() * 10;
    const low = open - Math.random() * 10;
    const close = low + Math.random() * (high - low);
    data[i] = { open, high, low, close };
    open = close; // Next open starts where the last close was
  }
  return data;
}

// Convert RGB to normalized color values (0 to 1)
function rgbToNormalized(r, g, b) {
  return [r / 255, g / 255, b / 255];
}

// Create instanced candlesticks with uniform borders and wicks
function createInstancedCandlesticks(data) {
  const barWidth = 1;
  const maxCandlesticks = data.length;

  // Materials
  const material = new THREE.MeshBasicMaterial({ vertexColors: true });

  // Geometries for the candlestick body and borders
  const geometryBar = new THREE.BoxGeometry(barWidth, 1, 0);

  // Create instanced mesh for candlesticks
  const instancedMesh = new THREE.InstancedMesh(geometryBar, material, maxCandlesticks);

  const dummy = new THREE.Object3D();
  const wickVertices = new Float32Array(maxCandlesticks * 6); // 2 points for each wick
  const wickColors = new Float32Array(maxCandlesticks * 6); // Color for each wick point

  // Define colors in normalized RGB
  const bullishColor = rgbToNormalized(0, 70, 0); // Custom RGB for bullish wicks (green)
  const bearishColor = rgbToNormalized(138, 0, 0); // Custom RGB for bearish wicks (red)
  const colorArray = new Float32Array(maxCandlesticks * 3); // RGB for each instance

  let currentX = 0;

  for (let index = 0; index < maxCandlesticks; index++) {
    const d = data[index];
    const isBullish = d.close >= d.open;
    const height = Math.abs(d.close - d.open);
    const y = (d.close + d.open) / 2;

    // Set the position and scale for the body
    dummy.position.set(currentX, y, 0);
    dummy.scale.set(1, height, 1); // Width is set to 1; barWidth is already accounted for in scaling
    dummy.updateMatrix();

    instancedMesh.setMatrixAt(index, dummy.matrix);

    // Set color for each instance
    const color = isBullish ? bullishColor : bearishColor;
    colorArray.set(color, index * 3); // Set color
    wickColors.set([...color, ...color], index * 6); // Set wick color (two points)

    // Wick positions
    const wickIndex = index * 6;
    wickVertices[wickIndex] = currentX;
    wickVertices[wickIndex + 1] = d.low;
    wickVertices[wickIndex + 2] = -0.5;
    wickVertices[wickIndex + 3] = currentX;
    wickVertices[wickIndex + 4] = d.high;
    wickVertices[wickIndex + 5] = -0.5;

    // Increment x for the next candlestick
    currentX += barWidth * 1.5;
  }

  // Add the instances to the scene
  scene.add(instancedMesh);

  // Create and add wick line segments to the scene
  const wickGeometry = new THREE.BufferGeometry();
  wickGeometry.setAttribute('position', new THREE.BufferAttribute(wickVertices, 3));
  wickGeometry.setAttribute('color', new THREE.BufferAttribute(wickColors, 3)); // Add color to wicks
  const wickMaterial = new THREE.LineBasicMaterial({ vertexColors: true }); // Enable vertex colors
  const wickLineSegments = new THREE.LineSegments(wickGeometry, wickMaterial);
  scene.add(wickLineSegments);

  // Add color attribute to geometry
  geometryBar.setAttribute('color', new THREE.InstancedBufferAttribute(colorArray, 3));

  return { instancedMesh, wickLineSegments };
}

// Generate data and create instanced candlesticks
const ohlcData = generateOHLCData(1000000); // Adjust the number as needed
const { instancedMesh, wickLineSegments } = createInstancedCandlesticks(ohlcData);

// Zoom logic (based on mouse wheel and key modifiers)
const zoomSpeed = 0.8;
let visibilityUpdateTimeout; // Timeout variable for visibility updates

function onWheel(event) {
  event.preventDefault();

  const rect = renderer.domElement.getBoundingClientRect();
  const mouseX = ((event.clientX - rect.left) / rect.width) * 2 - 1;
  const mouseY = -((event.clientY - rect.top) / rect.height) * 2 + 1;

  const mouseWorld = new THREE.Vector3(mouseX, mouseY, 0).unproject(camera);
  const zoomFactor = 1 - event.deltaY * 0.001 * zoomSpeed;

  const ctrlPressed = event.ctrlKey;
  const shiftPressed = event.shiftKey;

  if (ctrlPressed) {
    camera.left = (camera.left - mouseWorld.x) * zoomFactor + mouseWorld.x;
    camera.right = (camera.right - mouseWorld.x) * zoomFactor + mouseWorld.x;
  } else if (shiftPressed) {
    camera.top = (camera.top - mouseWorld.y) * zoomFactor + mouseWorld.y;
    camera.bottom = (camera.bottom - mouseWorld.y) * zoomFactor + mouseWorld.y;
  } else {
    camera.left = (camera.left - mouseWorld.x) * zoomFactor + mouseWorld.x;
    camera.right = (camera.right - mouseWorld.x) * zoomFactor + mouseWorld.x;
    camera.top = (camera.top - mouseWorld.y) * zoomFactor + mouseWorld.y;
    camera.bottom = (camera.bottom - mouseWorld.y) * zoomFactor + mouseWorld.y;
  }

  camera.updateProjectionMatrix();

  // Clear previous timeout if it exists
  clearTimeout(visibilityUpdateTimeout);

  // Set a new timeout to update visibility after a delay
  visibilityUpdateTimeout = setTimeout(updateCandlestickVisibility, 100); // Adjust the delay as needed
}

// Function to update candlestick visibility based on zoom level
function updateCandlestickVisibility() {
  const visibleWidth = camera.right - camera.left;
  const candlestickWidth = 1.5; // Width of each candlestick including spacing
  const visibleCandlesticks = visibleWidth / candlestickWidth;

  // Adjust the threshold for visibility based on zoom level
  const threshold = 700;

  if (visibleCandlesticks > threshold) {
    instancedMesh.visible = false; // Hide bodies if too many are visible
    wickLineSegments.visible = true; // Always show wicks
  } else {
    instancedMesh.visible = true; // Show bodies when zoomed in
    wickLineSegments.visible = true; // Always show wicks
  }
}

// Prevent default browser behavior for wheel scrolling with Control or Shift key
window.addEventListener('wheel', onWheel, { passive: false });

// Prevent default context menu on right-click
window.addEventListener('contextmenu', (event) => {
  event.preventDefault();
});

// Render loop
function animate() {
  requestAnimationFrame(animate);
  renderer.render(scene, camera);
}

// Handle window resize
function onWindowResize() {
  const aspect = window.innerWidth / window.innerHeight;
  camera.left = -cameraSize * aspect;
  camera.right = cameraSize * aspect;
  camera.top = cameraSize;
  camera.bottom = -cameraSize;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
  updateCandlestickVisibility();
}
window.addEventListener('resize', onWindowResize);

// Start animation
animate();

// Panning functionality
let isPanning = false; // Track panning state
let isDraggingEndpoint = false; // Track endpoint dragging state
let selectedEndpointIndex = null; // Index of the selected endpoint (0 or 1)
const previousMousePosition = new THREE.Vector2(); // Store previous mouse position

// Raycaster for detecting line interaction
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();

// **Interactive line code**
// Define line geometry and material
const lineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const lineGeometry = new THREE.BufferGeometry().setFromPoints([
  new THREE.Vector3(-50, 0, 0),
  new THREE.Vector3(50, 0, 0)
]);
const line = new THREE.Line(lineGeometry, lineMaterial);
line.frustumCulled = false; // Prevent the line from being culled
scene.add(line);

// Set the distance for grabbing the line (in world units)
let distanceForGrab = 2; // You can adjust this value to make grabbing easier

// Set the raycaster's line precision to `distanceForGrab`
raycaster.params.Line.threshold = distanceForGrab; // For Three.js r127 and above

// Predefine vectors to avoid excessive allocations
const mouseWorld = new THREE.Vector3();
const deltaWorld = new THREE.Vector3();
const currentMouse = new THREE.Vector2();
const previousMouseWorld = new THREE.Vector3();

// Mouse down event
function onMouseDown(event) {
  if (event.button === 2) { // Right mouse button for panning
    isPanning = true;
    previousMousePosition.set(event.clientX, event.clientY);
  } else if (event.button === 0) { // Left mouse button for line interaction
    // Prepare raycaster
    const rect = renderer.domElement.getBoundingClientRect();
    mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    mouse.y = - ((event.clientY - rect.top) / rect.height) * 2 + 1;

    raycaster.setFromCamera(mouse, camera);

    // Check intersection with the line
    const intersects = raycaster.intersectObject(line, true);

    if (intersects.length > 0) {
      // Clicked on the line
      // Determine which endpoint is closer to the mouse
      const positions = lineGeometry.attributes.position.array;
      const lineStart = new THREE.Vector3(positions[0], positions[1], positions[2]);
      const lineEnd = new THREE.Vector3(positions[3], positions[4], positions[5]);

      mouseWorld.copy(intersects[0].point);

      const distanceToStart = mouseWorld.distanceTo(lineStart);
      const distanceToEnd = mouseWorld.distanceTo(lineEnd);

      selectedEndpointIndex = (distanceToStart < distanceToEnd) ? 0 : 1;

      isDraggingEndpoint = true;
      // Set previous mouse position in NDC
      previousMousePosition.set(mouse.x, mouse.y);
    }
  }
}

// Mouse move event
function onMouseMove(event) {
  if (isPanning) {
    // Panning code
    const deltaX = event.clientX - previousMousePosition.x;
    const deltaY = event.clientY - previousMousePosition.y;

    const zoomFactorX = camera.right - camera.left;
    const zoomFactorY = camera.top - camera.bottom;

    const panX = (deltaX / window.innerWidth) * zoomFactorX;
    const panY = (deltaY / window.innerHeight) * zoomFactorY;

    camera.left -= panX;
    camera.right -= panX;
    camera.top += panY;
    camera.bottom += panY;
    camera.updateProjectionMatrix();

    previousMousePosition.set(event.clientX, event.clientY); // Update previous position

    // Update candlestick visibility after panning
    updateCandlestickVisibility();
  } else if (isDraggingEndpoint) {
    // Get current mouse position in normalized device coordinates (NDC)
    const rect = renderer.domElement.getBoundingClientRect();
    currentMouse.set(
      ((event.clientX - rect.left) / rect.width) * 2 - 1,
      - ((event.clientY - rect.top) / rect.height) * 2 + 1
    );

    // Convert previous and current mouse positions to world coordinates
    previousMouseWorld.set(previousMousePosition.x, previousMousePosition.y, 0).unproject(camera);
    mouseWorld.set(currentMouse.x, currentMouse.y, 0).unproject(camera);

    // Compute delta in world coordinates
    deltaWorld.subVectors(mouseWorld, previousMouseWorld);

    // Update the selected endpoint
    const positions = lineGeometry.attributes.position.array;
    const index = selectedEndpointIndex * 3; // 0 or 3

    positions[index] += deltaWorld.x;
    positions[index + 1] += deltaWorld.y;

    lineGeometry.attributes.position.needsUpdate = true;
    lineGeometry.computeBoundingSphere(); // Update bounding sphere for frustum culling and raycasting

    previousMousePosition.copy(currentMouse); // Update previous mouse position
  }
}

// Mouse up event
function onMouseUp(event) {
  if (event.button === 2) { // Right mouse button
    isPanning = false;
  } else if (event.button === 0) { // Left mouse button
    isDraggingEndpoint = false;
  }
}

// Add event listeners for mouse actions
window.addEventListener('mousedown', onMouseDown);
window.addEventListener('mousemove', onMouseMove);
window.addEventListener('mouseup', onMouseUp);

  </script>
</body>
</html>