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

@precession/react-native

v0.9.1

Published

React Native SDK for embedding Retor 3D experiences

Downloads

19

Readme

@precession/react-native

Embed Retor 3D experiences in a React Native / Expo app — with composable bottom-sheet UI built on @gorhom/bottom-sheet.

Installation

# Expo
npx expo install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg expo-blur
npm install @gorhom/bottom-sheet lucide-react-native @precession/react-native

# bare React Native
npm install react-native-webview react-native-gesture-handler react-native-reanimated react-native-svg @gorhom/bottom-sheet lucide-react-native expo-blur @precession/react-native
cd ios && pod install

expo-blur is optional — it's used for the default blurred sheet background. Skip it if you don't want blur and pass a custom backgroundComponent to any sheet.

You also need to wrap your app root in a GestureHandlerRootView (per @gorhom/bottom-sheet requirements):

import { GestureHandlerRootView } from "react-native-gesture-handler";

export default function App() {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <YourApp />
    </GestureHandlerRootView>
  );
}

Quick Start — Default UI

The fastest way: drop in <Hud> with the three sheets and they'll work out of the box.

import { View } from "react-native";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet } from "@precession/react-native";

export default function Scene() {
  return (
    <View style={{ flex: 1 }}>
      <Viewer projectId="abc123" apiKey="pk_your_embed_key">
        <Hud>
          <ProjectSheet />
          <LineDetailSheet />
          <AddNoteSheet />
        </Hud>
      </Viewer>
    </View>
  );
}

apiKey is required. Create a publishable embed key in your project's Share → SDK embed keys section. Without it the viewer won't load.

What you get:

  • A bottom sheet showing the project name and a horizontal carousel of lines
  • Tap a line → second sheet opens (minimized) with the tag list; tap the header to expand
  • Tap a tag → camera scrolls to it
  • Call controls.exitLine() (e.g. from a renderFooter button) to return to the browse sheet

Concepts

  • <Viewer> wraps the WebView and exposes scene state via React context. Always vanilla — Retor itself shows no UI.
  • <Hud> sets up BottomSheetModalProvider. Place sheets and other overlays inside.
  • Sheets auto-present based on bridge state:
    • <ProjectSheet> shows when no line is open
    • <LineDetailSheet> shows when a line is open
    • <AddNoteSheet> shows when useAddNote().open() is called
  • Composition components (<LinesCarousel>, <LineTagList>) take a render prop so you can replace the visuals while keeping the data wiring.

Customising the visuals

Each sheet accepts:

  • snapPoints — override the default snap points
  • renderHeader — replace the header
  • children — replace the body (typically a render-prop list)

<LineDetailSheet> additionally accepts:

  • renderFooter(line, controls) — render a fixed footer pinned to the bottom of the sheet (sticks above scrolling content). Wire it to controls.exitLine(), controls.toggleAutoplay(), etc. If omitted, no footer is shown — sheets now open minimized, so provide your own footer if you want a persistent action.
import { Pressable, Text } from "react-native";
import { ProjectSheet, LinesCarousel, LineDetailSheet, LineTagList, useViewer } from "@precession/react-native";

function MyLineCard({ line }: { line: RetorLine }) {
  const { openLine } = useViewer();
  return (
    <Pressable
      onPress={() => openLine(line._id)}
      style={{ width: 200, padding: 16, backgroundColor: "#222", borderRadius: 16 }}
    >
      <Text style={{ color: "white", fontWeight: "600" }}>{line.name}</Text>
    </Pressable>
  );
}

<ProjectSheet snapPoints={["20%", "50%"]}>
  <LinesCarousel>
    {(line) => <MyLineCard line={line} />}
  </LinesCarousel>
</ProjectSheet>

<LineDetailSheet>
  <LineTagList>
    {(tag, isActive) => (
      <Pressable style={{ padding: 12 }}>
        <Text style={{ color: isActive ? "white" : "gray" }}>{tag.name}</Text>
      </Pressable>
    )}
  </LineTagList>
</LineDetailSheet>

Notes

<AddNoteSheet> triggers when you call useAddNote().open(tagId?). It collects text + private/public, then either:

  • calls onNoteSubmit on the parent <Viewer> (if set) — persistence is your responsibility
  • or falls back to persisting via Retor's own backend (Convex) when no onNoteSubmit is provided

Re-pass saved notes back to the 3D scene via <Notes>.

Enabling notes

Whether the add-note "+" button shows is gated per-line by each line's notesSupported flag (toggled in the Retor editor). Override this for every line with the notesEnabled prop on <Viewer>:

  • omitted (default) — respect each line's own notesSupported flag
  • notesEnabled={true} — force notes on for all lines
  • notesEnabled={false} — force notes off for all lines

The resolved value is available as useAddNote().enabled if you build your own add-note affordance.

Note fields for proper rendering

When passing notes via <Notes>, each note should include:

| Field | Required | Purpose | |-------|----------|---------| | _id | yes | Unique identifier | | name | yes | The note text (displayed in the tag list and 3D scene) | | position | yes | { x, y, z } — where the note sits on the line | | objectId | yes | Set to the lineId so the note associates with the correct line | | progress | yes | 0..1 position along the line (from the submit payload) — used for sort order and scroll-to | | avatarUrl | recommended | Profile image URL — renders as a circular avatar in the tag pill and list item | | authorName | recommended | Display name — used as initial-letter fallback when no avatarUrl | | tagType | recommended | Set to "icon" for the standard note pill appearance | | userId | for deletion | The note author's user ID — compared against Viewer.userId to show the delete button |

Creating + deleting notes

import { useState } from "react";
import { Viewer, Hud, ProjectSheet, LineDetailSheet, AddNoteSheet, Notes, type RetorTag } from "@precession/react-native";

export default function Scene() {
  const [notes, setNotes] = useState<RetorTag[]>([]);
  const currentUserId = "user_abc"; // your auth system's user ID

  return (
    <Viewer
      projectId="abc123"
      userId={currentUserId}
      onNoteSubmit={({ text, isPrivate, lineId, position, progress }) => {
        if (!position) return;
        setNotes((prev) => [
          ...prev,
          {
            _id: `note-${Date.now()}`,
            name: text,
            position,
            progress,
            objectId: lineId ?? undefined,
            tagType: "icon",
            avatarUrl: "https://example.com/avatar.jpg",
            authorName: "Jane",
            userId: currentUserId,
          },
        ]);
      }}
      onNoteDelete={(noteId) => {
        setNotes((prev) => prev.filter((n) => n._id !== noteId));
        // also delete from your backend
      }}
    >
      <Notes notes={notes} />
      <Hud>
        <ProjectSheet />
        <LineDetailSheet />
        <AddNoteSheet />
      </Hud>
    </Viewer>
  );
}

When onNoteSubmit is not provided, the SDK sends the note to Retor's backend automatically (using the signed-in Clerk session inside the WebView). No <Notes> re-injection needed in that case.

When a user deletes a note, the SDK:

  1. Optimistically removes it from the local tag list
  2. Fires onNoteDelete(noteId) so you can delete from your backend

Cursors

<Cursors> is a sentinel (sibling to <Notes>) that renders avatar pins in the 3D scene at arbitrary lat/lng — use it for "you are here" markers, teammates, ghosts, etc. It draws nothing itself; it just pushes the data into the scene.

import { Viewer, Cursors, type CursorEntry } from "@precession/react-native";

const me: CursorEntry[] = [
  { id: "self", lat: 63.42, lng: 10.39, avatarUrl: meUrl, color: "#ef4444", pulse: true },
];
const team: CursorEntry[] = [
  { id: "u_jane", lat: 63.4205, lng: 10.391, initial: "J", color: "#22d3ee" },
];

<Viewer projectId="abc123">
  <Cursors data={me} />
  <Cursors data={team} onCursorTap={(id) => openTeammateSheet(id)} />
</Viewer>;
  • Multiple <Cursors> coexist — entries are merged by id across instances (later wins), so you can keep self / ghost / team in separate sets.
  • elevationM is optional; when omitted the pin snaps to the scene-surface elevation at lat/lng.
  • onCursorTap(id) fires when a pin is tapped — id is the CursorEntry.id you set, so route it to your own data (open a sheet, focus the camera, etc.). Each <Cursors> instance's handler receives taps for its own entries.

Display mode

Switch what every object draws — its Gaussian splat, point cloud, or reconstructed surface mesh with a chosen material — via a single display-mode enum:

| Mode | Draws | |------|-------| | "splat" | Gaussian splat | | "pointcloud" | point cloud | | "mesh_normal" | surface mesh, matcap clay material | | "mesh_sun" | surface mesh, sun/shade study (see below) | | "mesh_elevation" | surface mesh, elevation isolines | | "mesh_heatmap" | surface mesh, slope heatmap (flat = blue, steep = red) |

// Declarative
<Viewer projectId="abc123" displayMode={isMeshView ? "mesh_sun" : "splat"} />

// Imperative
const { setDisplayMode } = useViewer();
setDisplayMode("mesh_elevation"); // null → restore the project's saved visibility + material

It's per-viewer and non-destructive — it overrides what this viewer draws without changing the project's saved visibility/material, so other viewers and the editor are unaffected. mesh_* modes need the object to actually have a mesh.

Sun position (shade material)

mesh_sun lights the terrain from the sun at a given date + time (computed from the project's GPS). Override it for this viewer at runtime — handy for a time-of-day slider:

const { setSunDate } = useViewer();
setSunDate(new Date("2026-06-21T15:00:00")); // Date or epoch-ms; null → project's saved sun time

Date drives the seasonal sun height; the clock time is treated as local solar time (noon = highest).

Compass HUD

Drop a <HudCompass> over the viewer to show a tilted mini-map dial that points to GPS north (red = north, white = south) and spins as the user rotates the scene:

import { Viewer, HudCompass } from "@precession/react-native";

<View style={{ flex: 1 }}>
  <Viewer projectId="abc123" />
  <HudCompass offset={{ x: 0, y: 80 }} />
</View>

The heading is streamed from the viewer over the bridge; useHeading() exposes it directly if you'd rather build your own indicator. Both need a georeferenced project (GPS reference tags).

Tap to toggle top-down. Tapping <HudCompass> switches the viewer between 3D and a top-down / map camera that looks straight down the ground plane — and, while on a line, straight down over the scroll cursor (so it follows as you scroll). The dial lies flat in top-down and tilts back in 3D. You can also drive it directly with controls.setTopDown(true | false).

Location & elevation

useViewer().controls (and useViewer() directly) expose two async resolvers backed by the project's reconstructed surface. Both return Promises (the work happens inside the embedded viewer and comes back over the bridge).

const viewer = useViewer();

// Resolve a raw GPS reading to ranked candidate positions on the model surface.
const { candidates } = await viewer.getLocation({
  lineId: activeLineId,
  lat, lng, elev,
  rawAccuracy: { horizontalM: 12 },
  previousTagId,            // optional prior — favours candidates near it
});
const best = candidates[0]; // highest confidence (educated guess)

// Surface elevation (meters) at a point — null when the surface has no coverage there.
const elevationM = await viewer.getElevationAt(lat, lng);
  • getLocation treats raw GPS as noisy: candidates are positions on the model surface, not snapped to the line. When lineId is provided, line-adjacent candidates rank higher. candidates[0] is a sensible default; re-rank with your own heuristics (velocity, history) if you like. Each candidate carries closestTag, closestPointOnLine, per-axis errors, and a confidence (0..1).
  • getElevationAt is overhang-safe (cave/overhang aware) and returns null where the surface isn't reconstructed.

These require the project to be georeferenced (coordinate tags) and to have a reconstructed surface mesh. getLocation still returns a degraded line-based guess when no mesh is available.

Hooks

All hooks read from the bridge context provided by the parent <Viewer>.

| Hook | Returns | |------|---------| | useProject() | The current RetorProject (or null) | | useLines() | Array of RetorLine | | useActiveLine() | The currently open line (or null) | | useLineProgress() | { progress, closestTagId } | | useAutoplay() | { isPlaying, toggle, play, pause } | | useAddNote() | { isOpen, tagId, open, close, submit } | | useHeading() | Live camera compass heading in degrees (0 = north, +clockwise), or null | | useViewer() | Imperative controls (openLine, exitLine, scrollToTag, scrollToProgress, setDisplayMode, setSunDate, getLocation, getElevationAt, ...) |

Imperative API

The useViewer hook also supports controlling a specific viewer by ID:

<Viewer id="left" projectId="..." />
<Viewer id="right" projectId="..." />

const left = useViewer("left");
left.openLine("line-a");

Or pass a ref directly:

const ref = useRef<ViewerHandle>(null);
<Viewer ref={ref} projectId="..." />
ref.current?.openLine("...");

Cover photo

A static thumbnail of a project's start view — no 3D, no bridge:

import { CoverPhoto } from "@precession/react-native";

<CoverPhoto projectId="abc123" style={{ width: 200, height: 120 }} />

License

MIT