Initial import
This commit is contained in:
286
src/layer/gesture/context/GestureContext.tsx
Executable file
286
src/layer/gesture/context/GestureContext.tsx
Executable file
@@ -0,0 +1,286 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import type { GestureLayerDefinition, PageGestureDefinition, PageGestureState } from '../types';
|
||||
|
||||
type GestureTrackingState = {
|
||||
gestureId: string;
|
||||
startX: number;
|
||||
startY: number;
|
||||
readyToTrigger: boolean;
|
||||
};
|
||||
|
||||
type GestureContextValue = {
|
||||
pageState: PageGestureState;
|
||||
setPageState: (nextState: PageGestureState) => void;
|
||||
registerLayer: (layer: GestureLayerDefinition) => void;
|
||||
unregisterLayer: (layerId: string) => void;
|
||||
};
|
||||
|
||||
const GestureContext = createContext<GestureContextValue | null>(null);
|
||||
|
||||
function isInteractiveElement(target: EventTarget | null) {
|
||||
if (!(target instanceof Element)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Boolean(
|
||||
target.closest(
|
||||
'button, a, input, select, textarea, summary, label, [role="button"], [role="link"], [data-no-gesture]',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function isGestureAvailable(pageState: PageGestureState, gesture: PageGestureDefinition) {
|
||||
if (gesture.enabled === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!gesture.activeStates || gesture.activeStates.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return gesture.activeStates.includes(pageState);
|
||||
}
|
||||
|
||||
export function GestureProvider({ children }: PropsWithChildren) {
|
||||
const [pageState, setPageState] = useState<PageGestureState>('anyway');
|
||||
const [mobileViewport, setMobileViewport] = useState(false);
|
||||
const [layerVersion, setLayerVersion] = useState(0);
|
||||
const layersRef = useRef<GestureLayerDefinition[]>([]);
|
||||
const trackingRef = useRef<GestureTrackingState | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const mediaQuery = window.matchMedia('(max-width: 768px)');
|
||||
const updateViewport = () => {
|
||||
setMobileViewport(mediaQuery.matches);
|
||||
};
|
||||
|
||||
updateViewport();
|
||||
mediaQuery.addEventListener('change', updateViewport);
|
||||
|
||||
return () => {
|
||||
mediaQuery.removeEventListener('change', updateViewport);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveGesture = (gestureId: string) => {
|
||||
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
|
||||
const layer = layersRef.current[layerIndex];
|
||||
const gesture = layer.gestures.find((candidate) => candidate.id === gestureId);
|
||||
|
||||
if (gesture) {
|
||||
return gesture;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const topRightGesture = useMemo(() => {
|
||||
const resolveGestureByTrigger = (trigger: PageGestureDefinition['trigger']) => {
|
||||
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
|
||||
const layer = layersRef.current[layerIndex];
|
||||
|
||||
if (layer.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let gestureIndex = layer.gestures.length - 1; gestureIndex >= 0; gestureIndex -= 1) {
|
||||
const gesture = layer.gestures[gestureIndex];
|
||||
|
||||
if (!isGestureAvailable(pageState, gesture)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gesture.mobileOnly && !mobileViewport) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gesture.trigger === trigger) {
|
||||
return gesture;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return resolveGestureByTrigger('pull-down-top-right');
|
||||
}, [layerVersion, mobileViewport, pageState]);
|
||||
|
||||
const middleRightGesture = useMemo(() => {
|
||||
for (let layerIndex = layersRef.current.length - 1; layerIndex >= 0; layerIndex -= 1) {
|
||||
const layer = layersRef.current[layerIndex];
|
||||
|
||||
if (layer.enabled === false) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (let gestureIndex = layer.gestures.length - 1; gestureIndex >= 0; gestureIndex -= 1) {
|
||||
const gesture = layer.gestures[gestureIndex];
|
||||
|
||||
if (!isGestureAvailable(pageState, gesture)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gesture.mobileOnly && !mobileViewport) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (gesture.trigger === 'pull-left-middle-right') {
|
||||
return gesture;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [layerVersion, mobileViewport, pageState]);
|
||||
|
||||
const resetTracking = () => {
|
||||
trackingRef.current = null;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if ((!topRightGesture && !middleRightGesture) || !mobileViewport) {
|
||||
trackingRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const handleWindowTouchStart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
|
||||
if (!touch) {
|
||||
trackingRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const inTopRightZone = Boolean(
|
||||
topRightGesture &&
|
||||
touch.clientX >= window.innerWidth - (topRightGesture.hotZoneSize ?? 120) &&
|
||||
touch.clientY <= (topRightGesture.hotZoneSize ?? 120),
|
||||
);
|
||||
const middleHotZoneSize = middleRightGesture?.hotZoneSize ?? 120;
|
||||
const middleZoneTop = window.innerHeight * 0.25;
|
||||
const middleZoneBottom = window.innerHeight * 0.75;
|
||||
const inMiddleRightZone = Boolean(
|
||||
middleRightGesture &&
|
||||
touch.clientX >= window.innerWidth - middleHotZoneSize &&
|
||||
touch.clientY >= middleZoneTop &&
|
||||
touch.clientY <= middleZoneBottom,
|
||||
);
|
||||
|
||||
if ((!inTopRightZone && !inMiddleRightZone) || isInteractiveElement(event.target)) {
|
||||
trackingRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
trackingRef.current = {
|
||||
gestureId: inTopRightZone && topRightGesture ? topRightGesture.id : middleRightGesture!.id,
|
||||
startX: touch.clientX,
|
||||
startY: touch.clientY,
|
||||
readyToTrigger: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleWindowTouchMove = (event: TouchEvent) => {
|
||||
const tracking = trackingRef.current;
|
||||
|
||||
if (!tracking) {
|
||||
return;
|
||||
}
|
||||
|
||||
const touch = event.touches[0];
|
||||
const gesture = resolveGesture(tracking.gestureId);
|
||||
|
||||
if (!touch || !gesture || !isGestureAvailable(pageState, gesture)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deltaX = touch.clientX - tracking.startX;
|
||||
const deltaY = touch.clientY - tracking.startY;
|
||||
const minDistance = gesture.minDistance ?? 72;
|
||||
const maxHorizontalDrift = gesture.maxHorizontalDrift ?? 96;
|
||||
const isReady =
|
||||
gesture.trigger === 'pull-left-middle-right'
|
||||
? deltaX <= minDistance * -1 && Math.abs(deltaY) <= maxHorizontalDrift
|
||||
: deltaY >= minDistance && Math.abs(deltaX) <= maxHorizontalDrift;
|
||||
|
||||
if (tracking.readyToTrigger !== isReady) {
|
||||
trackingRef.current = {
|
||||
...tracking,
|
||||
readyToTrigger: isReady,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleWindowTouchEnd = () => {
|
||||
const tracking = trackingRef.current;
|
||||
trackingRef.current = null;
|
||||
|
||||
if (!tracking?.readyToTrigger) {
|
||||
return;
|
||||
}
|
||||
|
||||
const gesture = resolveGesture(tracking.gestureId);
|
||||
|
||||
if (!gesture || !isGestureAvailable(pageState, gesture)) {
|
||||
return;
|
||||
}
|
||||
|
||||
gesture.onTrigger();
|
||||
};
|
||||
|
||||
window.addEventListener('touchstart', handleWindowTouchStart, { passive: true });
|
||||
window.addEventListener('touchmove', handleWindowTouchMove, { passive: true });
|
||||
window.addEventListener('touchend', handleWindowTouchEnd, { passive: true });
|
||||
window.addEventListener('touchcancel', resetTracking, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', handleWindowTouchStart);
|
||||
window.removeEventListener('touchmove', handleWindowTouchMove);
|
||||
window.removeEventListener('touchend', handleWindowTouchEnd);
|
||||
window.removeEventListener('touchcancel', resetTracking);
|
||||
};
|
||||
}, [middleRightGesture, mobileViewport, pageState, topRightGesture]);
|
||||
|
||||
const value = useMemo<GestureContextValue>(
|
||||
() => ({
|
||||
pageState,
|
||||
setPageState,
|
||||
registerLayer: (layer) => {
|
||||
const nextLayers = layersRef.current.filter((candidate) => candidate.id !== layer.id);
|
||||
nextLayers.push(layer);
|
||||
layersRef.current = nextLayers;
|
||||
setLayerVersion((previous) => previous + 1);
|
||||
},
|
||||
unregisterLayer: (layerId) => {
|
||||
layersRef.current = layersRef.current.filter((candidate) => candidate.id !== layerId);
|
||||
setLayerVersion((previous) => previous + 1);
|
||||
},
|
||||
}),
|
||||
[pageState],
|
||||
);
|
||||
|
||||
return (
|
||||
<GestureContext.Provider value={value}>
|
||||
{children}
|
||||
</GestureContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useGestureContext() {
|
||||
const context = useContext(GestureContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useGestureContext must be used within a GestureProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
23
src/layer/gesture/hooks/useGestureLayer.ts
Executable file
23
src/layer/gesture/hooks/useGestureLayer.ts
Executable file
@@ -0,0 +1,23 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useGestureContext } from '../context/GestureContext';
|
||||
import type { GestureLayerDefinition, PageGestureState } from '../types';
|
||||
|
||||
export function useGesturePageState(pageState: PageGestureState) {
|
||||
const { setPageState } = useGestureContext();
|
||||
|
||||
useEffect(() => {
|
||||
setPageState(pageState);
|
||||
}, [pageState, setPageState]);
|
||||
}
|
||||
|
||||
export function useGestureLayer(layer: GestureLayerDefinition) {
|
||||
const { registerLayer, unregisterLayer } = useGestureContext();
|
||||
|
||||
useEffect(() => {
|
||||
registerLayer(layer);
|
||||
|
||||
return () => {
|
||||
unregisterLayer(layer.id);
|
||||
};
|
||||
}, [layer, registerLayer, unregisterLayer]);
|
||||
}
|
||||
3
src/layer/gesture/index.ts
Executable file
3
src/layer/gesture/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export * from './context/GestureContext';
|
||||
export * from './hooks/useGestureLayer';
|
||||
export * from './types';
|
||||
19
src/layer/gesture/types/index.ts
Executable file
19
src/layer/gesture/types/index.ts
Executable file
@@ -0,0 +1,19 @@
|
||||
export type PageGestureState = 'anyway';
|
||||
|
||||
export type PageGestureDefinition = {
|
||||
id: string;
|
||||
activeStates?: PageGestureState[];
|
||||
enabled?: boolean;
|
||||
mobileOnly?: boolean;
|
||||
trigger: 'pull-down-top-right' | 'pull-left-middle-right';
|
||||
hotZoneSize?: number;
|
||||
minDistance?: number;
|
||||
maxHorizontalDrift?: number;
|
||||
onTrigger: () => void;
|
||||
};
|
||||
|
||||
export type GestureLayerDefinition = {
|
||||
id: string;
|
||||
enabled?: boolean;
|
||||
gestures: PageGestureDefinition[];
|
||||
};
|
||||
419
src/layer/gps/context/GpsLayerContext.tsx
Executable file
419
src/layer/gps/context/GpsLayerContext.tsx
Executable file
@@ -0,0 +1,419 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { sendClientNotification } from '../../../app/main/notificationApi';
|
||||
import type {
|
||||
GpsAnchor,
|
||||
GpsConfig,
|
||||
GpsCoordinates,
|
||||
GpsGeofenceEvent,
|
||||
GpsLayerSnapshot,
|
||||
GpsPermissionState,
|
||||
} from '../types';
|
||||
|
||||
type GpsLayerContextValue = GpsLayerSnapshot & {
|
||||
enableGps: () => void;
|
||||
disableGps: () => void;
|
||||
toggleGps: () => void;
|
||||
saveCurrentAnchor: (name: string) => { ok: boolean; message?: string; anchor?: GpsAnchor };
|
||||
removeAnchor: (anchorId: string) => void;
|
||||
selectAnchor: (anchorId: string | null) => void;
|
||||
updateConfig: (patch: Partial<GpsConfig>) => void;
|
||||
clearEvents: () => void;
|
||||
};
|
||||
|
||||
const GPS_STORAGE_KEY = 'gps-layer:anchors';
|
||||
const GPS_CONFIG_STORAGE_KEY = 'gps-layer:config';
|
||||
const GPS_SELECTED_STORAGE_KEY = 'gps-layer:selected-anchor';
|
||||
const GPS_EVENTS_LIMIT = 24;
|
||||
const DEFAULT_CONFIG: GpsConfig = {
|
||||
notifyOnEnter: true,
|
||||
notifyOnExit: true,
|
||||
radiusMeters: 120,
|
||||
};
|
||||
|
||||
const GpsLayerContext = createContext<GpsLayerContextValue | null>(null);
|
||||
|
||||
function clampRadiusMeters(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_CONFIG.radiusMeters;
|
||||
}
|
||||
|
||||
return Math.min(5000, Math.max(30, Math.round(value)));
|
||||
}
|
||||
|
||||
function readJsonStorage<T>(key: string, fallback: T, parse: (value: unknown) => T) {
|
||||
if (typeof window === 'undefined') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
const raw = window.localStorage.getItem(key);
|
||||
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function createAnchorId() {
|
||||
return `gps-anchor-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
function toCoordinates(position: GeolocationPosition): GpsCoordinates {
|
||||
return {
|
||||
latitude: position.coords.latitude,
|
||||
longitude: position.coords.longitude,
|
||||
accuracy: Number.isFinite(position.coords.accuracy) ? position.coords.accuracy : null,
|
||||
altitude: Number.isFinite(position.coords.altitude ?? NaN) ? position.coords.altitude ?? null : null,
|
||||
heading: Number.isFinite(position.coords.heading ?? NaN) ? position.coords.heading ?? null : null,
|
||||
speed: Number.isFinite(position.coords.speed ?? NaN) ? position.coords.speed ?? null : null,
|
||||
timestamp: position.timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
function toRadians(value: number) {
|
||||
return (value * Math.PI) / 180;
|
||||
}
|
||||
|
||||
function calculateDistanceMeters(
|
||||
latitudeA: number,
|
||||
longitudeA: number,
|
||||
latitudeB: number,
|
||||
longitudeB: number,
|
||||
) {
|
||||
const earthRadiusMeters = 6371000;
|
||||
const deltaLatitude = toRadians(latitudeB - latitudeA);
|
||||
const deltaLongitude = toRadians(longitudeB - longitudeA);
|
||||
const sinLatitude = Math.sin(deltaLatitude / 2);
|
||||
const sinLongitude = Math.sin(deltaLongitude / 2);
|
||||
const root =
|
||||
sinLatitude * sinLatitude +
|
||||
Math.cos(toRadians(latitudeA)) *
|
||||
Math.cos(toRadians(latitudeB)) *
|
||||
sinLongitude *
|
||||
sinLongitude;
|
||||
|
||||
return 2 * earthRadiusMeters * Math.atan2(Math.sqrt(root), Math.sqrt(1 - root));
|
||||
}
|
||||
|
||||
function parseAnchors(value: unknown): GpsAnchor[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value.flatMap((item) => {
|
||||
if (!item || typeof item !== 'object') {
|
||||
return [];
|
||||
}
|
||||
|
||||
const candidate = item as Partial<GpsAnchor>;
|
||||
|
||||
if (
|
||||
typeof candidate.id !== 'string' ||
|
||||
typeof candidate.name !== 'string' ||
|
||||
typeof candidate.createdAt !== 'string' ||
|
||||
!Number.isFinite(candidate.latitude) ||
|
||||
!Number.isFinite(candidate.longitude)
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
id: candidate.id,
|
||||
name: candidate.name,
|
||||
latitude: Number(candidate.latitude),
|
||||
longitude: Number(candidate.longitude),
|
||||
createdAt: candidate.createdAt,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
function parseConfig(value: unknown): GpsConfig {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return DEFAULT_CONFIG;
|
||||
}
|
||||
|
||||
const candidate = value as Partial<GpsConfig>;
|
||||
|
||||
return {
|
||||
notifyOnEnter: candidate.notifyOnEnter ?? DEFAULT_CONFIG.notifyOnEnter,
|
||||
notifyOnExit: candidate.notifyOnExit ?? DEFAULT_CONFIG.notifyOnExit,
|
||||
radiusMeters: clampRadiusMeters(candidate.radiusMeters ?? DEFAULT_CONFIG.radiusMeters),
|
||||
};
|
||||
}
|
||||
|
||||
function sendGpsPushNotification(event: GpsGeofenceEvent) {
|
||||
return sendClientNotification({
|
||||
title: `GPS 거점 ${event.type === 'enter' ? 'In' : 'Out'} 알림`,
|
||||
body: `${event.anchorName} ${event.type === 'enter' ? '진입' : '이탈'} 감지 (${event.distanceMeters}m)`,
|
||||
threadId: 'gps-geofence',
|
||||
data: {
|
||||
category: 'gps-geofence',
|
||||
eventType: event.type,
|
||||
anchorId: event.anchorId,
|
||||
anchorName: event.anchorName,
|
||||
distanceMeters: String(event.distanceMeters),
|
||||
createdAt: event.createdAt,
|
||||
},
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
|
||||
export function GpsLayerProvider({ children }: PropsWithChildren) {
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [permissionState, setPermissionState] = useState<GpsPermissionState>('idle');
|
||||
const [currentPosition, setCurrentPosition] = useState<GpsCoordinates | null>(null);
|
||||
const [anchors, setAnchors] = useState<GpsAnchor[]>(() => readJsonStorage(GPS_STORAGE_KEY, [], parseAnchors));
|
||||
const [config, setConfig] = useState<GpsConfig>(() =>
|
||||
readJsonStorage(GPS_CONFIG_STORAGE_KEY, DEFAULT_CONFIG, parseConfig),
|
||||
);
|
||||
const [selectedAnchorId, setSelectedAnchorId] = useState<string | null>(() =>
|
||||
readJsonStorage(GPS_SELECTED_STORAGE_KEY, null, (value) => (typeof value === 'string' ? value : null)),
|
||||
);
|
||||
const [anchorPresence, setAnchorPresence] = useState<Record<string, boolean>>({});
|
||||
const [events, setEvents] = useState<GpsGeofenceEvent[]>([]);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
const watchIdRef = useRef<number | null>(null);
|
||||
const anchorPresenceRef = useRef<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(GPS_STORAGE_KEY, JSON.stringify(anchors));
|
||||
}, [anchors]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.setItem(GPS_CONFIG_STORAGE_KEY, JSON.stringify(config));
|
||||
}, [config]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedAnchorId) {
|
||||
window.localStorage.setItem(GPS_SELECTED_STORAGE_KEY, selectedAnchorId);
|
||||
return;
|
||||
}
|
||||
|
||||
window.localStorage.removeItem(GPS_SELECTED_STORAGE_KEY);
|
||||
}, [selectedAnchorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedAnchorId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedExists = anchors.some((anchor) => anchor.id === selectedAnchorId);
|
||||
|
||||
if (!selectedExists) {
|
||||
setSelectedAnchorId(anchors[0]?.id ?? null);
|
||||
}
|
||||
}, [anchors, selectedAnchorId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof navigator === 'undefined' || !('geolocation' in navigator)) {
|
||||
setPermissionState('unsupported');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!enabled) {
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
watchIdRef.current = null;
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setPermissionState((previous) => (previous === 'granted' ? previous : 'requesting'));
|
||||
setErrorMessage(null);
|
||||
|
||||
watchIdRef.current = navigator.geolocation.watchPosition(
|
||||
(position) => {
|
||||
setCurrentPosition(toCoordinates(position));
|
||||
setPermissionState('granted');
|
||||
setErrorMessage(null);
|
||||
},
|
||||
(error) => {
|
||||
setPermissionState(error.code === error.PERMISSION_DENIED ? 'denied' : 'error');
|
||||
setErrorMessage(error.message || 'GPS 위치를 읽지 못했습니다.');
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
maximumAge: 5000,
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
return () => {
|
||||
if (watchIdRef.current !== null) {
|
||||
navigator.geolocation.clearWatch(watchIdRef.current);
|
||||
watchIdRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentPosition || anchors.length === 0) {
|
||||
anchorPresenceRef.current = {};
|
||||
setAnchorPresence({});
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPresence: Record<string, boolean> = {};
|
||||
const nextEvents: GpsGeofenceEvent[] = [];
|
||||
|
||||
for (const anchor of anchors) {
|
||||
const distanceMeters = calculateDistanceMeters(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
anchor.latitude,
|
||||
anchor.longitude,
|
||||
);
|
||||
const isInside = distanceMeters <= config.radiusMeters;
|
||||
const previousPresence = anchorPresenceRef.current[anchor.id];
|
||||
|
||||
nextPresence[anchor.id] = isInside;
|
||||
|
||||
if (typeof previousPresence === 'boolean' && previousPresence !== isInside) {
|
||||
const type = isInside ? 'enter' : 'exit';
|
||||
const shouldNotify =
|
||||
(type === 'enter' && config.notifyOnEnter) || (type === 'exit' && config.notifyOnExit);
|
||||
|
||||
const event: GpsGeofenceEvent = {
|
||||
id: `gps-event-${Date.now()}-${anchor.id}-${type}`,
|
||||
anchorId: anchor.id,
|
||||
anchorName: anchor.name,
|
||||
type,
|
||||
distanceMeters: Math.round(distanceMeters),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
nextEvents.push(event);
|
||||
|
||||
if (shouldNotify) {
|
||||
void sendGpsPushNotification(event);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anchorPresenceRef.current = nextPresence;
|
||||
setAnchorPresence(nextPresence);
|
||||
|
||||
if (nextEvents.length > 0) {
|
||||
setEvents((previous) => [...nextEvents.reverse(), ...previous].slice(0, GPS_EVENTS_LIMIT));
|
||||
}
|
||||
}, [anchors, config.notifyOnEnter, config.notifyOnExit, config.radiusMeters, currentPosition]);
|
||||
|
||||
const value = useMemo<GpsLayerContextValue>(
|
||||
() => ({
|
||||
enabled,
|
||||
supported: typeof navigator !== 'undefined' && 'geolocation' in navigator,
|
||||
permissionState,
|
||||
currentPosition,
|
||||
anchors,
|
||||
config,
|
||||
selectedAnchorId,
|
||||
anchorPresence,
|
||||
events,
|
||||
errorMessage,
|
||||
enableGps: () => {
|
||||
setEnabled(true);
|
||||
},
|
||||
disableGps: () => {
|
||||
setEnabled(false);
|
||||
},
|
||||
toggleGps: () => {
|
||||
setEnabled((previous) => !previous);
|
||||
},
|
||||
saveCurrentAnchor: (name: string) => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (!currentPosition) {
|
||||
return {
|
||||
ok: false,
|
||||
message: '현재 GPS 좌표가 없어 거점을 저장할 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
if (!trimmedName) {
|
||||
return {
|
||||
ok: false,
|
||||
message: '거점명을 입력해 주세요.',
|
||||
};
|
||||
}
|
||||
|
||||
const nextAnchor: GpsAnchor = {
|
||||
id: createAnchorId(),
|
||||
name: trimmedName,
|
||||
latitude: currentPosition.latitude,
|
||||
longitude: currentPosition.longitude,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setAnchors((previous) => [nextAnchor, ...previous]);
|
||||
setSelectedAnchorId(nextAnchor.id);
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
anchor: nextAnchor,
|
||||
};
|
||||
},
|
||||
removeAnchor: (anchorId) => {
|
||||
setAnchors((previous) => previous.filter((anchor) => anchor.id !== anchorId));
|
||||
setAnchorPresence((previous) => {
|
||||
const nextPresence = { ...previous };
|
||||
delete nextPresence[anchorId];
|
||||
anchorPresenceRef.current = nextPresence;
|
||||
return nextPresence;
|
||||
});
|
||||
},
|
||||
selectAnchor: (anchorId) => {
|
||||
setSelectedAnchorId(anchorId);
|
||||
},
|
||||
updateConfig: (patch) => {
|
||||
setConfig((previous) => ({
|
||||
notifyOnEnter: patch.notifyOnEnter ?? previous.notifyOnEnter,
|
||||
notifyOnExit: patch.notifyOnExit ?? previous.notifyOnExit,
|
||||
radiusMeters:
|
||||
patch.radiusMeters === undefined ? previous.radiusMeters : clampRadiusMeters(patch.radiusMeters),
|
||||
}));
|
||||
},
|
||||
clearEvents: () => {
|
||||
setEvents([]);
|
||||
},
|
||||
}),
|
||||
[anchorPresence, anchors, config, currentPosition, enabled, errorMessage, events, permissionState, selectedAnchorId],
|
||||
);
|
||||
|
||||
return <GpsLayerContext.Provider value={value}>{children}</GpsLayerContext.Provider>;
|
||||
}
|
||||
|
||||
export function useGpsLayerContext() {
|
||||
const context = useContext(GpsLayerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useGpsLayerContext must be used within a GpsLayerProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
5
src/layer/gps/hooks/useGpsLayer.ts
Executable file
5
src/layer/gps/hooks/useGpsLayer.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import { useGpsLayerContext } from '../context/GpsLayerContext';
|
||||
|
||||
export function useGpsLayer() {
|
||||
return useGpsLayerContext();
|
||||
}
|
||||
3
src/layer/gps/index.ts
Executable file
3
src/layer/gps/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export * from './context/GpsLayerContext';
|
||||
export * from './hooks/useGpsLayer';
|
||||
export * from './types';
|
||||
55
src/layer/gps/types/index.ts
Executable file
55
src/layer/gps/types/index.ts
Executable file
@@ -0,0 +1,55 @@
|
||||
export type GpsPermissionState =
|
||||
| 'idle'
|
||||
| 'requesting'
|
||||
| 'granted'
|
||||
| 'denied'
|
||||
| 'unsupported'
|
||||
| 'error';
|
||||
|
||||
export type GpsCoordinates = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy: number | null;
|
||||
altitude: number | null;
|
||||
heading: number | null;
|
||||
speed: number | null;
|
||||
timestamp: number;
|
||||
};
|
||||
|
||||
export type GpsAnchor = {
|
||||
id: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GpsConfig = {
|
||||
notifyOnEnter: boolean;
|
||||
notifyOnExit: boolean;
|
||||
radiusMeters: number;
|
||||
};
|
||||
|
||||
export type GpsGeofenceEventType = 'enter' | 'exit';
|
||||
|
||||
export type GpsGeofenceEvent = {
|
||||
id: string;
|
||||
anchorId: string;
|
||||
anchorName: string;
|
||||
type: GpsGeofenceEventType;
|
||||
distanceMeters: number;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type GpsLayerSnapshot = {
|
||||
enabled: boolean;
|
||||
supported: boolean;
|
||||
permissionState: GpsPermissionState;
|
||||
currentPosition: GpsCoordinates | null;
|
||||
anchors: GpsAnchor[];
|
||||
config: GpsConfig;
|
||||
selectedAnchorId: string | null;
|
||||
anchorPresence: Record<string, boolean>;
|
||||
events: GpsGeofenceEvent[];
|
||||
errorMessage: string | null;
|
||||
};
|
||||
3
src/layer/index.ts
Executable file
3
src/layer/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export * from './gesture';
|
||||
export * from './gps';
|
||||
export * from './search';
|
||||
122
src/layer/search/context/SearchLayerContext.tsx
Executable file
122
src/layer/search/context/SearchLayerContext.tsx
Executable file
@@ -0,0 +1,122 @@
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PropsWithChildren,
|
||||
} from 'react';
|
||||
import { SearchCommandModal, type SearchKeywordOption } from '../../../components/search';
|
||||
import type { SearchLayerSnapshot, SearchOpenMode, SearchWindowSelection, SearchWindowSelectionDraft } from '../types';
|
||||
|
||||
type SearchLayerContextValue = SearchLayerSnapshot & {
|
||||
setOptions: (options: SearchKeywordOption[]) => void;
|
||||
openSearch: (mode?: SearchOpenMode) => void;
|
||||
closeSearch: () => void;
|
||||
clearWindowSelection: (instanceId: string) => void;
|
||||
addWindowSelections: (selections: SearchWindowSelectionDraft[]) => void;
|
||||
};
|
||||
|
||||
const SearchLayerContext = createContext<SearchLayerContextValue | null>(null);
|
||||
const WINDOW_SELECTION_DEDUP_MS = 500;
|
||||
|
||||
export function SearchLayerProvider({ children }: PropsWithChildren) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [options, setOptions] = useState<SearchKeywordOption[]>([]);
|
||||
const [mode, setMode] = useState<SearchOpenMode>('navigate');
|
||||
const [windowSelections, setWindowSelections] = useState<SearchWindowSelection[]>([]);
|
||||
const lastWindowSelectionRef = useRef<{ id: string; at: number } | null>(null);
|
||||
|
||||
const value = useMemo<SearchLayerContextValue>(
|
||||
() => ({
|
||||
open,
|
||||
options,
|
||||
mode,
|
||||
windowSelections,
|
||||
setOptions,
|
||||
openSearch: (nextMode = 'navigate') => {
|
||||
lastWindowSelectionRef.current = null;
|
||||
setMode(nextMode);
|
||||
setOpen(true);
|
||||
},
|
||||
closeSearch: () => {
|
||||
setOpen(false);
|
||||
},
|
||||
clearWindowSelection: (instanceId: string) => {
|
||||
setWindowSelections((previous) => previous.filter((selection) => selection.instanceId !== instanceId));
|
||||
},
|
||||
addWindowSelections: (selections) => {
|
||||
if (selections.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setWindowSelections((previous) => [
|
||||
...previous,
|
||||
...selections.map((selection) => ({
|
||||
...selection,
|
||||
instanceId: `window-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
})),
|
||||
]);
|
||||
},
|
||||
}),
|
||||
[mode, open, options, windowSelections],
|
||||
);
|
||||
|
||||
return (
|
||||
<SearchLayerContext.Provider value={value}>
|
||||
{children}
|
||||
<SearchCommandModal
|
||||
open={open}
|
||||
options={options}
|
||||
mode={mode}
|
||||
onClose={value.closeSearch}
|
||||
onSelectOption={(option) => {
|
||||
if (mode === 'window') {
|
||||
const now = Date.now();
|
||||
const previousSelection = lastWindowSelectionRef.current;
|
||||
|
||||
if (
|
||||
previousSelection &&
|
||||
previousSelection.id === option.id &&
|
||||
now - previousSelection.at <= WINDOW_SELECTION_DEDUP_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
lastWindowSelectionRef.current = {
|
||||
id: option.id,
|
||||
at: now,
|
||||
};
|
||||
|
||||
setOpen(false);
|
||||
setWindowSelections((previous) => [
|
||||
...previous,
|
||||
{
|
||||
instanceId: `window-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
id: option.id,
|
||||
label: option.label,
|
||||
group: option.group,
|
||||
description: option.description,
|
||||
keywords: option.keywords ?? [],
|
||||
},
|
||||
]);
|
||||
(option.onSelectWindow ?? option.onSelect)();
|
||||
return;
|
||||
}
|
||||
|
||||
option.onSelect();
|
||||
}}
|
||||
/>
|
||||
</SearchLayerContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSearchLayerContext() {
|
||||
const context = useContext(SearchLayerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error('useSearchLayerContext must be used within a SearchLayerProvider.');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
5
src/layer/search/hooks/useSearchLayer.ts
Executable file
5
src/layer/search/hooks/useSearchLayer.ts
Executable file
@@ -0,0 +1,5 @@
|
||||
import { useSearchLayerContext } from '../context/SearchLayerContext';
|
||||
|
||||
export function useSearchLayer() {
|
||||
return useSearchLayerContext();
|
||||
}
|
||||
3
src/layer/search/index.ts
Executable file
3
src/layer/search/index.ts
Executable file
@@ -0,0 +1,3 @@
|
||||
export * from './context/SearchLayerContext';
|
||||
export * from './hooks/useSearchLayer';
|
||||
export * from './types';
|
||||
21
src/layer/search/types/index.ts
Executable file
21
src/layer/search/types/index.ts
Executable file
@@ -0,0 +1,21 @@
|
||||
import type { SearchKeywordOption } from '../../../components/search';
|
||||
|
||||
export type SearchOpenMode = 'navigate' | 'window';
|
||||
|
||||
export type SearchWindowSelection = {
|
||||
instanceId: string;
|
||||
id: string;
|
||||
label: string;
|
||||
group: string;
|
||||
description?: string;
|
||||
keywords: string[];
|
||||
};
|
||||
|
||||
export type SearchWindowSelectionDraft = Omit<SearchWindowSelection, 'instanceId'>;
|
||||
|
||||
export type SearchLayerSnapshot = {
|
||||
open: boolean;
|
||||
options: SearchKeywordOption[];
|
||||
mode: SearchOpenMode;
|
||||
windowSelections: SearchWindowSelection[];
|
||||
};
|
||||
Reference in New Issue
Block a user