@mmmmzxe/react-360-viewer 0.1.13 → 0.1.15
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 +21 -0
- package/README.md +17 -0
- package/dist/index.js +3590 -65
- package/dist/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/package.json +11 -18
- package/dist/inject-styles.js +0 -21
- package/src/components/ui/Badge/index.tsx +0 -45
- package/src/components/ui/Button/index.tsx +0 -67
- package/src/components/ui/Card/index.tsx +0 -74
- package/src/components/ui/Item/index.tsx +0 -136
- package/src/components/ui/Label/index.tsx +0 -20
- package/src/components/ui/Popover/index.tsx +0 -56
- package/src/components/ui/Separator/index.tsx +0 -30
- package/src/components/ui/Spinner/index.tsx +0 -11
- package/src/components/utils/index.ts +0 -6
- package/src/constants/viewer360ClassNames.ts +0 -42
- package/src/constants/viewer360Config.ts +0 -11
- package/src/constants/viewer360Labels.ts +0 -14
- package/src/constants/viewer360MarkerLabels.ts +0 -3
- package/src/feature/Viewer360.test.tsx +0 -47
- package/src/feature/Viewer360.tsx +0 -223
- package/src/feature/Viewer360AddModeBanner.tsx +0 -20
- package/src/feature/Viewer360FrameIndicator.tsx +0 -20
- package/src/feature/Viewer360HotspotOverlay.tsx +0 -57
- package/src/feature/Viewer360LoadingOverlay.tsx +0 -28
- package/src/feature/Viewer360MarkerPin.tsx +0 -105
- package/src/feature/Viewer360Toolbar.tsx +0 -75
- package/src/helpers/adjustViewerZoom.test.ts +0 -29
- package/src/helpers/adjustViewerZoom.ts +0 -64
- package/src/helpers/computeDragFrameIndex.test.ts +0 -20
- package/src/helpers/computeDragFrameIndex.ts +0 -23
- package/src/helpers/computeViewerImageLayout.test.ts +0 -48
- package/src/helpers/computeViewerImageLayout.ts +0 -114
- package/src/helpers/computeViewerPanOffset.ts +0 -18
- package/src/helpers/markerHelpers.test.ts +0 -38
- package/src/helpers/markerHelpers.ts +0 -33
- package/src/helpers/viewer360PropsHelpers.ts +0 -46
- package/src/helpers/viewerHelpers.ts +0 -74
- package/src/hooks/useViewer360.ts +0 -306
- package/src/index.ts +0 -68
- package/src/styles.css +0 -80
- package/src/types/Viewer360Hotspot.ts +0 -67
- package/src/types/Viewer360Marker.ts +0 -52
- package/src/types/Viewer360Props.ts +0 -108
- package/src/types/index.ts +0 -30
- package/src/utils/index.ts +0 -6
|
@@ -1,105 +0,0 @@
|
|
|
1
|
-
import type { JSX, MouseEvent } from 'react';
|
|
2
|
-
|
|
3
|
-
import { Trash2 } from 'lucide-react';
|
|
4
|
-
|
|
5
|
-
import { defaultViewer360MarkerPinLabels } from '../constants/viewer360MarkerLabels';
|
|
6
|
-
import { viewer360MarkerPinClassNames } from '../constants/viewer360ClassNames';
|
|
7
|
-
import type { Viewer360MarkerPinProps } from '../types';
|
|
8
|
-
import { Button } from '@/components/ui/Button';
|
|
9
|
-
import {
|
|
10
|
-
Item,
|
|
11
|
-
ItemActions,
|
|
12
|
-
ItemContent,
|
|
13
|
-
ItemDescription,
|
|
14
|
-
ItemTitle,
|
|
15
|
-
} from '@/components/ui/Item';
|
|
16
|
-
import { cn } from '@/components/utils';
|
|
17
|
-
|
|
18
|
-
export function Viewer360MarkerPin<TData = unknown>({
|
|
19
|
-
marker,
|
|
20
|
-
hotspot,
|
|
21
|
-
leftPercent,
|
|
22
|
-
topPercent,
|
|
23
|
-
onDelete,
|
|
24
|
-
isDeletePending = false,
|
|
25
|
-
onClick,
|
|
26
|
-
renderTag,
|
|
27
|
-
classNames,
|
|
28
|
-
labels,
|
|
29
|
-
}: Viewer360MarkerPinProps<TData>): JSX.Element {
|
|
30
|
-
// ----------------------------------------------------------------------------------------------------
|
|
31
|
-
// MARK: States & Constants
|
|
32
|
-
// ----------------------------------------------------------------------------------------------------
|
|
33
|
-
const deleteLabel = labels?.delete ?? defaultViewer360MarkerPinLabels.delete;
|
|
34
|
-
const showTooltip = Boolean(marker.title || marker.description || onDelete || renderTag);
|
|
35
|
-
|
|
36
|
-
// ----------------------------------------------------------------------------------------------------
|
|
37
|
-
// MARK: Main Component UI
|
|
38
|
-
// ----------------------------------------------------------------------------------------------------
|
|
39
|
-
return (
|
|
40
|
-
<div
|
|
41
|
-
className={cn(viewer360MarkerPinClassNames.root, classNames?.root, 'group/marker')}
|
|
42
|
-
style={{ left: `${leftPercent}%`, top: `${topPercent}%` }}
|
|
43
|
-
>
|
|
44
|
-
<div className="relative size-4 shrink-0">
|
|
45
|
-
<span className={cn(viewer360MarkerPinClassNames.ping, classNames?.ping)} aria-hidden="true" />
|
|
46
|
-
|
|
47
|
-
<Button
|
|
48
|
-
type="button"
|
|
49
|
-
variant="destructive"
|
|
50
|
-
size="icon-xs"
|
|
51
|
-
className={cn(viewer360MarkerPinClassNames.dot, classNames?.dot, 'hover:bg-destructive')}
|
|
52
|
-
aria-label={marker.title}
|
|
53
|
-
onClick={onClick}
|
|
54
|
-
/>
|
|
55
|
-
</div>
|
|
56
|
-
|
|
57
|
-
{showTooltip && (
|
|
58
|
-
<div
|
|
59
|
-
className={cn(
|
|
60
|
-
viewer360MarkerPinClassNames.tooltip,
|
|
61
|
-
classNames?.tooltip,
|
|
62
|
-
'pointer-events-none opacity-0 transition-opacity duration-150 group-hover/marker:pointer-events-auto group-hover/marker:opacity-100 group-focus-within/marker:pointer-events-auto group-focus-within/marker:opacity-100'
|
|
63
|
-
)}
|
|
64
|
-
>
|
|
65
|
-
<Item
|
|
66
|
-
size="sm"
|
|
67
|
-
variant="default"
|
|
68
|
-
className={cn(viewer360MarkerPinClassNames.tooltipHeader, classNames?.tooltipHeader, 'w-full border-transparent')}
|
|
69
|
-
>
|
|
70
|
-
<ItemContent className={cn(viewer360MarkerPinClassNames.tooltipBody, classNames?.tooltipBody)}>
|
|
71
|
-
<ItemTitle className={cn(viewer360MarkerPinClassNames.tooltipTitle, classNames?.tooltipTitle)}>
|
|
72
|
-
{marker.title}
|
|
73
|
-
</ItemTitle>
|
|
74
|
-
{renderTag && (
|
|
75
|
-
<div className="mt-1 flex w-fit items-center">{renderTag({ marker, hotspot })}</div>
|
|
76
|
-
)}
|
|
77
|
-
{marker.description && (
|
|
78
|
-
<ItemDescription className={cn(viewer360MarkerPinClassNames.tooltipDescription, classNames?.tooltipDescription)}>
|
|
79
|
-
{marker.description}
|
|
80
|
-
</ItemDescription>
|
|
81
|
-
)}
|
|
82
|
-
</ItemContent>
|
|
83
|
-
{onDelete && (
|
|
84
|
-
<ItemActions>
|
|
85
|
-
<Button
|
|
86
|
-
variant="ghost"
|
|
87
|
-
size="icon-sm"
|
|
88
|
-
className={classNames?.deleteButton}
|
|
89
|
-
disabled={isDeletePending}
|
|
90
|
-
aria-label={deleteLabel}
|
|
91
|
-
onClick={(event: MouseEvent<HTMLButtonElement>) => {
|
|
92
|
-
event.stopPropagation();
|
|
93
|
-
onDelete(marker.id);
|
|
94
|
-
}}
|
|
95
|
-
>
|
|
96
|
-
<Trash2 className="size-4" />
|
|
97
|
-
</Button>
|
|
98
|
-
</ItemActions>
|
|
99
|
-
)}
|
|
100
|
-
</Item>
|
|
101
|
-
</div>
|
|
102
|
-
)}
|
|
103
|
-
</div>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import type { JSX } from 'react';
|
|
2
|
-
|
|
3
|
-
import { Crosshair, Minus, Plus, RotateCcw, ZoomIn } from 'lucide-react';
|
|
4
|
-
|
|
5
|
-
import { viewer360ClassNames } from '../constants/viewer360ClassNames';
|
|
6
|
-
import type { Viewer360ToolbarRenderProps } from '../types';
|
|
7
|
-
import { Badge } from '@/components/ui/Badge';
|
|
8
|
-
import { Button } from '@/components/ui/Button';
|
|
9
|
-
import { CardFooter } from '@/components/ui/Card';
|
|
10
|
-
import { Label } from '@/components/ui/Label';
|
|
11
|
-
import { Separator } from '@/components/ui/Separator';
|
|
12
|
-
import { cn } from '@/components/utils';
|
|
13
|
-
|
|
14
|
-
type Viewer360ToolbarProps = Viewer360ToolbarRenderProps;
|
|
15
|
-
|
|
16
|
-
export function Viewer360Toolbar({
|
|
17
|
-
showDragHint,
|
|
18
|
-
showHotspotModeControl,
|
|
19
|
-
showZoomControls,
|
|
20
|
-
showResetControl,
|
|
21
|
-
labels,
|
|
22
|
-
isHotspotMode,
|
|
23
|
-
zoom,
|
|
24
|
-
minZoom,
|
|
25
|
-
maxZoom,
|
|
26
|
-
isResetDisabled,
|
|
27
|
-
onHotspotModeChange,
|
|
28
|
-
onZoomIn,
|
|
29
|
-
onZoomOut,
|
|
30
|
-
onResetView,
|
|
31
|
-
}: Viewer360ToolbarProps): JSX.Element {
|
|
32
|
-
// ----------------------------------------------------------------------------------------------------
|
|
33
|
-
// MARK: Main Component UI
|
|
34
|
-
// ----------------------------------------------------------------------------------------------------
|
|
35
|
-
return (
|
|
36
|
-
<CardFooter className={cn(viewer360ClassNames.toolbar, 'gap-2 border-t px-4 py-3 pt-3')}>
|
|
37
|
-
{showDragHint && (
|
|
38
|
-
<Label className={cn(viewer360ClassNames.dragHint, 'font-normal text-muted-foreground')}>{labels.dragHint}</Label>
|
|
39
|
-
)}
|
|
40
|
-
|
|
41
|
-
<div className={viewer360ClassNames.controls}>
|
|
42
|
-
{showHotspotModeControl && (
|
|
43
|
-
<>
|
|
44
|
-
<Button variant={isHotspotMode ? 'default' : 'outline'} size="sm" onClick={() => onHotspotModeChange(!isHotspotMode)}>
|
|
45
|
-
<Crosshair className="me-1.5 size-4" />
|
|
46
|
-
{labels.addHotspot}
|
|
47
|
-
</Button>
|
|
48
|
-
<Separator orientation="vertical" className={cn(viewer360ClassNames.divider, 'h-6')} />
|
|
49
|
-
</>
|
|
50
|
-
)}
|
|
51
|
-
|
|
52
|
-
{showZoomControls && (
|
|
53
|
-
<>
|
|
54
|
-
<Button variant="outline" size="icon-sm" disabled={zoom <= minZoom} aria-label={labels.zoomOut} onClick={onZoomOut}>
|
|
55
|
-
<Minus className="size-4" />
|
|
56
|
-
</Button>
|
|
57
|
-
<Badge variant="outline" className={cn(viewer360ClassNames.zoomDisplay, 'h-8 gap-1 px-2 py-1')}>
|
|
58
|
-
<ZoomIn className="size-3 text-muted-foreground" />
|
|
59
|
-
{labels.zoom(Math.round(zoom * 100))}
|
|
60
|
-
</Badge>
|
|
61
|
-
<Button variant="outline" size="icon-sm" disabled={zoom >= maxZoom} aria-label={labels.zoomIn} onClick={onZoomIn}>
|
|
62
|
-
<Plus className="size-4" />
|
|
63
|
-
</Button>
|
|
64
|
-
</>
|
|
65
|
-
)}
|
|
66
|
-
|
|
67
|
-
{showResetControl && (
|
|
68
|
-
<Button variant="outline" size="icon-sm" disabled={isResetDisabled} aria-label={labels.resetView} onClick={onResetView}>
|
|
69
|
-
<RotateCcw className="size-4" />
|
|
70
|
-
</Button>
|
|
71
|
-
)}
|
|
72
|
-
</div>
|
|
73
|
-
</CardFooter>
|
|
74
|
-
);
|
|
75
|
-
}
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { applyWheelZoom, isResetDisabled, resolveViewer360Config, stepZoomIn, stepZoomOut } from './adjustViewerZoom';
|
|
4
|
-
|
|
5
|
-
describe('adjustViewerZoom', () => {
|
|
6
|
-
const config = resolveViewer360Config();
|
|
7
|
-
|
|
8
|
-
it('clamps wheel zoom within bounds', () => {
|
|
9
|
-
const result = applyWheelZoom(1, -100, { panX: 0, panY: 0 }, config);
|
|
10
|
-
expect(result.zoom).toBeGreaterThan(1);
|
|
11
|
-
expect(result.zoom).toBeLessThanOrEqual(config.maxZoom);
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
it('resets pan when zoom returns to minimum', () => {
|
|
15
|
-
const result = applyWheelZoom(1.15, 100, { panX: 12, panY: 8 }, config);
|
|
16
|
-
expect(result.zoom).toBe(config.minZoom);
|
|
17
|
-
expect(result.pan).toEqual({ panX: 0, panY: 0 });
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
it('steps zoom in and out', () => {
|
|
21
|
-
expect(stepZoomIn(1, config)).toBeCloseTo(1.15);
|
|
22
|
-
expect(stepZoomOut(1.15, { panX: 4, panY: 2 }, config).zoom).toBeCloseTo(1);
|
|
23
|
-
});
|
|
24
|
-
|
|
25
|
-
it('detects reset disabled state', () => {
|
|
26
|
-
expect(isResetDisabled(1, { panX: 0, panY: 0 }, config)).toBe(true);
|
|
27
|
-
expect(isResetDisabled(1.3, { panX: 0, panY: 0 }, config)).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
});
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
import type { Viewer360Config } from '../types';
|
|
2
|
-
|
|
3
|
-
import type { PanOffset } from './computeViewerImageLayout';
|
|
4
|
-
|
|
5
|
-
type ResolvedViewer360Config = Required<Viewer360Config>;
|
|
6
|
-
|
|
7
|
-
export function resolveViewer360Config(config?: Viewer360Config): ResolvedViewer360Config {
|
|
8
|
-
return {
|
|
9
|
-
minZoom: config?.minZoom ?? 1,
|
|
10
|
-
maxZoom: config?.maxZoom ?? 3,
|
|
11
|
-
zoomStep: config?.zoomStep ?? 0.15,
|
|
12
|
-
dragSensitivity: config?.dragSensitivity ?? 8,
|
|
13
|
-
autoRotate: config?.autoRotate ?? false,
|
|
14
|
-
autoRotateIntervalMs: config?.autoRotateIntervalMs ?? 100,
|
|
15
|
-
autoRotateDirection: config?.autoRotateDirection ?? 'forward',
|
|
16
|
-
};
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
export function clampZoom(zoom: number, config: ResolvedViewer360Config): number {
|
|
20
|
-
return Math.min(config.maxZoom, Math.max(config.minZoom, zoom));
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function applyWheelZoom(
|
|
24
|
-
currentZoom: number,
|
|
25
|
-
deltaY: number,
|
|
26
|
-
currentPan: PanOffset,
|
|
27
|
-
config: ResolvedViewer360Config
|
|
28
|
-
): { zoom: number; pan: PanOffset } {
|
|
29
|
-
const delta = deltaY > 0 ? -config.zoomStep : config.zoomStep;
|
|
30
|
-
const zoom = clampZoom(currentZoom + delta, config);
|
|
31
|
-
|
|
32
|
-
return {
|
|
33
|
-
zoom,
|
|
34
|
-
pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan,
|
|
35
|
-
};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function stepZoomIn(currentZoom: number, config: ResolvedViewer360Config): number {
|
|
39
|
-
return clampZoom(currentZoom + config.zoomStep, config);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function stepZoomOut(
|
|
43
|
-
currentZoom: number,
|
|
44
|
-
currentPan: PanOffset,
|
|
45
|
-
config: ResolvedViewer360Config
|
|
46
|
-
): { zoom: number; pan: PanOffset } {
|
|
47
|
-
const zoom = clampZoom(currentZoom - config.zoomStep, config);
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
zoom,
|
|
51
|
-
pan: zoom === config.minZoom ? { panX: 0, panY: 0 } : currentPan,
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function isResetDisabled(zoom: number, pan: PanOffset, config: ResolvedViewer360Config): boolean {
|
|
56
|
-
return zoom === config.minZoom && pan.panX === 0 && pan.panY === 0;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function getViewerCursorClass(isHotspotMode: boolean, zoom: number, isDragging: boolean, minZoom: number): string {
|
|
60
|
-
if (isHotspotMode) return 'cursor-crosshair';
|
|
61
|
-
if (isDragging) return 'cursor-grabbing';
|
|
62
|
-
if (zoom > minZoom) return 'cursor-grab';
|
|
63
|
-
return 'cursor-ew-resize';
|
|
64
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { clampFrameIndex, computeDragFrameIndex } from './computeDragFrameIndex';
|
|
4
|
-
|
|
5
|
-
describe('computeDragFrameIndex', () => {
|
|
6
|
-
it('wraps frame indices', () => {
|
|
7
|
-
expect(clampFrameIndex(-1, 24)).toBe(23);
|
|
8
|
-
expect(clampFrameIndex(24, 24)).toBe(0);
|
|
9
|
-
});
|
|
10
|
-
|
|
11
|
-
it('returns null when drag delta is below sensitivity', () => {
|
|
12
|
-
const result = computeDragFrameIndex({ pointerX: 100, frameIndex: 3 }, 104, 24, 8);
|
|
13
|
-
expect(result).toBeNull();
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
it('advances frames based on horizontal drag', () => {
|
|
17
|
-
const result = computeDragFrameIndex({ pointerX: 200, frameIndex: 5 }, 120, 24, 8);
|
|
18
|
-
expect(result).toBe(15);
|
|
19
|
-
});
|
|
20
|
-
});
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
export function clampFrameIndex(index: number, frameCount: number): number {
|
|
2
|
-
if (frameCount === 0) return 0;
|
|
3
|
-
return ((index % frameCount) + frameCount) % frameCount;
|
|
4
|
-
}
|
|
5
|
-
|
|
6
|
-
type DragStart = {
|
|
7
|
-
pointerX: number;
|
|
8
|
-
frameIndex: number;
|
|
9
|
-
};
|
|
10
|
-
|
|
11
|
-
export function computeDragFrameIndex(
|
|
12
|
-
dragStart: DragStart,
|
|
13
|
-
clientX: number,
|
|
14
|
-
frameCount: number,
|
|
15
|
-
dragSensitivity: number
|
|
16
|
-
): number | null {
|
|
17
|
-
const deltaX = clientX - dragStart.pointerX;
|
|
18
|
-
const frameDelta = Math.round(-deltaX / dragSensitivity);
|
|
19
|
-
|
|
20
|
-
if (frameDelta === 0) return null;
|
|
21
|
-
|
|
22
|
-
return clampFrameIndex(dragStart.frameIndex + frameDelta, frameCount);
|
|
23
|
-
}
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import {
|
|
4
|
-
computeHotspotPositionFromClick,
|
|
5
|
-
computeHotspotScreenPosition,
|
|
6
|
-
computeViewerImageLayout,
|
|
7
|
-
} from './computeViewerImageLayout';
|
|
8
|
-
|
|
9
|
-
describe('computeViewerImageLayout', () => {
|
|
10
|
-
const layout = computeViewerImageLayout({
|
|
11
|
-
containerWidth: 800,
|
|
12
|
-
containerHeight: 500,
|
|
13
|
-
imageWidth: 1600,
|
|
14
|
-
imageHeight: 900,
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it('letterboxes wide images', () => {
|
|
18
|
-
expect(layout.drawWidth).toBe(800);
|
|
19
|
-
expect(layout.drawHeight).toBeCloseTo(450);
|
|
20
|
-
expect(layout.offsetY).toBeCloseTo(25);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
it('maps stored hotspot coordinates to screen space', () => {
|
|
24
|
-
const screen = computeHotspotScreenPosition(50, 50, layout, 1);
|
|
25
|
-
expect(screen.leftPercent).toBeCloseTo(50, 1);
|
|
26
|
-
expect(screen.topPercent).toBeCloseTo(50, 1);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('derives hotspot coordinates from click position', () => {
|
|
30
|
-
const rect = {
|
|
31
|
-
left: 0,
|
|
32
|
-
top: 0,
|
|
33
|
-
width: 800,
|
|
34
|
-
height: 500,
|
|
35
|
-
right: 800,
|
|
36
|
-
bottom: 500,
|
|
37
|
-
x: 0,
|
|
38
|
-
y: 0,
|
|
39
|
-
toJSON: () => ({}),
|
|
40
|
-
} as DOMRect;
|
|
41
|
-
|
|
42
|
-
const position = computeHotspotPositionFromClick(400, 250, rect, layout, 1);
|
|
43
|
-
expect(position.positionX).toBeGreaterThan(0);
|
|
44
|
-
expect(position.positionY).toBeGreaterThan(0);
|
|
45
|
-
expect(position.positionX).toBeLessThanOrEqual(100);
|
|
46
|
-
expect(position.positionY).toBeLessThanOrEqual(100);
|
|
47
|
-
});
|
|
48
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
export type ViewerImageLayout = {
|
|
2
|
-
width: number;
|
|
3
|
-
height: number;
|
|
4
|
-
centerX: number;
|
|
5
|
-
centerY: number;
|
|
6
|
-
drawWidth: number;
|
|
7
|
-
drawHeight: number;
|
|
8
|
-
offsetX: number;
|
|
9
|
-
offsetY: number;
|
|
10
|
-
};
|
|
11
|
-
|
|
12
|
-
export type PanOffset = {
|
|
13
|
-
panX: number;
|
|
14
|
-
panY: number;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
type ComputeViewerImageLayoutParams = {
|
|
18
|
-
containerWidth: number;
|
|
19
|
-
containerHeight: number;
|
|
20
|
-
imageWidth: number;
|
|
21
|
-
imageHeight: number;
|
|
22
|
-
pan?: PanOffset;
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
export function computeViewerImageLayout({
|
|
26
|
-
containerWidth,
|
|
27
|
-
containerHeight,
|
|
28
|
-
imageWidth,
|
|
29
|
-
imageHeight,
|
|
30
|
-
pan = { panX: 0, panY: 0 },
|
|
31
|
-
}: ComputeViewerImageLayoutParams): ViewerImageLayout {
|
|
32
|
-
const imgAspect = imageWidth / imageHeight;
|
|
33
|
-
const containerAspect = containerWidth / containerHeight;
|
|
34
|
-
|
|
35
|
-
let drawWidth: number;
|
|
36
|
-
let drawHeight: number;
|
|
37
|
-
|
|
38
|
-
if (imgAspect > containerAspect) {
|
|
39
|
-
drawWidth = containerWidth;
|
|
40
|
-
drawHeight = containerWidth / imgAspect;
|
|
41
|
-
} else {
|
|
42
|
-
drawHeight = containerHeight;
|
|
43
|
-
drawWidth = containerHeight * imgAspect;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
const offsetX = (containerWidth - drawWidth) / 2 + pan.panX;
|
|
47
|
-
const offsetY = (containerHeight - drawHeight) / 2 + pan.panY;
|
|
48
|
-
|
|
49
|
-
return {
|
|
50
|
-
width: containerWidth,
|
|
51
|
-
height: containerHeight,
|
|
52
|
-
centerX: containerWidth / 2,
|
|
53
|
-
centerY: containerHeight / 2,
|
|
54
|
-
drawWidth,
|
|
55
|
-
drawHeight,
|
|
56
|
-
offsetX,
|
|
57
|
-
offsetY,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function computeHotspotScreenPosition(
|
|
62
|
-
hotspotX: number,
|
|
63
|
-
hotspotY: number,
|
|
64
|
-
layout: ViewerImageLayout,
|
|
65
|
-
zoom: number
|
|
66
|
-
): { leftPercent: number; topPercent: number } {
|
|
67
|
-
const baseOffsetX = (layout.width - layout.drawWidth) / 2;
|
|
68
|
-
const baseOffsetY = (layout.height - layout.drawHeight) / 2;
|
|
69
|
-
|
|
70
|
-
const containerX = (hotspotX / 100) * layout.width;
|
|
71
|
-
const containerY = (hotspotY / 100) * layout.height;
|
|
72
|
-
|
|
73
|
-
const imageLocalX = (containerX - baseOffsetX) / layout.drawWidth;
|
|
74
|
-
const imageLocalY = (containerY - baseOffsetY) / layout.drawHeight;
|
|
75
|
-
|
|
76
|
-
const imagePointX = layout.offsetX + imageLocalX * layout.drawWidth;
|
|
77
|
-
const imagePointY = layout.offsetY + imageLocalY * layout.drawHeight;
|
|
78
|
-
|
|
79
|
-
const screenX = layout.centerX + zoom * (imagePointX - layout.centerX);
|
|
80
|
-
const screenY = layout.centerY + zoom * (imagePointY - layout.centerY);
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
leftPercent: (screenX / layout.width) * 100,
|
|
84
|
-
topPercent: (screenY / layout.height) * 100,
|
|
85
|
-
};
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export function computeHotspotPositionFromClick(
|
|
89
|
-
clientX: number,
|
|
90
|
-
clientY: number,
|
|
91
|
-
containerRect: DOMRect,
|
|
92
|
-
layout: ViewerImageLayout,
|
|
93
|
-
zoom: number
|
|
94
|
-
): { positionX: number; positionY: number } {
|
|
95
|
-
const clickX = clientX - containerRect.left;
|
|
96
|
-
const clickY = clientY - containerRect.top;
|
|
97
|
-
|
|
98
|
-
const unzoomedX = layout.centerX + (clickX - layout.centerX) / zoom;
|
|
99
|
-
const unzoomedY = layout.centerY + (clickY - layout.centerY) / zoom;
|
|
100
|
-
|
|
101
|
-
const baseOffsetX = (layout.width - layout.drawWidth) / 2;
|
|
102
|
-
const baseOffsetY = (layout.height - layout.drawHeight) / 2;
|
|
103
|
-
|
|
104
|
-
const imageLocalX = Math.min(1, Math.max(0, (unzoomedX - layout.offsetX) / layout.drawWidth));
|
|
105
|
-
const imageLocalY = Math.min(1, Math.max(0, (unzoomedY - layout.offsetY) / layout.drawHeight));
|
|
106
|
-
|
|
107
|
-
const storedX = baseOffsetX + imageLocalX * layout.drawWidth;
|
|
108
|
-
const storedY = baseOffsetY + imageLocalY * layout.drawHeight;
|
|
109
|
-
|
|
110
|
-
return {
|
|
111
|
-
positionX: Math.min(100, Math.max(0, (storedX / layout.width) * 100)),
|
|
112
|
-
positionY: Math.min(100, Math.max(0, (storedY / layout.height) * 100)),
|
|
113
|
-
};
|
|
114
|
-
}
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
import type { PanOffset } from './computeViewerImageLayout';
|
|
2
|
-
|
|
3
|
-
type PanStart = {
|
|
4
|
-
pointerX: number;
|
|
5
|
-
pointerY: number;
|
|
6
|
-
panX: number;
|
|
7
|
-
panY: number;
|
|
8
|
-
};
|
|
9
|
-
|
|
10
|
-
export function computeViewerPanOffset(panStart: PanStart, clientX: number, clientY: number): PanOffset {
|
|
11
|
-
const deltaX = clientX - panStart.pointerX;
|
|
12
|
-
const deltaY = clientY - panStart.pointerY;
|
|
13
|
-
|
|
14
|
-
return {
|
|
15
|
-
panX: panStart.panX + deltaX,
|
|
16
|
-
panY: panStart.panY + deltaY,
|
|
17
|
-
};
|
|
18
|
-
}
|
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
2
|
-
|
|
3
|
-
import { hotspotToViewer360Marker, toViewer360Hotspots } from './markerHelpers';
|
|
4
|
-
|
|
5
|
-
describe('markerHelpers', () => {
|
|
6
|
-
it('maps hotspot data to marker content', () => {
|
|
7
|
-
const marker = hotspotToViewer360Marker({
|
|
8
|
-
id: '1',
|
|
9
|
-
frameIndex: 0,
|
|
10
|
-
positionX: 40,
|
|
11
|
-
positionY: 50,
|
|
12
|
-
data: { id: '1', title: 'Scratch', description: 'Front bumper' },
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
expect(marker).toEqual({ id: '1', title: 'Scratch', description: 'Front bumper' });
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
it('falls back to hotspot id when data is missing', () => {
|
|
19
|
-
const marker = hotspotToViewer360Marker({
|
|
20
|
-
id: 'marker-2',
|
|
21
|
-
frameIndex: 1,
|
|
22
|
-
positionX: 10,
|
|
23
|
-
positionY: 20,
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
expect(marker).toEqual({ id: 'marker-2', title: 'marker-2' });
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
it('builds hotspot array from marker records', () => {
|
|
30
|
-
const hotspots = toViewer360Hotspots([
|
|
31
|
-
{ id: 'a', frameIndex: 0, positionX: 12, positionY: 34, title: 'Dent' },
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
expect(hotspots).toHaveLength(1);
|
|
35
|
-
expect(hotspots[0]?.frameIndex).toBe(0);
|
|
36
|
-
expect(hotspots[0]?.positionX).toBe(12);
|
|
37
|
-
});
|
|
38
|
-
});
|
|
@@ -1,33 +0,0 @@
|
|
|
1
|
-
import type { Viewer360Hotspot, Viewer360Marker } from '../types';
|
|
2
|
-
|
|
3
|
-
export function hotspotToViewer360Marker<TData>(hotspot: Viewer360Hotspot<TData>): Viewer360Marker {
|
|
4
|
-
const data = hotspot.data;
|
|
5
|
-
|
|
6
|
-
if (data && typeof data === 'object' && 'title' in data && typeof (data as { title?: unknown }).title === 'string') {
|
|
7
|
-
const marker = data as unknown as Viewer360Marker;
|
|
8
|
-
|
|
9
|
-
return {
|
|
10
|
-
id: marker.id ?? hotspot.id,
|
|
11
|
-
title: marker.title,
|
|
12
|
-
description: marker.description,
|
|
13
|
-
};
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return {
|
|
17
|
-
id: hotspot.id,
|
|
18
|
-
title: hotspot.id,
|
|
19
|
-
};
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export function toViewer360Hotspots<TData extends Viewer360Marker>(
|
|
23
|
-
markers: Array<Viewer360Marker & { frameIndex: number; positionX: number; positionY: number }>,
|
|
24
|
-
mapData?: (marker: Viewer360Marker & { frameIndex: number; positionX: number; positionY: number }) => TData
|
|
25
|
-
): Viewer360Hotspot<TData>[] {
|
|
26
|
-
return markers.map((marker) => ({
|
|
27
|
-
id: marker.id,
|
|
28
|
-
frameIndex: marker.frameIndex,
|
|
29
|
-
positionX: marker.positionX,
|
|
30
|
-
positionY: marker.positionY,
|
|
31
|
-
data: mapData ? mapData(marker) : (marker as unknown as TData),
|
|
32
|
-
}));
|
|
33
|
-
}
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import type { CSSProperties } from 'react';
|
|
2
|
-
|
|
3
|
-
import { defaultViewer360Labels } from '../constants/viewer360Labels';
|
|
4
|
-
import { viewer360ClassNames } from '../constants/viewer360ClassNames';
|
|
5
|
-
import type { Viewer360ClassNames, Viewer360Labels, Viewer360Theme } from '../types';
|
|
6
|
-
import { cn } from '@/components/utils';
|
|
7
|
-
|
|
8
|
-
export function mergeViewer360Labels(labels?: Viewer360Labels): Required<Viewer360Labels> {
|
|
9
|
-
return {
|
|
10
|
-
loading: labels?.loading ?? defaultViewer360Labels.loading,
|
|
11
|
-
dragHint: labels?.dragHint ?? defaultViewer360Labels.dragHint,
|
|
12
|
-
frameIndicator: labels?.frameIndicator ?? defaultViewer360Labels.frameIndicator,
|
|
13
|
-
zoom: labels?.zoom ?? defaultViewer360Labels.zoom,
|
|
14
|
-
hotspotModeActive: labels?.hotspotModeActive ?? defaultViewer360Labels.hotspotModeActive,
|
|
15
|
-
addHotspot: labels?.addHotspot ?? defaultViewer360Labels.addHotspot,
|
|
16
|
-
zoomIn: labels?.zoomIn ?? defaultViewer360Labels.zoomIn,
|
|
17
|
-
zoomOut: labels?.zoomOut ?? defaultViewer360Labels.zoomOut,
|
|
18
|
-
resetView: labels?.resetView ?? defaultViewer360Labels.resetView,
|
|
19
|
-
deleteMarker: labels?.deleteMarker ?? defaultViewer360Labels.deleteMarker,
|
|
20
|
-
};
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export function mergeViewer360ClassNames(classNames?: Viewer360ClassNames): Required<Viewer360ClassNames> {
|
|
24
|
-
return {
|
|
25
|
-
root: cn(viewer360ClassNames.root, classNames?.root),
|
|
26
|
-
viewport: cn(viewer360ClassNames.viewport, classNames?.viewport),
|
|
27
|
-
canvas: cn(viewer360ClassNames.canvas, classNames?.canvas),
|
|
28
|
-
overlay: cn(viewer360ClassNames.overlay, classNames?.overlay),
|
|
29
|
-
loading: cn(viewer360ClassNames.loading, classNames?.loading),
|
|
30
|
-
loadingText: cn(viewer360ClassNames.loadingText, classNames?.loadingText),
|
|
31
|
-
frameIndicator: cn(viewer360ClassNames.frameIndicator, classNames?.frameIndicator),
|
|
32
|
-
hotspotModeBanner: cn(viewer360ClassNames.hotspotModeBanner, classNames?.hotspotModeBanner),
|
|
33
|
-
toolbar: cn(viewer360ClassNames.toolbar, classNames?.toolbar),
|
|
34
|
-
dragHint: cn(viewer360ClassNames.dragHint, classNames?.dragHint),
|
|
35
|
-
controls: cn(viewer360ClassNames.controls, classNames?.controls),
|
|
36
|
-
controlButton: cn(viewer360ClassNames.controlButton, classNames?.controlButton),
|
|
37
|
-
controlButtonActive: cn(viewer360ClassNames.controlButtonActive, classNames?.controlButtonActive),
|
|
38
|
-
controlButtonDisabled: cn(viewer360ClassNames.controlButtonDisabled, classNames?.controlButtonDisabled),
|
|
39
|
-
zoomDisplay: cn(viewer360ClassNames.zoomDisplay, classNames?.zoomDisplay),
|
|
40
|
-
divider: cn(viewer360ClassNames.divider, classNames?.divider),
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export function buildViewer360ThemeStyle(theme?: Viewer360Theme): CSSProperties {
|
|
45
|
-
return theme ? (theme as CSSProperties) : {};
|
|
46
|
-
}
|