Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -75,5 +75,7 @@ docs/decisions/.adr-toolkit.lock

new/troubleshooting-notes.md

docs/notes/

*storybook.log
storybook-static
64 changes: 10 additions & 54 deletions src/app/dev/particle-bench/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { LivePresenceBadge } from '@/features/realtime-presence/presentation/com
import { WarmthReactionButton } from '@/features/realtime-presence/presentation/components/WarmthReactionButton';
import {
type FrameStats,
type FxMode,
type WarmthParticleEvent,
WarmthParticleCanvas,
} from '@/features/realtime-presence/presentation/components/WarmthParticleCanvas';
Expand All @@ -32,15 +31,9 @@ type Rate = (typeof RATES)[number];
type MockTransport = IPresenceTransport & MockPresenceTransportScenarios;
const LOG_MAX = 6;

// ─── URL param helpers ────────────────────────────────────────────────────────
const PARAM_KEYS: Record<keyof MotionParams, string> = {
beeH: 'beeH',
meDurationMs: 'meMs',
otherDurationMs: 'otherMs',
tailRate: 'tailRate',
tailParticleSize: 'tailSize',
sineAmp: 'sineAmp',
startXJitter: 'xJitter',
sineAmp: 'sineAmp',
startXJitter: 'xJitter',
};

function readMotionFromParams(params: URLSearchParams): Partial<MotionParams> {
Expand All @@ -55,15 +48,14 @@ function readMotionFromParams(params: URLSearchParams): Partial<MotionParams> {
return out;
}

function motionToSearchString(fx: FxMode, m: MotionParams): string {
const p = new URLSearchParams({ fx });
function motionToSearchString(m: MotionParams): string {
const p = new URLSearchParams();
for (const [key, urlKey] of Object.entries(PARAM_KEYS) as [keyof MotionParams, string][]) {
p.set(urlKey, String(m[key]));
}
return p.toString();
}

// ─── Slider row ───────────────────────────────────────────────────────────────
function SliderRow({
label, value, min, max, step, onChange,
}: {
Expand All @@ -85,14 +77,12 @@ function SliderRow({
);
}

// ─── Main page ────────────────────────────────────────────────────────────────
function ParticleBenchPage() {
const searchParams = useSearchParams();
const router = useRouter();

const [rate, setRate] = useState<Rate>(30);
const [pooling, setPooling] = useState(true);
const [fx, setFx] = useState<FxMode>(() => (searchParams.get('fx') === 'bee' ? 'bee' : 'light'));
const [motion, setMotion] = useState<MotionParams>(() => ({
...DEFAULT_MOTION,
...readMotionFromParams(searchParams),
Expand Down Expand Up @@ -136,7 +126,6 @@ function ParticleBenchPage() {
});
}, [transport, addLog]);

// 자동 파티클
useEffect(() => {
const t = setInterval(() => {
const enqueue = enqueueRef.current;
Expand Down Expand Up @@ -180,32 +169,29 @@ function ParticleBenchPage() {
setTimeout(() => setBatchMsg(''), 3_000);
}, [addLog]);

// URL 쿼리 동기화 (debounce 없이, motion은 slider가 완만하게 바뀜)
useEffect(() => {
router.replace(`?${motionToSearchString(fx, motion)}`, { scroll: false });
}, [fx, motion]); // eslint-disable-line react-hooks/exhaustive-deps
router.replace(`?${motionToSearchString(motion)}`, { scroll: false });
}, [motion]); // eslint-disable-line react-hooks/exhaustive-deps

const setParam = useCallback((key: keyof MotionParams, value: number) => {
setMotion((prev) => ({ ...prev, [key]: value }));
}, []);

const handleCopyJson = useCallback(() => {
const json = JSON.stringify({ fx, ...motion }, null, 2);
const json = JSON.stringify(motion, null, 2);
navigator.clipboard.writeText(json).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1_500);
});
}, [fx, motion]);
}, [motion]);

const handleReset = useCallback(() => {
setMotion({ ...DEFAULT_MOTION });
setFx('light');
}, []);

return (
<div style={{ minHeight: '100dvh', background: '#111', color: '#eee', fontFamily: 'monospace', display: 'flex', flexDirection: 'column' }}>

{/* 컨트롤 패널 */}
<div style={{ padding: '12px 16px', display: 'flex', flexWrap: 'wrap', gap: '12px', alignItems: 'center', background: '#1a1a1a', borderBottom: '1px solid #333' }}>
<strong style={{ fontSize: 13 }}>particle-bench</strong>

Expand All @@ -225,18 +211,6 @@ function ParticleBenchPage() {
풀링 <span style={{ color: pooling ? '#4caf50' : '#f44336' }}>{pooling ? 'ON' : 'OFF'}</span>
</label>

{/* fx 토글 */}
<label style={{ display: 'flex', flexDirection: 'column', gap: 3, fontSize: 11 }}>
이펙트
<div style={{ display: 'flex', gap: 5 }}>
{(['light', 'bee'] as FxMode[]).map((mode) => (
<button key={mode} onClick={() => setFx(mode)} style={{ padding: '3px 8px', background: fx === mode ? '#f5c842' : '#333', color: fx === mode ? '#111' : '#eee', border: 'none', borderRadius: 3, cursor: 'pointer', fontSize: 11 }}>
{mode === 'light' ? '✨ light' : '🐝 bee'}
</button>
))}
</div>
</label>

<button onClick={() => setDebugOpen((v) => !v)} style={{ padding: '3px 8px', background: '#2a2a2a', color: '#aaa', border: '1px solid #444', borderRadius: 3, cursor: 'pointer', fontSize: 11 }}>
{debugOpen ? '▲ 디버그 패널' : '▼ 디버그 패널'}
</button>
Expand All @@ -252,7 +226,6 @@ function ParticleBenchPage() {
</div>
</div>

{/* 디버그 패널 */}
{debugOpen && (
<div style={{ padding: '12px 16px', background: '#141414', borderBottom: '1px solid #2a2a2a', display: 'flex', flexDirection: 'column', gap: 8 }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 4 }}>
Expand All @@ -266,21 +239,12 @@ function ParticleBenchPage() {
</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(280px, 1fr))', gap: '6px 20px' }}>
<SliderRow label="벌 크기 (px)" value={motion.beeH} {...MOTION_LIMITS.beeH} onChange={(v) => setParam('beeH', v)} />
<SliderRow label="me 비행 (ms)" value={motion.meDurationMs} {...MOTION_LIMITS.meDurationMs} onChange={(v) => setParam('meDurationMs', v)} />
<SliderRow label="other 비행 (ms)" value={motion.otherDurationMs} {...MOTION_LIMITS.otherDurationMs} onChange={(v) => setParam('otherDurationMs', v)} />
<SliderRow label="꼬리 방출량" value={motion.tailRate} {...MOTION_LIMITS.tailRate} onChange={(v) => setParam('tailRate', v)} />
<SliderRow label="꼬리 입자 (px)" value={motion.tailParticleSize} {...MOTION_LIMITS.tailParticleSize} onChange={(v) => setParam('tailParticleSize', v)} />
<SliderRow label="사인 진폭 (px)" value={motion.sineAmp} {...MOTION_LIMITS.sineAmp} onChange={(v) => setParam('sineAmp', v)} />
<SliderRow label="시작 x 지터 (px)" value={motion.startXJitter} {...MOTION_LIMITS.startXJitter} onChange={(v) => setParam('startXJitter', v)} />
</div>
<div style={{ fontSize: 10, color: '#3a3a3a', marginTop: 2 }}>
light 모드: 벌 크기·꼬리·비행 슬라이더는 bee 모드에서만 유효
</div>
</div>
)}

{/* 배지 + 반응 버튼 + 시뮬레이션 */}
<div style={{ padding: '14px 16px', background: '#181818', borderBottom: '1px solid #2a2a2a', display: 'flex', flexWrap: 'wrap', gap: 20, alignItems: 'flex-start' }}>
<div>
<div style={{ fontSize: 10, color: '#555', marginBottom: 6 }}>배지</div>
Expand Down Expand Up @@ -314,27 +278,19 @@ function ParticleBenchPage() {
</div>
</div>

{/* 캔버스 영역 */}
<div style={{ flex: 1, position: 'relative', overflow: 'hidden', minHeight: 360 }}>
<div style={{
position: 'absolute', inset: 0,
background: fx === 'light'
? 'linear-gradient(180deg, #f8f6f0 0%, #faf4e8 60%, #f5efe0 100%)'
: 'linear-gradient(180deg, #1a0e05 0%, #2d1a0a 60%, #1a0e05 100%)',
transition: 'background 0.4s ease',
background: 'linear-gradient(180deg, #f8f6f0 0%, #faf4e8 60%, #f5efe0 100%)',
}} />
<WarmthParticleCanvas
key={`${String(pooling)}-${fx}`}
key={String(pooling)}
onMount={handleMount}
poolingEnabled={pooling}
fx={fx}
motionParams={motion}
onOtherBatch={handleOtherBatch}
style={{ position: 'absolute', inset: 0 }}
/>
<div style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', color: fx === 'light' ? 'rgba(0,0,0,0.2)' : 'rgba(255,255,255,0.25)', fontSize: 12, pointerEvents: 'none', whiteSpace: 'nowrap' }}>
{fx === 'light' ? '✨ light 모드 (흰 배경 확인)' : '🐝 bee 모드'}
</div>
</div>

<div style={{ padding: '10px 16px', fontSize: 10, color: '#555', background: '#1a1a1a', borderTop: '1px solid #333' }}>
Expand Down
97 changes: 85 additions & 12 deletions src/features/map/collaboration/CollabPinsOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import { useEffect, useRef } from 'react';
import styled from '@emotion/styled';
import { HugeiconsIcon } from '@hugeicons/react';
import { Location01Icon } from '@hugeicons/core-free-icons';
import { Location01Icon, Cancel01Icon, UserMultiple02Icon } from '@hugeicons/core-free-icons';
import { toast } from 'sonner';
import { useSelf } from '@liveblocks/react';
import { useMapStore } from '../hooks/useMapStore';
Expand All @@ -28,6 +28,18 @@ function colorForUser(userId: string) {
return USER_COLORS[hash % USER_COLORS.length];
}

const PLACE_NAME_MAX = 80;

function isValidPin(pin: CollabPin): boolean {
return (
typeof pin.placeId === 'string' && pin.placeId.length > 0 &&
typeof pin.placeName === 'string' && pin.placeName.length > 0 && pin.placeName.length <= PLACE_NAME_MAX &&
typeof pin.userId === 'string' && pin.userId.length > 0 &&
Number.isFinite(pin.lat) && pin.lat >= -90 && pin.lat <= 90 &&
Number.isFinite(pin.lng) && pin.lng >= -180 && pin.lng <= 180
);
}

function buildPinEl(pin: CollabPin): HTMLElement {
const color = colorForUser(pin.userId);
const pinIcon = mapIconSvg('mapPin', 14);
Expand Down Expand Up @@ -137,20 +149,26 @@ export function CollabPinsOverlay() {
const map = useMapStore((s) => s.map);
const mode = useMapStore((s) => s.mode);
const items = useMapStore((s) => s.items);
const viewportItems = useMapStore((s) => s.viewportItems);
const detailId = useMapStore((s) => s.detailId);
const me = useSelf();
const { pins, addPin, removePin } = useCollabPins();

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const overlaysRef = useRef<Map<string, any>>(new Map());
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const polylineRef = useRef<any>(null);
const prevPinKeysRef = useRef<Set<string>>(new Set());
const initializedRef = useRef(false);

// Sync Liveblocks pins → Kakao CustomOverlay instances
// Sync Liveblocks pins → Kakao CustomOverlay instances + Polyline
useEffect(() => {
if (!map || !pins) return;

if (mode !== 'info') {
overlaysRef.current.forEach((o) => o.setMap(null));
overlaysRef.current.clear();
if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; }
return;
}

Expand All @@ -166,13 +184,7 @@ export function CollabPinsOverlay() {

// Add new overlays — validate each pin before rendering (untrusted shared state)
pins.forEach((pin) => {
if (
typeof pin.placeId !== 'string' || !pin.placeId ||
typeof pin.placeName !== 'string' || !pin.placeName ||
typeof pin.userId !== 'string' || !pin.userId ||
!Number.isFinite(pin.lat) || pin.lat < -90 || pin.lat > 90 ||
!Number.isFinite(pin.lng) || pin.lng < -180 || pin.lng > 180
) return;
if (!isValidPin(pin)) return;

const key = `${pin.placeId}-${pin.userId}`;
if (overlaysRef.current.has(key)) return;
Expand All @@ -191,19 +203,76 @@ export function CollabPinsOverlay() {
// Malformed pin — skip silently
}
});

// Draw polyline connecting all pins when 2+
if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; }
const validPins = pins.filter(isValidPin);
if (validPins.length >= 2) {
try {
const path = validPins.map((p) => new window.kakao.maps.LatLng(p.lat, p.lng));
const polyline = new window.kakao.maps.Polyline({
path,
strokeWeight: 2,
strokeColor: '#4B8BFF',
strokeOpacity: 0.6,
strokeStyle: 'dash',
});
polyline.setMap(map);
polylineRef.current = polyline;
} catch { /* kakao not ready */ }
}
}, [map, pins, mode]);

// Notify other participants when someone adds a new pin
useEffect(() => {
if (pins === null) return;

const myUserId = me?.presence?.userId ?? 'guest';
const currentKeys = new Set(pins.map((p) => `${p.placeId}-${p.userId}`));

if (!initializedRef.current) {
initializedRef.current = true;
prevPinKeysRef.current = currentKeys;
return;
}

for (const pin of pins) {
const key = `${pin.placeId}-${pin.userId}`;
if (!prevPinKeysRef.current.has(key) && pin.userId !== myUserId && isValidPin(pin)) {
toast(pin.placeName, {
icon: <HugeiconsIcon icon={UserMultiple02Icon} size={16} strokeWidth={1.8} />,
description: '함께하는 친구가 핀을 찍었어요!',
duration: 3000,
});
}
}

prevPinKeysRef.current = currentKeys;
}, [pins, me?.presence?.userId]);

// Cleanup on unmount
useEffect(() => {
return () => {
overlaysRef.current.forEach((o) => o.setMap(null));
overlaysRef.current.clear();
if (polylineRef.current) { polylineRef.current.setMap(null); polylineRef.current = null; }
};
}, []);

if (!map || mode !== 'info') return null;

const selectedPlace = detailId ? items.find((i) => i.id === detailId) : null;
// Find selected place from both items (non-info) and viewportItems (info mode)
const selectedPlace = detailId ? (() => {
const fromItems = items.find((i) => i.id === detailId);
if (fromItems) return fromItems;

const vp = viewportItems.find((v) => v.type === 'PLACE' && v.placeId === detailId);
if (vp && vp.placeId && vp.name) {
return { id: vp.placeId, name: vp.name, lat: vp.center.lat, lng: vp.center.lng };
}
return null;
})() : null;

const myUserId = me?.presence?.userId ?? 'guest';
const alreadyPinned = selectedPlace
? (pins ?? []).some((p) => p.placeId === selectedPlace.id && p.userId === myUserId)
Expand All @@ -219,13 +288,17 @@ export function CollabPinsOverlay() {
userId: myUserId,
};
addPin(pin);
toast.success(`'${selectedPlace.name}' 핀을 공유했어요!`);
toast.success(`'${selectedPlace.name}' 핀을 공유했어요!`, {
icon: <HugeiconsIcon icon={Location01Icon} size={16} strokeWidth={1.8} />,
});
}

function handleRemovePin() {
if (!selectedPlace) return;
removePin(selectedPlace.id);
toast.info(`핀을 제거했어요.`);
toast.info(`핀을 제거했어요.`, {
icon: <HugeiconsIcon icon={Cancel01Icon} size={16} strokeWidth={1.8} />,
});
}

if (!selectedPlace) return null;
Expand Down
Loading
Loading