@ericchen1990/pano-view 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Eric Chen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,479 @@
1
+ # @ericchen1990/pano-view
2
+
3
+ Composable React components for equirectangular and krpano-style multiresolution panorama viewers.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install @ericchen1990/pano-view react react-dom three @react-three/fiber
9
+ ```
10
+
11
+ React 19, React DOM 19, `@react-three/fiber` 9, and Three.js are peer dependencies.
12
+
13
+ ## Sphere panorama
14
+
15
+ `PanoView` owns the canvas, perspective camera, and controls. Give its container an explicit size and place one panorama source inside it.
16
+
17
+ ```tsx
18
+ "use client";
19
+
20
+ import { PanoView, Sphere } from "@pano-view/react";
21
+
22
+ export function SphereExample() {
23
+ return (
24
+ <PanoView style={{ width: "100%", height: 560 }}>
25
+ <Sphere src="/panoramas/room.webp" />
26
+ </PanoView>
27
+ );
28
+ }
29
+ ```
30
+
31
+ `Sphere` expects a 2:1 equirectangular image. Use `yawOffset` when the source's forward direction needs horizontal adjustment.
32
+
33
+ ## Cube Tile panorama
34
+
35
+ `Tile` renders six inward-facing cube faces and loads only tiles around the current view. The default layout matches krpano-style output:
36
+
37
+ ```text
38
+ tiles/{face}/l{level}/{row}/l{level}_{face}_{col}_{row}.webp
39
+ ```
40
+
41
+ Faces are `f`, `r`, `b`, `l`, `u`, and `d`; rows and columns are 1-based.
42
+
43
+ ```tsx
44
+ import { PanoView, Tile } from "@pano-view/react";
45
+
46
+ export function TileExample() {
47
+ return (
48
+ <PanoView style={{ width: "100%", height: 560 }}>
49
+ <Tile
50
+ baseUrl="https://cdn.example.com/panoramas/room"
51
+ multires="512,500,1000,2000"
52
+ />
53
+ </PanoView>
54
+ );
55
+ }
56
+ ```
57
+
58
+ The first `multires` value is the tile size. Remaining values are ascending cube-face sizes for `l1`, `l2`, and later levels. The default preview is `${baseUrl}/previews/cube-vertical.webp`, with faces stacked as `f/r/b/l/u/d`.
59
+
60
+ During rapid rotation or zoom, loaded tiles remain visible while newly visible tiles use their parent level or the preview as a local fallback.
61
+
62
+ Override storage conventions with either a krpano placeholder template or a resolver. Both return paths relative to `baseUrl`:
63
+
64
+ ```tsx
65
+ <Tile
66
+ baseUrl="/panoramas/room"
67
+ multires={{ tileSize: 512, levels: [500, 1000, 2000] }}
68
+ urlTemplate="assets/%s/%l/%v_%h.webp"
69
+ resolveTileUrl={({ face, level, row, col }) =>
70
+ `api/tile/${face}/${level}/${row}/${col}`
71
+ }
72
+ />
73
+ ```
74
+
75
+ `resolveTileUrl` takes precedence over `urlTemplate`.
76
+
77
+ For krpano cube-tile templates, `%s` is the cube face and `%l` is the multires
78
+ level. Horizontal tile indices support the equivalent `%h`, `%x`, `%u`, and
79
+ `%c` placeholders; vertical tile indices support `%v`, `%y`, `%w`, and `%r`.
80
+ Prefix an index placeholder with zeroes for padding: `%0h` produces a two-digit
81
+ horizontal index and `%00v` produces a three-digit vertical index. The stereo
82
+ `%t` and frame `%f` placeholders are not applicable to `Tile`, which represents
83
+ a single non-stereo cube panorama.
84
+
85
+ ## Scene transitions
86
+
87
+ `PanoramaScenes` switches controlled sphere and cube-tile scenes with GPU-only
88
+ snapshot blending. The target scene loads its sphere image or tile preview first;
89
+ once ready, the current framebuffer becomes a temporary GPU texture, its source
90
+ textures are released, and the target scene blends in. This avoids holding two
91
+ high-resolution tile scenes in WebGL memory at once.
92
+
93
+ ```tsx
94
+ import {
95
+ PanoramaScenes,
96
+ PanoView,
97
+ type PanoramaScene,
98
+ } from "@pano-view/react";
99
+
100
+ const scenes: PanoramaScene[] = [
101
+ { id: "lobby", type: "sphere", src: "/panoramas/lobby.webp" },
102
+ {
103
+ id: "terrace",
104
+ type: "tile",
105
+ baseUrl: "/panoramas/terrace",
106
+ multires: "512,1000,2000",
107
+ },
108
+ ];
109
+
110
+ <PanoView style={{ height: 560 }}>
111
+ <PanoramaScenes
112
+ scenes={scenes}
113
+ activeSceneId={activeSceneId}
114
+ transition="ellipticZoomOpen"
115
+ renderHotspots={(scene) => <SceneHotspots sceneId={scene.id} />}
116
+ onTransitionError={({ sceneId }) => console.warn("Could not load", sceneId)}
117
+ />
118
+ </PanoView>;
119
+ ```
120
+
121
+ Available presets are `none`, `crossfade`, `zoom`, `blackout`, `whiteFlash`,
122
+ `slideRightToLeft`, `slideTopToBottom`, `slideDiagonal`, `circleOpen`,
123
+ `verticalOpen`, `horizontalOpen`, and `ellipticZoomOpen`. Pass
124
+ `{ preset: "crossfade", duration: 0.6 }` to override a preset duration.
125
+
126
+ While a transition runs, panorama drag/zoom input is locked and
127
+ `renderHotspots` is hidden. New `activeSceneId` values supersede a target that
128
+ is still being prepared. `maxTextureMemoryMb` and `maxConcurrentTileLoads`
129
+ apply to the whole `PanoramaScenes` viewer rather than to each tile scene.
130
+
131
+ ## Controls and imperative API
132
+
133
+ Angles are public degrees. Positive yaw looks right and positive pitch looks up.
134
+
135
+ ```tsx
136
+ import { useRef } from "react";
137
+ import {
138
+ PanoView,
139
+ Sphere,
140
+ AutoRotate,
141
+ type PanoViewHandle,
142
+ } from "@pano-view/react";
143
+
144
+ export function ControlledExample() {
145
+ const ref = useRef<PanoViewHandle>(null);
146
+
147
+ return (
148
+ <>
149
+ <button onClick={() => ref.current?.setView({ yaw: 90, fov: 55 })}>
150
+ Look right
151
+ </button>
152
+ <PanoView
153
+ ref={ref}
154
+ controls={{
155
+ inertia: true,
156
+ rotateDamping: 14,
157
+ zoomDamping: 16,
158
+ }}
159
+ initialView={{ yaw: 0, pitch: 0, fov: 75 }}
160
+ minFov={30}
161
+ maxFov={100}
162
+ onViewChange={(view) => console.log(view)}
163
+ style={{ height: 560 }}
164
+ >
165
+ <AutoRotate enabled speed={18} acceleration={18} startDelay={1_000} />
166
+ <Sphere src="/panoramas/room.webp" />
167
+ </PanoView>
168
+ </>
169
+ );
170
+ }
171
+ ```
172
+
173
+ The handle exposes `getView`, `setView`, `reset`, `startAutoRotate`, `stopAutoRotate`, and `toggleFullscreen`. Built-in input supports pointer drag, touch drag, pinch, wheel, arrow keys, `+/-`, and `0` to reset. The two auto-rotation handle methods remain supported for compatibility; prefer rendering `AutoRotate` for new code.
174
+
175
+ ## Panorama coordinate events
176
+
177
+ Use the panorama pointer callbacks when an authoring UI needs the spherical
178
+ position under the cursor. Event positions use the same public degree
179
+ convention as the camera: positive yaw looks right and positive pitch looks up.
180
+
181
+ ```tsx
182
+ import {
183
+ PanoView,
184
+ Sphere,
185
+ panoPositionToVector3,
186
+ type HotspotPosition,
187
+ } from "@pano-view/react";
188
+
189
+ export function PlacementExample() {
190
+ const place = (position: HotspotPosition) => {
191
+ const worldPosition = panoPositionToVector3(position, 10);
192
+ console.log(position, worldPosition);
193
+ };
194
+
195
+ return (
196
+ <PanoView
197
+ onPanoramaClick={({ position }) => place(position)}
198
+ onPanoramaDoubleClick={({ position }) => console.log("double", position)}
199
+ onPanoramaPointerMove={({ position }) => console.log("move", position)}
200
+ style={{ height: 560 }}
201
+ >
202
+ <Sphere src="/panoramas/room.webp" />
203
+ </PanoView>
204
+ );
205
+ }
206
+ ```
207
+
208
+ `normalizePanoPosition`, `normalizePanoYaw`, `clampPanoPitch`, and
209
+ `vector3ToPanoPosition` are also exported for controlled authoring flows. Yaw
210
+ is normalized to `[-180, 180)`. Pitch is clamped to `[-89.9, 89.9]` to avoid
211
+ the spherical-coordinate singularity at the poles.
212
+
213
+ ## Shared hotspot contract and saved definitions
214
+
215
+ Every hotspot has an `id`, optional `visible`, `interactive`, `renderOrder`,
216
+ and semantic interaction callbacks. Point hotspots additionally use a
217
+ controlled `position`, angular `width`/`height`, hotspot mode, and the zoom
218
+ options described below. `interactive={false}` keeps a hotspot visible while
219
+ letting a drawing tool receive the panorama pointer events beneath it.
220
+
221
+ `onClick` and `onHoverChange` receive a position and input source (`"pointer"`
222
+ or `"keyboard"`). Point hotspots can use `draggable`, `onDragStart`,
223
+ `onPositionChange`, and `onDragEnd`; the host must write the reported position
224
+ back to its state. Polygon and polyline hotspots report the corresponding
225
+ controlled `vertices` through `onVerticesChange`.
226
+
227
+ For persistence or a host-owned editor, use the exported discriminated
228
+ `HotspotDefinition` union. It contains the original five categories plus the
229
+ open `polyline` extension:
230
+
231
+ ```ts
232
+ import type { HotspotDefinition } from "@pano-view/react";
233
+
234
+ const hotspots: HotspotDefinition[] = [
235
+ { type: "image", id: "gallery", position: { yaw: 24, pitch: -5 }, src: "/hotspots/card.webp" },
236
+ { type: "graphic", id: "marker", position: { yaw: -18, pitch: 9 }, graphic: { kind: "ring" } },
237
+ { type: "sequence", id: "pulse", position: { yaw: -42, pitch: -7 }, src: "/hotspots/pulse.png", frameCount: 20 },
238
+ { type: "video", id: "clip", position: { yaw: 48, pitch: 6 }, src: "/hotspots/clip.webm" },
239
+ { type: "polygon", id: "zone", vertices: [{ yaw: 12, pitch: 4 }, { yaw: 22, pitch: 4 }, { yaw: 18, pitch: 14 }] },
240
+ { type: "polyline", id: "route", vertices: [{ yaw: -8, pitch: 1 }, { yaw: 4, pitch: 8 }] },
241
+ ];
242
+ ```
243
+
244
+ Definitions intentionally contain data only. Render them with a switch in the
245
+ host, where application-specific click actions, controlled media state, and
246
+ error reporting belong.
247
+
248
+ ## Image and graphic hotspots
249
+
250
+ `ImageHotspot` renders an image at a controlled spherical position. Dimensions
251
+ are angular degrees, so they remain independent of the canvas resolution.
252
+
253
+ ```tsx
254
+ import { ImageHotspot, PanoView, Sphere } from "@pano-view/react";
255
+
256
+ <PanoView style={{ height: 560 }}>
257
+ <Sphere src="/panoramas/room.webp" />
258
+ <ImageHotspot
259
+ id="gallery"
260
+ ariaLabel="Open gallery"
261
+ position={{ yaw: 28, pitch: -4 }}
262
+ width={18}
263
+ height={10}
264
+ mode="surface"
265
+ src="/hotspots/gallery.webp"
266
+ onClick={({ position }) => console.log("gallery", position)}
267
+ />
268
+ </PanoView>;
269
+ ```
270
+
271
+ `GraphicHotspot` accepts built-in `circle`, `rectangle`, and `ring` graphics,
272
+ an SVG URL, or safe SVG path data with an explicit viewBox. Built-in graphics
273
+ support `fill`, `stroke`, and `strokeWidth`; rectangles also support
274
+ `cornerRadius` and rings support `innerRadius`.
275
+
276
+ ```tsx
277
+ import { GraphicHotspot } from "@pano-view/react";
278
+
279
+ <GraphicHotspot
280
+ id="wayfinding"
281
+ ariaLabel="Open wayfinding point"
282
+ position={{ yaw: -18, pitch: 9 }}
283
+ width={8}
284
+ height={8}
285
+ graphic={{
286
+ kind: "ring",
287
+ fill: "#df6b42",
288
+ stroke: "#f5fbfc",
289
+ strokeWidth: 8,
290
+ innerRadius: 0.64,
291
+ }}
292
+ onClick={({ position }) => console.log("wayfinding", position)}
293
+ />
294
+ ```
295
+
296
+ Set `draggable` and update the controlled position from `onPositionChange` to
297
+ move a selected hotspot. Clickable hotspots need `ariaLabel`: PanoView creates
298
+ an internal semantic control for Tab, Enter, and Space activation, with a
299
+ visible WebGL focus outline.
300
+
301
+ `ImageHotspot` calls `onLoad(texture)` and `onError(error)`. `GraphicHotspot`
302
+ uses the same callbacks. Built-in paths are rasterized locally; only an SVG URL
303
+ or SVG `path` data plus an explicit `viewBox` is accepted, never arbitrary SVG
304
+ markup.
305
+
306
+ ## Hotspot mode and zoom behaviour
307
+
308
+ Point hotspots use one safe `mode` instead of separate orientation and
309
+ placement properties:
310
+
311
+ - `mode="surface"` attaches the plane to the local panorama surface.
312
+ - `mode="billboard"` keeps the plane facing the camera and floats it in front
313
+ of the shell. Set `distance` (world units, default `10`) to bring it nearer.
314
+ The built-in panorama shell is at roughly `1000` units; distance is
315
+ automatically capped so the hotspot's corners cannot cross it.
316
+ - `scaleMode="fov"` (default) lets a hotspot grow when the user zooms in.
317
+ `scaleMode="fixed"` compensates for FOV changes and keeps its screen size
318
+ close to the size at `referenceFov` (default `75`).
319
+
320
+ ```tsx
321
+ <ImageHotspot
322
+ id="callout"
323
+ ariaLabel="Open callout"
324
+ position={{ yaw: 18, pitch: 5 }}
325
+ width={10}
326
+ height={6}
327
+ mode="billboard"
328
+ distance={14}
329
+ scaleMode="fixed"
330
+ src="/hotspots/callout.webp"
331
+ />
332
+ ```
333
+
334
+ ## Polygon hotspots
335
+
336
+ `PolygonHotspot` renders a local spherical area from three or more controlled
337
+ yaw/pitch vertices. Concave polygons and the `-180°/180°` seam are supported.
338
+ The component reports, rather than renders, self-intersecting polygons, shapes
339
+ that contain a pole, and shapes that cannot fit within one hemisphere.
340
+
341
+ ```tsx
342
+ import { PolygonHotspot } from "@pano-view/react";
343
+
344
+ <PolygonHotspot
345
+ id="exhibit-zone"
346
+ ariaLabel="Open exhibit zone"
347
+ vertices={[
348
+ { yaw: 16, pitch: 8 },
349
+ { yaw: 31, pitch: 7 },
350
+ { yaw: 27, pitch: 19 },
351
+ { yaw: 19, pitch: 16 },
352
+ ]}
353
+ fill="#df6b42"
354
+ fillOpacity={0.32}
355
+ stroke="#f5fbfc"
356
+ strokeWidth={2}
357
+ strokeOpacity={0.8}
358
+ draggable
359
+ onVerticesChange={({ vertices }) => setZoneVertices(vertices)}
360
+ onInvalid={(issues) => console.error(issues)}
361
+ />
362
+ ```
363
+
364
+ `strokeWidth` is a screen-space CSS pixel value, so it remains visually
365
+ consistent across FOV changes. Dragging moves all vertices together while
366
+ keeping yaw normalized and pitch clamped. Individual vertex editing is provided
367
+ by the authoring workflow, not the runtime hotspot component.
368
+
369
+ Set `fillOpacity={0}` for a closed outline-only polygon. The fill and outline
370
+ share the same spherical edge sampling, so there is no intentional seam
371
+ between them.
372
+
373
+ ## Polyline hotspots
374
+
375
+ `PolylineHotspot` is an open path of at least two yaw/pitch vertices. It uses
376
+ the same CSS-pixel `strokeWidth`, visibility, semantic interaction, controlled
377
+ whole-path drag, and vertex-change callback as `PolygonHotspot`, but it never
378
+ connects the final point to the first and has no fill.
379
+
380
+ ```tsx
381
+ import { PolylineHotspot } from "@pano-view/react";
382
+
383
+ <PolylineHotspot
384
+ id="guided-route"
385
+ ariaLabel="Guided route"
386
+ vertices={routeVertices}
387
+ stroke="#75cbd3"
388
+ strokeWidth={2}
389
+ strokeOpacity={0.9}
390
+ draggable
391
+ onVerticesChange={({ vertices }) => setRouteVertices(vertices)}
392
+ />;
393
+ ```
394
+
395
+ `onInvalid` reports the only invalid runtime shape: fewer than two vertices.
396
+
397
+ ## Sequence and video hotspots
398
+
399
+ `SequenceHotspot` animates a sprite sheet: one image containing equally sized
400
+ frames in a vertical or horizontal strip. This matches the common krpano
401
+ animated-hotspot format. Give `width` and `height` the aspect ratio of a single
402
+ frame, rather than the full strip. `playing` is controlled by the host; `fps`
403
+ defaults to `12` and `loop` defaults to `true`.
404
+
405
+ ```tsx
406
+ import { SequenceHotspot } from "@pano-view/react";
407
+
408
+ <SequenceHotspot
409
+ id="pulse"
410
+ ariaLabel="Play marker sequence"
411
+ position={{ yaw: -42, pitch: -7 }}
412
+ width={8}
413
+ height={8}
414
+ src="/hotspots/pulse-strip.png"
415
+ frameCount={20}
416
+ frameDirection="vertical"
417
+ fps={12}
418
+ loop
419
+ playing={isPulsePlaying}
420
+ onEnded={() => setPulsePlaying(false)}
421
+ onError={({ error }) => console.error(error)}
422
+ />;
423
+ ```
424
+
425
+ `VideoHotspot` uses an `HTMLVideoElement` and `VideoTexture`. Its `playing`
426
+ prop is likewise controlled: toggle it from your click handler or application
427
+ state. On source change or unmount, the element is paused and its texture is
428
+ released. `playsInline` defaults to `true`, `muted` to `true`, `loop` to
429
+ `false`, and `preload` to `"metadata"`.
430
+
431
+ ```tsx
432
+ import { VideoHotspot } from "@pano-view/react";
433
+
434
+ <VideoHotspot
435
+ id="clip"
436
+ ariaLabel="Play room video"
437
+ position={{ yaw: 48, pitch: 6 }}
438
+ width={18}
439
+ height={10.125}
440
+ mode="surface"
441
+ src="/hotspots/room.webm"
442
+ poster="/hotspots/room-poster.webp"
443
+ playing={isClipPlaying}
444
+ muted
445
+ volume={0.8}
446
+ onClick={() => setClipPlaying((playing) => !playing)}
447
+ onEnded={() => setClipPlaying(false)}
448
+ onPlaybackStateChange={(state) => console.log(state)}
449
+ onPlaybackError={(error) => console.error(error)}
450
+ />;
451
+ ```
452
+
453
+ Browsers can reject unmuted or otherwise non-gesture playback. In that case
454
+ `onPlaybackStateChange` receives `"blocked"` and `onPlaybackError` receives
455
+ the browser error; retain control of `playing` in the host and offer an
456
+ explicit user action.
457
+
458
+ Sequence loading uses `onLoadProgress` and `onError`; video reports media and
459
+ poster failures through `onError`. Neither component changes `playing` by
460
+ itself: update that prop in response to your UI, click handler, `onEnded`, or
461
+ playback error. This keeps source changes, unmounts, and React StrictMode
462
+ lifecycles deterministic.
463
+
464
+ ## Automatic rotation
465
+
466
+ Render `AutoRotate` inside `PanoView` to keep rotation configuration separate from user-input controls. `speed` is measured in degrees per second; use a negative value to rotate left. `acceleration` is measured in degrees per second squared and smoothly ramps from zero to `speed` (default: `18`, so the default speed takes one second to reach). Set it to `0` for an immediate fixed speed. `startDelay` is measured in milliseconds from when `enabled` becomes true. While the user is dragging, or while drag inertia is still settling, rotation pauses and resumes from zero speed automatically.
467
+
468
+ ```tsx
469
+ <PanoView style={{ height: 560 }}>
470
+ <AutoRotate enabled speed={12} acceleration={6} startDelay={2_000} />
471
+ <Sphere src="/panoramas/room.webp" />
472
+ </PanoView>
473
+ ```
474
+
475
+ Drag and zoom update a target view that the camera follows smoothly. `rotateDamping` and `zoomDamping` control that following speed in seconds^-1 (defaults: `14` and `16` respectively); lower values feel softer, while `0` disables smoothing for that axis. Both values must be non-negative finite numbers. Imperative `setView()` and `reset()` remain immediate.
476
+
477
+ ## Next.js and SSR
478
+
479
+ The distributed entry is marked as a client module. Render it below a Client Component boundary. Panorama resources are loaded in the browser; no API keys or server configuration are required.