npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@bubo-squared/miraj

v0.1.2

Published

Developer friendly Gaussian Splatting viewer for eCommerce

Readme

miraj

Developer friendly Gaussian Splatting viewer for eCommerce

Example usage

Creating the viewer instance

const viewer = new MirajViewer('viewerContainer');

Loading a file from URL

viewer.load('/example_splat.ply', FileFormat.PLY);

Loading a Bubo² hosted model

Managed loading loads models hosted by Bubo², but also includes automatic loading of markers and viewer camera position and constraints.

// Load the smallest available variant
viewer.loadManaged('5hxx8e');

// or load a different variant, e.g. the high quality 
viewer.loadManaged('5hxx8e', variant='high');

Viewer configuration

Viewer configuration controls many aspects of the viewer behavior and can be provided at initialization or updated at any time. For details on all available configuration options and their effects consult the Configuration options section or type documentation.

// Configuration can be provided at initialization
const viewer = new MirajViewer('canvasContainer', {
    enableLoadingSpinner: false,
    enableLoadingBar: false,
});

// or updated later, updates will only affect provided options
// i.e. enableLoadingSpinner remains false after this update
viewer.updateConfig({
    enableLoadingBar: true
});

// setting `reset` to `true` will reset config to default values
// and apply the provided changes on top
// enableLoadingBar will set to provided value (false)
// while everything else (including api_url) would be reset to defaults
viewer.updateConfig({
    enableLoadingBar: false,
}, true);

Note on MirajViewer.loadManged

It's important to note that loadManaged loads all cameraControllerConfig config options from the server, and applies those changes on top of existing config.

This is done because the options like intial camera position, camera constraints, etc. are usually set per model in Bubo² web application.

Thus, any programmatic config changes to those properties should be done after loadManaged starts loading the model.

// if we set any `cameraControllerConfig` options prior to calling `loadManaged`
viewer.updateConfig({
    cameraControllerConfig: {
        distance: 50,
    }
});
// prints 50
console.log(viewer.config.cameraControllerConfig?.distance);
await viewer.loadManaged('9pcqOx');
// those options will be overwritten by the values associated with the loaded model
// prints value fetched from server
console.log(viewer.config.cameraControllerConfig?.distance);

Another thing to note is that loadManaged is an async function which will resolve once the config and markers have been loaded from the server, and the model file loading started. Thus any cameraControllerConfig options set before loadManaged resolves could be overwritten.

// without await, updateConfig will execute before loadManaged
// fetches model associated config from the server, and thus
// config will be overwritten with the server
viewer.loadManaged('9pcqOx').then(() => {
  // prints value fetched from server (second)
  console.log(viewer.config.cameraControllerConfig?.distance);
});
viewer.updateConfig({
    cameraControllerConfig: {
        distance: 50,
    }
});
// prints 50 (first)
console.log(viewer.config.cameraControllerConfig?.distance);

The correct approach is to update configs after loadManaged is done.

// use either `.then`, or `await`
viewer.loadManaged('9pcqOx').then(() => {
  // config is now updated with values from the server
  // and we can override distance
  viewer.updateConfig({
      cameraControllerConfig: {
          distance: 50,
      }
  });
  // prints 50
  console.log(viewer.config.cameraControllerConfig?.distance);
});

Configuration Options Table

MirajConfig

Configuration options for the MirajViewer:

| Option | Type | Default | Description | |--------|------|---------|-------------| | apiUrl | string | 'https://api.bubo2.com/api' | The base URL for the API | | cameraControllerConfig | OrbitControllerConfig | {} | Camera controller configuration | | enableLoadingSpinner | boolean | true | Show loading spinner during model loads | | enableLoadingBar | boolean | true | Show loading progress bar during model loads |

OrbitControllerConfig

Camera controller configuration options:

| Option | Type | Default | Description | |--------|------|---------|-------------| | target | [number, number, number] | [0, 0, 0] | Camera orbit target point | | flipUpDirection | boolean | true | Camera up direction (-Y if true, +Y if false) | | horizontalAngle | number | 0 | Horizontal orbit angle (degrees) | | verticalAngle | number | 90 | Vertical orbit angle (degrees) | | distance | number | 20 | Distance from camera to target | | minDistance | number | 0.1 | Minimum allowed camera distance | | maxDistance | number | 100 | Maximum allowed camera distance | | minVerticalAngle | number | 1 | Minimum vertical angle (degrees) | | maxVerticalAngle | number | 179 | Maximum vertical angle (degrees) | | rotationSpeed | number | 1 | Orbit rotation speed multiplier | | zoomSpeed | number | 1 | Zoom speed multiplier | | panSpeed | number | 1 | Pan speed multiplier | | enableZoom | boolean | true | Enable zooming | | enablePan | boolean | true | Enable panning | | enableOrbit | boolean | true | Enable orbiting |

Position Constraints

Optional limits for target and camera positions:

| Option | Type | Default | Description | |--------|------|---------|-------------| | targetMinX, targetMaxX | number | undefined | Target X-axis limits | | targetMinY, targetMaxY | number | undefined | Target Y-axis limits | | targetMinZ, targetMaxZ | number | undefined | Target Z-axis limits | | positionMinY, positionMaxY | number | undefined | Camera Y-axis position limits |

Tracking model load progress

Every call to viewer.load(...) or viewer.loadManaged(...) emits a consistent set of lifecycle events:

  • loadstart (once) – a new load begins
  • loadprogress (0..many) – incremental progress (progress in [0, 1); it will never be exactly 1.0)
  • loadcomplete – load finished successfully
  • loaderror – load failed
  • loadcancel – load was cancelled by a new one or unload

All events include a monotonically increasing requestId. Use it to ignore stale events if needed.
source is the URL (or 'memory' for in‑memory data).
progress is a float; convert to percent with progress * 100.

Simple logging example:

const viewer = new MirajViewer('viewerContainer');

viewer.addListener('loadstart', (e) => {
  console.log(`Loading started (#${e.requestId}): ${e.source}`);
});

viewer.addListener('loadprogress', (e) => {
  console.log(`Progress (#${e.requestId}): ${(e.progress * 100).toFixed(1)}%`);
});

viewer.addListener('loadcomplete', (e) => {
  console.log(`Completed (#${e.requestId}) – splats: ${e.totalSplats ?? 'n/a'}`);
});

viewer.addListener('loaderror', (e) => {
  console.error(`Error (#${e.requestId}):`, e.error);
});

viewer.addListener('loadcancel', (e) => {
  console.log(`Cancelled (#${e.requestId})`);
});

// Start a load (this may cancel an earlier one)
viewer.load('/example_splat.ply', FileFormat.PLY);

Notes:

  • Calling viewer.load(...) while another load is active triggers loadcancel for the previous one before the new loadstart.
  • For managed models (loadManaged), markers and camera options are applied first; the lifecycle events cover the actual file download & parse phase.
  • loadprogress stops before 1.0; final completion is signaled by loadcomplete.

Working with Markers

Markers are lightweight HTML elements anchored to 3D positions. They always face the screen and auto‑reposition each render.

Adding a marker

const marker = viewer.addMarker({
  position: [0, 0, 0],
  userData: { tag: 'center' }
});
console.log(marker.data.id);

Removing markers

viewer.removeMarker(marker);          // by Marker instance
viewer.removeMarker(marker.data.id);  // by id

Current markers vs markerData

  • viewer.markers → live Marker objects (with .element and .data)
  • viewer.markerData → serialized data only (no DOM)
  • Assigning viewer.markerData = [...] replaces all existing markers:
viewer.markerData = [
  { position: [1, 0, 0] },
  { position: [-1, 0, 0], userData: { label: 'Left' } }
];

Styling

Each marker root element has class miraj-marker which you can override in your styles.

.miraj-marker {
  width: 18px;
  height: 18px;
  background: #ff0;
  border: 2px solid #333;
  border-radius: 50%;
  cursor: pointer;
}

Or you can add classes to markers programmatically:

for (const m of viewer.markers) {
  m.element.classList.add('my-marker');
}

Click‑to‑add-marker example

viewer.on('click', (e) => {
  const world = viewer.getWorldPositionAt(e.canvasPos);
  if (!world) return;
  if (e.leftClick) {
    viewer.addMarker({ position: world });
  } else if (viewer.markers.length) {
    // Remove nearest (simple demo)
    let nearest = viewer.markers[0];
    let best = Infinity;
    for (const m of viewer.markers) {
      const [x,y,z] = m.data.position;
      const dx = x - world[0], dy = y - world[1], dz = z - world[2];
      const d = Math.hypot(dx, dy, dz);
      if (d < best) { best = d; nearest = m; }
    }
    if (best < 2) viewer.removeMarker(nearest);
  }
});

Marker events

// This is a good place for customizing marker content
viewer.on('markeradd', (e) => {
  console.log('Added', e.marker.data.id);
  e.marker.element.title = e.marker.data.id;

  event.marker.element.style.backgroundColor = 'yellow';
  const idElement = document.createElement('p');
  idElement.innerText = marker.data.id;
  idElement.style.margin = '0';
  idElement.style.marginTop = '-2px';
  idElement.style.marginLeft = '25px';
  marker.element.appendChild(idElement);
});

viewer.on('markerclick', (e) => {
  // Toggle color
  const el = e.marker.element;
  el.style.backgroundColor = el.style.backgroundColor === 'yellow' ? 'blue' : 'yellow';
});

viewer.on('markerpointerenter', (e) => {
  e.marker.element.classList.add('miraj-marker--pulse');
});
viewer.on('markerpointerleave', (e) => {
  e.marker.element.classList.remove('miraj-marker--pulse');
});

viewer.on('markerremove', (e) => {
  console.log('Removed', e.markerId);
});

Event summary:

  • markeradd – a marker was created
  • markerremove – a marker was removed
  • markerclick – user clicked a marker
  • markerpointerenter / markerpointerleave – hover transitions

Replacing all markers after a managed load

Managed loads set markers from the server. To override:

await viewer.loadManaged('modelId');
viewer.markerData = []; // clear
viewer.addMarker({ position: [0,0,0] });

Tip: Call viewer.markerData to snapshot current markers (e.g. to persist) then restore later by assigning it back.

Tips and tricks

Enable / disable user interactions

You can toggle zoom, pan, and orbit separately at runtime:

viewer.updateConfig({
  cameraControllerConfig: {
    enableZoom: false,
    enablePan: true,
    enableOrbit: true,
  }
});

// Re‑enable all
viewer.updateConfig({
  cameraControllerConfig: {
    enableZoom: true,
    enablePan: true,
    enableOrbit: true,
  }
});

Keep camera above the ground

Prevent the camera from dipping below an imaginary ground plane (y = 0):

viewer.updateConfig({
  cameraControllerConfig: {
    // never go below ground (up is negative Y, so we limit on MaxY, not MinY)
    positionMaxY: 0,
  }
});

Keep the model in view (limit orbit target)

Clamp the orbit target so users cannot drag the model out of frame (values need to be adjusted per model):

viewer.updateConfig({
  cameraControllerConfig: {
    targetMinX: -5,
    targetMaxX:  5,
    targetMinY:  0,
    targetMaxY:  4,
    targetMinZ: -6,
    targetMaxZ:  6
  }
});

Limit zoom distance so camera isn't too close/far

viewer.updateConfig({
  cameraControllerConfig: {
    minDistance: 20,
    maxDistance: 50
  }
});

Combining everything

viewer.updateConfig({
  cameraControllerConfig: {
    enableZoom: true,
    enablePan: true,
    enableOrbit: true,
    positionMinY: 0,
    targetMinX: -5, targetMaxX: 5,
    targetMinY: 0,  targetMaxY: 4,
    targetMinZ: -6, targetMaxZ: 6,
    minDistance: 20, maxDistance: 50
  }
});

Reminder: If you use loadManaged, the server can override cameraControllerConfig. Apply these constraints after loadManaged resolves viewer.loadManaged(...).then(() => { viewer.updateConfig(...); });


Programmatic Camera Controls

Beyond user input, you can drive the camera directly through viewer.controller (an OrbitController instance).

controller.orbitBy(dhDeg, dvDeg);          // relative orbit in degrees
controller.setOrbit(horizontal?, vertical?); // absolute angles (omit to keep)
controller.panBy(dx, dy, dz);              // world-space pan
controller.panByScreen(dxPx, dyPx);        // screen-space pan (pixels, like drag)
controller.zoomBy(factor);                 // factor > 1 zooms in (closer)
controller.setDistance(distance);          // absolute distance (clamped)
controller.setTarget([x,y,z]);             // move orbit center

Examples

// Orbit right by 10°
viewer.controller.orbitBy(10, 0);

// Orbit up by 10°
viewer.controller.orbitBy(0, 10);

// Look from 45° horizontal, 60° vertical
viewer.controller.setOrbit(45, 60);

// Only change vertical angle
viewer.controller.setOrbit(undefined, 30);

Pan screen-style (e.g. arrow keys):

window.addEventListener('keydown', e => {
  const stepPx = 40;
  if (e.key === 'ArrowLeft')  viewer.controller.panByScreen(-stepPx, 0);
  if (e.key === 'ArrowRight') viewer.controller.panByScreen(stepPx, 0);
  if (e.key === 'ArrowUp')    viewer.controller.panByScreen(0, -stepPx);
  if (e.key === 'ArrowDown')  viewer.controller.panByScreen(0, stepPx);
});

Constraint‑Aware Controls (using can* Queries)

Each mutating method has a corresponding predicate so you can:

  • Disable UI buttons
  • Provide proper affordances when limits reached

Query helpers:

controller.canOrbitBy(dh, dv);
controller.canSetOrbit(h?, v?);
controller.canPanBy(dx, dy, dz);
controller.canPanByScreen(dxPx, dyPx);
controller.canZoomBy(factor);
controller.canSetDistance(distance);
controller.canSetTarget([x,y,z]);

Example – enabling / disabling toolbar buttons:

function updateButtons() {
  btnOrbitLeft.disabled  = !c.canOrbitBy(-10, 0);
  btnOrbitRight.disabled = !c.canOrbitBy(10, 0);
  btnOrbitUp.disabled    = !c.canOrbitBy(0, -10);
  btnOrbitDown.disabled  = !c.canOrbitBy(0, 10);

  btnZoomIn.disabled  = !c.canZoomBy(1.2);
  btnZoomOut.disabled = !c.canZoomBy(0.8);

  btnPanLeft.disabled  = !c.canPanByScreen(-50, 0);
  btnPanRight.disabled = !c.canPanByScreen(50, 0);
  btnPanUp.disabled    = !c.canPanByScreen(0, -50);
  btnPanDown.disabled  = !c.canPanByScreen(0, 50);
}

const c = viewer.controller;
const allButtons = document.querySelectorAll('.camera-btn');
allButtons.forEach(b => b.addEventListener('click', () => {
  switch (b.id) {
    case 'orbit-left':  c.orbitBy(-10, 0); break;
    case 'orbit-right': c.orbitBy(10, 0); break;
    case 'orbit-up':    c.orbitBy(0, -10); break;
    case 'orbit-down':  c.orbitBy(0, 10); break;
    case 'zoom-in':     c.zoomBy(1.2); break;
    case 'zoom-out':    c.zoomBy(0.8); break;
    case 'pan-left':    c.panByScreen(-60,0); break;
    case 'pan-right':   c.panByScreen(60,0); break;
    case 'pan-up':      c.panByScreen(0,-60); break;
    case 'pan-down':    c.panByScreen(0,60); break;
  }
}));
viewer.camera.on('change', updateButtons);
updateButtons();