Project Orinoco is a browser-native 3D visualization tool for exploring network telemetry. It projects selected traffic features into a three-dimensional Cartesian space, allowing analysts to inspect individual observations, navigate spatial relationships, and investigate clusters interactively.
The application visualizes network flow features within a symmetric, signed 3D Cartesian coordinate system — every axis runs through the origin, so data-zero is always visible regardless of whether the loaded dataset is all-positive, all-negative, or mixed. Analysts can navigate the environment, inspect individual data points, isolate a spatial octant for focused analysis, and dynamically adjust their exploration viewpoint through an interactive pivot system. Camera movement is always relative to the active pivot. Any CSV matching the expected shape (three numeric columns, one or two text columns) can be loaded directly in-browser — the bundled sample dataset itself now loads through this same CSV pipeline, rather than a separate hardcoded data path.
Demo Videos: Watch Orinoco MVP Walkthrough 7/8/2026 Watch Orinoco MVP Walkthrough 7/20/2026 Watch Orinoco Signed Grid & Octant Isolation Walkthrough
Example walkthrough:
- Navigate the 3D environment using keyboard and mouse controls
- Hover over threat nodes to inspect metadata
- Select nodes to change the active pivot location
- Isolate a spatial octant to focus on one corner of the data
- Load a new CSV dataset directly from the toolbar, with the grid, axis labels, and points all updating to match
Note: the screenshots below predate the signed-grid/octant-isolation rework (PR #42) and are pending an update. See the demo videos above for the current UI in the meantime.
(placeholder — screenshot to be retaken)
(placeholder — screenshot to be retaken)
Project Orinoco renders high-dimensional network features in an interactive WebGL environment using Three.js and React Three Fiber.
Every axis is symmetric and signed — the display range for each axis is always [-M, +M], where M is derived from the dataset's farthest value on that axis (plus a 10% margin, rounded outward). Data-zero therefore always sits at the exact center of the plotting volume, and both positive and negative values are visible regardless of the dataset's actual sign distribution. This replaces an earlier floor-anchored, open-face box design.
The reference frame is drawn in two parts:
CartesianGrid.tsx— a single horizontal grid plane positioned at data-zero's render height (the box center for the full grid), plus a faint full 12-edge wireframe box outlining the plotting volume for depth referenceAxes.tsx— the three bold coordinate axes, each running the full length of its axis through the origin with an arrowhead and a single column-name title at the positive end, plus adaptive tick marks
Each axis is independently scaled to the dataset's numeric range by default — tick labels always reflect real data-space values, and recompute automatically whenever a new dataset loads or the scaling mode changes (see Scaling Modes below).
Data dimensions (bundled sample dataset):
| Feature | Axis |
|---|---|
| invel_pps | Y |
| orig_bytes | X |
| invel_bpp | Z |
A loaded CSV's own column names replace these automatically — see Dynamic Dataset Loading below.
Analysts can load any CSV dataset directly in-browser via the toolbar's file picker, rather than being limited to the bundled sample. The parser (src/lib/parseCSV.ts) auto-detects columns instead of assuming fixed names:
- Each column is classified as numeric or text by sampling its values
- The three numeric columns are mapped to X/Y/Z, in the order they appear in the file's header row
- Text columns are mapped to a unique identifier (
uid) and a classification label (class) — the column with the highest ratio of unique values is treated as the identifier - Malformed files (wrong number of numeric columns, no text columns, empty file) fail with a specific, visible error message rather than silently producing bad output or crashing
- Rows with missing or invalid numeric values are skipped individually rather than failing the whole file — a banner reports which row numbers were excluded
Once a file loads successfully, the grid's scale, the axis labels, the Point Analysis HUD panel's metric labels, and the rendered points all update together — nothing in the visualization stays hardcoded to any one dataset.
No separate default-data code path: the bundled sample (sample-data/mixed-sign-sample.csv, 500 rows straddling zero on all three axes) is imported as raw CSV text via Vite's ?raw loader, wrapped in a File, and run through the exact same parseCSV → setDataPoints pipeline a user upload takes. The store starts with an empty dataset; App.tsx loads the sample on mount.
flowchart LR
A[User selects CSV via Toolbar paperclip] --> B[parseCSV.ts]
B --> C{Exactly 3 numeric columns found?}
C -- No --> D[Reject with specific error message]
D --> E[Error banner shown, previous dataset stays active]
C -- Yes --> F[Classify text columns: uid = most unique values, class = remaining column]
F --> G[setDataPoints in store]
G --> H[computeGridSpace recomputes scale, ranges, and octant isolation clears]
G --> I[axisLabels updated]
H --> J[Grid and points re-render]
I --> J
Known limitations (accepted for the current release):
- Column order in the source file determines which numeric column becomes X, Y, or Z — there is no manual override UI yet
- Only the first two text columns are used (identifier and classification); additional text columns are currently ignored
The visualization environment supports analyst-focused navigation.
| Input | Action |
|---|---|
| W | Move toward current pivot |
| S | Move away from current pivot |
| A | Orbit left around pivot |
| D | Orbit right around pivot |
| Left / Right | Move pivot along the X axis |
| Up / Down | Move pivot along the Z axis |
| Space / Shift | Raise / lower pivot along the Y axis |
| Mouse Drag | Free camera rotation, or pan (translate view), depending on the active tool set in the Toolbar |
| Mouse Hover | Inspect point metadata |
| Mouse Click | Set selected node as new pivot |
| Toolbar Reset | Return pivot to origin |
- Zoom limits — shared
MIN_ZOOM_DIST(0.15) /MAX_ZOOM_DIST(18), enforced both by OrbitControls' scroll dolly and the W/S keys, so the camera can neither pass through a point at close range in an unbounded way nor fly infinitely far out - Pivot bounds — the pivot cannot be moved more than 50% beyond the grid walls, clamped by a shared helper used by both arrow-key traversal and drag-panning, so rotation and the pivot marker stay in lockstep even when the clamp engages
- Stuck-key protection — OS screenshot shortcuts that don't deliver a
keyupwhile a modifier key is held (e.g. macOS Cmd+Shift+4) are detected and clear all held movement keys, so the camera can't drift indefinitely from a "stuck" key
Users can select any data node as an investigation reference point, or move the pivot directly with the arrow/space/shift keys.
When the pivot changes:
- The global pivot coordinate updates in the shared store
- The camera translates by the same offset that the pivot moved — rotation is preserved, so the new pivot lands at the same screen position the old one held rather than the camera whipping around to face it
- The tactical reticle (a six-armed cross) tracks the new position
- Analysts can explore nearby data relationships from the new vantage point
Lockstep marker tracking: the pivot marker is driven imperatively by CameraRig.tsx, in the same per-frame update as the camera itself, rather than being bound to React state. A state-driven marker lagged a frame behind the camera's own imperative movement, since store updates commit asynchronously relative to the render-frame loop — the imperative approach eliminates that lag entirely.
Isolating an octant (see below) also resets the pivot to the origin, since the old pivot was a coordinate in the pre-isolation mapping and no longer corresponds to the same data.
Interactive 3D events provide metadata inspection through the Heads-Up Display (HUD).
Displayed information includes:
- UID
- Classification
- XYZ coordinates
- Feature values, labeled with the active dataset's real column names
The interface uses a security operations center inspired design with high-contrast visualization and a glass-morphism HUD.
Current classification visualization (bundled sample dataset):
| Data Value | Color |
|---|---|
normal |
#dddddd |
nss |
#dd0000 |
qc |
#00dd00 |
zt |
#0000dd |
A loaded CSV's own class values inherit these colors where names match, and fall back to a default color for any unrecognized class.
The Grid page in the Toolbar controls how the reference grid and axes are presented and scaled.
- Show grid — toggles the wireframe box and grid plane; axis lines, tick marks, and labels remain visible regardless, since coordinate reference stays useful even without the box geometry
- Per-axis tick label visibility — each of the three axes' tick marks and numeric labels can be hidden independently
- Tick density — a slider (3–30, default 10) controlling how many tick marks appear per axis; the actual step size is rounded to a "nice" 1–2–5 number so labels stay human-readable. Tick marks and numbers scale per-frame by camera distance to hold a constant on-screen size rather than ballooning on approach
- Scaling modes — see below
computeGridSpace() accepts a ScalingConfig that determines each axis's half-extent (M):
| Mode | Behavior |
|---|---|
| Auto-normalized (default) | Per-axis M, derived independently from each axis's own farthest value. Good for comparing shape when magnitudes differ wildly across axes. |
| Auto-real scale | One shared M, derived from the single farthest value across all axes — a unit of data renders as the same length on every axis (true relative distances). Small-range axes bunch near the origin rather than filling the box. |
| Custom | A typed ± bound per axis; any axis left blank or invalid falls back to its auto-normalized value. |
Mode and bound changes recompute the grid live.
The Isolate page in the Toolbar lets an analyst focus on one spatial corner (octant) of the data.
A small 3D gizmo (OctantGizmo.tsx) — eight cubes in a wireframe outline — mirrors the main view's rotation in lockstep, so whichever cube occupies a given on-screen position is the octant currently facing the camera there.
| Action | Result |
|---|---|
| Click a cube | That octant enlarges to fill the entire grid; all points outside it are hidden |
| Click the same cube again | Reverts to the full grid (toggle) |
| Click inside the outline but off any cube | Reverts to the full grid |
| Click Reset | Reverts to the full grid |
Implementation: isolation is expressed purely as a change to each axis's display range — the selected octant's sides become one-sided ([0, M] or [-M, 0]) instead of the full [-M, M]. Since that range still maps onto the same fixed render box, the octant enlarges automatically and everything downstream follows for free: points reposition, tick numbers rescale and refine, and the axes reposition so data-zero sits at a box corner instead of the center. Points exactly at 0 on an isolated axis are assigned to the positive side, so the eight octants partition the dataset cleanly with no point counted twice.
Transition: because both the pre- and post-isolation mappings are the same affine transform ((value − CENTER) × SCALE), IsolationTransition.tsx re-renders children into the new mapping immediately, applies a compensating transform that puts everything back where it visually was, then eases that transform to identity over 450ms — a real zoom effect using one group scale/position per frame, with no instance-buffer re-upload, so it stays cheap at scale. The transition deliberately only animates octant changes; a new CSV load can rescale by orders of magnitude and would otherwise swoop unpredictably.
Camera sync: the gizmo reads a plain mutable quaternion (lib/cameraSync.ts), written by CameraRig every frame — deliberately not routed through the Zustand store, since a per-frame store write would re-render every store subscriber 60 times a second.
Isolation clears automatically whenever a new CSV loads, since an octant chosen against the old dataset says nothing about the new one.
A Blender-style docked side panel provides quick access to data and display controls, without needing to memorize keyboard shortcuts for everything.
Layout: an icon strip sits fixed between the 3D viewport and a resizable content pane, both docked to the screen's right edge. Dragging the border on the icon strip's viewport-facing side resizes the content pane open or shut — matching the interaction pattern of Blender's Properties editor.
Icon strip contents:
| Icon | Action |
|---|---|
| Paperclip | Opens the native file picker to load a CSV |
| Reset | Returns the pivot to the origin |
| Hand / Pointer | Switches the mouse-drag behavior between orbit (rotate around pivot) and pan (translate view) |
| Data | Opens a panel for class-visibility filtering, per-axis numeric filters, and point-size scaling |
| Grid | Opens a panel for grid visibility, tick labels, tick density, and scaling mode selection |
| Box | Opens the Isolate page (octant gizmo) — lights up while an octant is isolated |
Grid visibility was moved from a standalone icon-strip toggle into the Grid page itself, consolidating display settings into one place.
stateDiagram-v2
[*] --> Collapsed
Collapsed --> Open: click page icon or drag border open
Open --> Collapsed: click same page icon or drag border past close threshold
Open --> Open: click different page icon, width preserved
The Data page in the Toolbar supports narrowing down the visible point cloud:
- Class visibility filtering — individual classification categories can be hidden from the render, each shown with its assigned color swatch
- Per-axis numeric filters — points can be filtered by value range on any of the three axes, using an operator dropdown (>, ≥, <, ≤, =, between) plus one or two value boxes
- Point-size scaling — point size adjusts automatically based on dataset density (denser datasets get smaller points so they don't merge into a blob), with a manual multiplier slider and one-click reset to the automatic size
Rendering is done as a single instanced mesh, so filtering by class, numeric range, or octant isolation doesn't remount or reallocate anything — hidden points are simply excluded from the draw call.
Project Orinoco separates rendering, application state, and interface responsibilities.
Project Orinoco follows a separation-of-concerns architecture:
- React manages application structure and UI
- React Three Fiber manages 3D visualization
- Zustand manages shared interaction state, including the active dataset
- Data sources remain independent from rendering logic — any dataset matching the expected shape can be loaded without touching rendering components
This architecture allows the visualization engine to evolve as new threat datasets become available.
flowchart TB
subgraph UI["2D HUD Layer - HTML/Tailwind"]
Toolbar
HUD["Point Analysis / Legend / Control Guide"]
end
subgraph R3F["3D Layer - React Three Fiber"]
CartesianGrid
Axes
PointCloud
OctantGizmo
CameraRig
end
Store[("Zustand Store: pivot, hoveredPoint, dataPoints, gridSpace, axisLabels, gridVisible, activeTool, hiddenClasses, numericFilters, hiddenTickAxes, scalingMode, customBounds, isolatedOctant, tickDensity")]
Toolbar -- "setDataPoints / setScalingMode / setIsolatedOctant" --> Store
PointCloud -- "setHoveredPoint / setPivot" --> Store
CameraRig -- "setPivot" --> Store
Store -- "dataPoints, gridSpace" --> PointCloud
Store -- "gridSpace, axisLabels" --> Axes
Store -- "gridSpace" --> CartesianGrid
Store -- "isolatedOctant" --> OctantGizmo
Store -- "hoveredPoint, axisLabels" --> HUD
The application renders two separate layers stacked on top of each other: a flat 2D HTML/Tailwind layer (branding, HUD panels, legends, the toolbar) and a 3D <Canvas> layer beneath it (the navigable scene). These are two independent React trees — the HTML layer isn't a child of the Canvas, and neither has a direct reference to the other.
The 2D layer uses pointer-events-none so mouse clicks pass through it into the 3D scene, except where a specific HUD element (like the toolbar) opts back in. Because the two trees can't pass props to each other directly, they communicate exclusively through the shared Zustand store: a pointer event inside the Canvas (e.g. hovering a data point in PointCloud.tsx) updates the store, and the HTML layer (in App.tsx) reacts to that same store value to update the HUD — with neither component needing to know the other exists.
Note on the toolbar's z-index: React Three Fiber's <Canvas> wrapper renders after the HUD overlay in the DOM and establishes its own stacking context. Without an explicit z-index on the toolbar, the Canvas visually paints on top of it despite looking identical to the background — silently intercepting clicks meant for the toolbar. The toolbar is deliberately given a higher z-index than the HUD overlay to prevent this.
Given the two-layer model above, some mechanism is needed to synchronize state between the 3D scene and the 2D HUD. Zustand was chosen over React Context or Redux for a few reasons:
- No
<Provider>wrapper required — any component calls theuseStorehook directly - Components subscribe to only the specific state slice they need (e.g.
state => state.pivot), so a change to one field doesn't cause unrelated components to re-render - Minimal boilerplate compared to Redux's actions/reducers/dispatch pattern, appropriate for the amount of shared state this application needs
Exceptions to the store-driven pattern: the pivot cross marker is driven imperatively by CameraRig.tsx via a ref, not by reading pivot from the store (see Dynamic Pivot System above). Similarly, the Isolate gizmo's camera-mirroring reads a plain mutable quaternion written every frame by CameraRig (lib/cameraSync.ts), deliberately outside Zustand, since a per-frame store write would re-render every subscriber 60 times a second.
Earlier, computeGridSpace() anchored each axis at its data minimum, adding a center reference line only for datasets that introduced negative values. The current model instead always returns a symmetric [-M, +M] range per axis, where M is derived from the farthest value on that axis (plus a margin, rounded outward) — so data-zero always sits at a known, consistent position (the box center for the full grid, a box edge or corner when an octant is isolated), regardless of the dataset's actual sign distribution. This removed the need for a separate "center axis" toggle and a "zero plane" mode switch — the zero-crossing reference is simply always drawn.
@react-three/drei ships a generic Grid helper — a flat, infinite floor-plane grid intended for general 3D scene reference (e.g. a game editor's floor). It doesn't support bounded dimensions, per-axis symmetric ranges, octant-based partial ranges, or tick marks/axis labels tied to specific data ranges. CartesianGrid.tsx and Axes.tsx were built as custom components instead, giving full control over the plane position, wireframe box, and tick/label placement.
Each axis's display range is computed by computeGridSpace() in src/lib/gridSpace.ts, a pure function re-run whenever the active dataset, scaling mode, custom bounds, or isolated octant changes (see useStore.ts's gridSpaceFor() helper, which centralizes every input this computation depends on so no dependency can be silently missed). gridSpace.ts's output — DISPLAY_RANGE, SCALE, CENTER, ZERO_RENDER, and toRenderSpace — is the single source of truth shared by CartesianGrid.tsx (box/plane geometry), Axes.tsx (ticks/labels), and PointCloud.tsx (point positioning and octant filtering via inOctant()), so the three can never drift out of sync with each other.
orinoco/
├── docs/
│ ├── images/
│ │ ├── main.png
│ │ └── data_info.png
│ └── video/
│ └── orinoco-demo.mp4
│
├── test-data/
│ ├── test-1k.csv
│ │ └── Synthetic 1,000-row dataset for load/render testing
│ ├── test-10k.csv
│ │ └── Synthetic 10,000-row dataset for performance testing
│ └── test-malformed.csv
│ └── Deliberately invalid CSV (wrong column count) for
│ exercising parseCSV.ts's error handling
│
├── sample-data/
│ └── mixed-sign-sample.csv
│ └── 500-row mixed positive/negative sample — now the
│ bundled default dataset, loaded via the CSV pipeline
│
├── src/
│ ├── components/
│ │ ├── Axes.tsx
│ │ │ └── Bold coordinate axes through the origin, tick
│ │ │ marks/labels, and axis titles
│ │ │
│ │ ├── CartesianGrid.tsx
│ │ │ └── Horizontal grid plane at data-zero + full
│ │ │ 12-edge wireframe box
│ │ │
│ │ ├── CameraRig.tsx
│ │ │ └── WASD + arrow/space/shift navigation, drag-panning,
│ │ │ zoom/pivot guardrails, and imperative per-frame
│ │ │ tracking of the pivot marker and camera-sync quaternion
│ │ │
│ │ ├── OctantGizmo.tsx
│ │ │ └── The Isolate page's rotating cube-of-cubes control
│ │ │
│ │ ├── IsolationTransition.tsx
│ │ │ └── Animates the affine transform between full-grid and
│ │ │ isolated-octant mappings
│ │ │
│ │ ├── PointCloud.tsx
│ │ │ └── Threat data rendering and interaction — reads the
│ │ │ active dataset and grid geometry from the store
│ │ │
│ │ └── Toolbar.tsx
│ │ └── Docked, resizable side panel: CSV loader, origin
│ │ reset, Data/Grid/Isolate pages
│ │
│ ├── lib/
│ │ ├── gridSpace.ts
│ │ │ └── computeGridSpace() — derives symmetric plotting
│ │ │ bounds, scaling, and octant-isolated ranges
│ │ ├── cameraSync.ts
│ │ │ └── Mutable camera-orientation quaternion shared between
│ │ │ CameraRig and OctantGizmo, outside the store
│ │ ├── classColors.ts
│ │ │ └── Single source of truth for classification → color mapping
│ │ └── parseCSV.ts
│ │ └── Auto-detecting CSV parser — classifies columns,
│ │ maps them to X/Y/Z/uid/class, validates and reports errors
│ │
│ ├── store/
│ │ └── useStore.ts
│ │ └── Global visualization state: pivot, hoveredPoint,
│ │ dataPoints, gridSpace, axisLabels, gridVisible,
│ │ activeTool, hiddenClasses, numericFilters,
│ │ hiddenTickAxes, scalingMode, customBounds,
│ │ isolatedOctant, tickDensity
│ │
│ ├── types.ts
│ │ └── Shared DataPoint interface, used by the store, parser,
│ │ and grid math so the shape is defined exactly once
│ │
│ ├── App.tsx
│ │ └── Application shell, Canvas, HUD, CSV-load orchestration,
│ │ and mount-time loading of the bundled sample via the
│ │ CSV pipeline
│ │
│ ├── main.tsx
│ │ └── React entry point
│ │
│ ├── vite-env.d.ts
│ │ └── Type declarations enabling the `?raw` CSV import
│ │
│ └── index.css
│ └── Tailwind CSS configuration
│
├── vite.config.ts
└── package.json
The application follows this interaction model:
User Interaction
|
↓
React Three Fiber Events
|
↓
Zustand Global State
|
↓
HUD Updates
Example — hovering a point:
- User hovers over a threat node
- R3F pointer event captures the interaction
- Metadata updates the Zustand store
- The HUD displays point information
Example — loading a CSV:
- User clicks the toolbar's paperclip icon and selects a file
parseCSV.tsclassifies columns and validates the shape- On success,
setDataPointsreplaces the dataset, clears any isolated octant, and recomputes grid geometry and axis labels atomically - The grid, axis labels, HUD panel, and rendered points all update to reflect the new dataset
Example — isolating an octant:
- User clicks a cube on the Isolate page's gizmo
setIsolatedOctantrecomputesgridSpacewith that octant's one-sided ranges and resets the pivot to the originIsolationTransitionanimates the affine remap over 450ms- Points outside the octant are filtered from the render via
inOctant(); ticks and axes rescale to the narrower range
Used for:
- Component architecture
- Type-safe application development
- UI state management
Used for:
- Fast development workflow
- Optimized production builds
- Raw-text CSV import (
?raw) for the bundled sample dataset
WebGL-based 3D rendering engine.
Provides a React-based interface for managing Three.js scenes.
Used features:
<Canvas />rendering environmentuseFrameanimation loop- Pointer interaction events
- 3D object components
- Scene and camera integration
- Instanced rendering (
InstancedMesh) for the point cloud, scaling to 100k+ points as a single draw call - Imperative refs (
pivotMarkerRef) for per-frame object updates outside the store-driven pattern
Provides reusable Three.js helpers:
- OrbitControls
- Billboard labels
- Text components
Used for low-latency synchronization between:
- 3D interaction events
- Camera pivot state
- The active dataset and its derived grid geometry
- HUD metadata
Managed state:
- Current pivot coordinates
- Hovered point information
- Active dataset (
dataPoints) - Derived grid geometry for the active dataset (
gridSpace) - Axis labels for the active dataset (
axisLabels) - Grid visibility (
gridVisible) - Active navigation tool — orbit or pan (
activeTool) - Distinct class names present in the active dataset (
availableClasses) - Class visibility and per-axis numeric filters (
hiddenClasses,numericFilters) - Per-axis tick label visibility (
hiddenTickAxes) - Active axis-scaling mode and custom bounds (
scalingMode,customBounds) - Currently isolated octant, if any (
isolatedOctant) - Tick-density target (
tickDensity)
CSV parsing library used by parseCSV.ts to read uploaded files. Paired with @types/papaparse for TypeScript type coverage, since the library doesn't ship its own type declarations.
Used for:
- HUD overlays
- Toolbar and panel styling
- Interface components
- Responsive styling
Rust-based linter used for:
- Fast code analysis
- Correctness checks
- Development consistency
The project runs OxLint through the configured npm lint script:
npm run lintIcon library used for interface elements, including the toolbar's icon strip (paperclip, reset, pan/orbit toggle, Data/Grid/Isolate page icons).
The application no longer bundles a static data.json. The default dataset (sample-data/mixed-sign-sample.csv, 500 rows with values straddling zero on all three axes) loads on mount through the same parseCSV → setDataPoints pipeline a user's own CSV upload takes — there is no separate hardcoded-data code path.
Loaded datasets, whether the bundled sample or a user CSV, share the same internal shape:
{
"uid": "C7mJzI2kJo1VmffDG6",
"x": 13816,
"y": 0.02753,
"z": 84.084211,
"className": "normal"
}Column-to-axis mapping is produced by parseCSV.ts's auto-detection — see Dynamic Dataset Loading.
Current visualization categories (bundled sample dataset):
| Data Value | Color |
|---|---|
normal |
#dddddd |
nss |
#dd0000 |
qc |
#00dd00 |
zt |
#0000dd |
Classification colors are defined in src/lib/classColors.ts, shared by both the point cloud rendering and the HUD legend so they can't drift out of sync. Any class value not present in this mapping (e.g. from a loaded CSV with new categories) falls back to a default color rather than failing.
| Tool | Version | Notes |
|---|---|---|
| Node.js | v22.12.0 or newer (v24.x tested) | Required by @react-three/drei's camera-controls dependency; see note below |
| npm | v10 or newer (bundled with Node) | Verify with npm -v |
| Git | any recent version | Required to clone the repo |
This project was developed and tested with Node v24.13.1 and npm 11.10.0.
- Windows: Official installer or nvm-windows
- macOS: nvm (
brew install nvm) or Homebrew (brew install node) - Linux: nvm or your distro's package manager
After installing, confirm your versions match the table above:
node -v
npm -vThe following commands are identical across PowerShell, Command Prompt, WSL/Git Bash, and macOS/Linux terminals.
git clone https://github.com/cwheelus/orinoco.git
cd orinoconpm installnpm run devBy default this serves the app at http://localhost:5173 (Vite's default dev port). On load, the app boots directly into the bundled mixed-sign sample dataset.
npm run buildnpm run lintSample CSV files for manually exercising the loader live in test-data/:
| File | Purpose |
|---|---|
test-1k.csv |
1,000-row synthetic dataset, for confirming normal load/render behavior at moderate scale |
test-10k.csv |
10,000-row synthetic dataset, for performance testing |
test-malformed.csv |
Deliberately invalid (wrong column count), for confirming parseCSV.ts's error handling surfaces correctly instead of failing silently |
The bundled default dataset lives in sample-data/:
| File | Purpose |
|---|---|
mixed-sign-sample.csv |
500-row sample with both positive and negative values on all three axes — loaded automatically on app start |
Load any of the test-data/ files through the toolbar's paperclip icon to try a different dataset.
Pulled directly from package.json
| Package | Version |
|---|---|
| react / react-dom | ^19.2.7 |
| typescript | ~6.0.2 |
| vite | ^8.1.1 |
| three | ^0.185.1 |
| @react-three/fiber | ^9.6.1 |
| @react-three/drei | ^10.7.7 |
| zustand | ^5.0.14 |
| tailwindcss | ^4.3.2 |
| papaparse | ^5.5.4 |
| @types/papaparse (dev) | ^5.5.2 |
| lucide-react | ^1.25.0 |
| oxlint (dev) | ^1.71.0 |
See the Issues tab for planned work and open feature requests.
Project Orinoco is a functional MVP demonstrating:
- Interactive 3D threat visualization with a symmetric, signed Cartesian grid
- Octant isolation with a camera-synced gizmo and animated transitions
- Three axis-scaling modes: auto-normalized, auto-real-scale, and custom bounds
- WASD camera navigation with dedicated arrow/space/shift pivot traversal, plus orbit and pan drag modes
- Navigation guardrails: zoom limits, pivot bounds, and stuck-key protection
- Dynamic pivot exploration with origin reset and lockstep marker tracking
- CSV-only dataset loading (no separate hardcoded default), with an auto-detecting parser and clear error handling for malformed or partially invalid files
- A docked, resizable toolbar for data loading, pivot reset, dataset filtering, and grid/scaling/isolation controls
- Instanced point rendering with count-adaptive sizing for large datasets
- Real-time metadata inspection, labeled with the active dataset's real column names
- SOC-style analyst interface
©2026 Sentient Solutions
Developers:
- Mark Yosinao
- Daniel Merced
Advisors:
- Eric Lloyd
- BC Ko