diff --git a/src/components/AscGISMap.vue b/src/components/AscGISMap.vue index 23b8eb9..81da108 100644 --- a/src/components/AscGISMap.vue +++ b/src/components/AscGISMap.vue @@ -5,6 +5,7 @@ import axios from 'axios' import { useCounterMixin } from '@/stores/mixin' import { useQuasar } from 'quasar' import { usePrivacyStore } from '@/stores/privacy' +import { useLocationValidation } from '@/composables/useLocationValidation' const mixin = useCounterMixin() const { messageError } = mixin @@ -12,9 +13,11 @@ const privacyStore = usePrivacyStore() // import type { LocationObject } from '@/interface/index/Main' const mapElement = ref(null) -const emit = defineEmits(['update:location']) +const emit = defineEmits(['update:location', 'locationStatus', 'mockDetected']) const $q = useQuasar() +const { validateLocation, showMockWarning } = useLocationValidation() + function updateLocation(latitude: number, longitude: number, namePOI: string) { // ส่ง event ไปยัง parent component เพื่ออัพเดทค่า props emit('update:location', latitude, longitude, namePOI) @@ -199,7 +202,7 @@ async function initializeMap() { } } -const locationGranted = ref(false) +const locationGranted = ref(false) // Function to request location permission const requestLocationPermission = () => { // เช็คสิทธิ์ privacy ก่อนเข้าถึงแผนที่ @@ -223,8 +226,30 @@ const requestLocationPermission = () => { navigator.geolocation.getCurrentPosition( async (position) => { - // Permission granted - locationGranted.value = true + // Validate location first + const validationResult = validateLocation(position) + + // Always emit mockDetected event (regardless of result) + if (validationResult.isMockDetected) { + showMockWarning(validationResult) + emit('mockDetected', validationResult) + } + + // Check for critical errors (invalid coordinates) that prevent showing location + const hasCriticalErrors = validationResult.errors.some(error => + error.includes('พิกัดตำแหน่งไม่ถูกต้อง') + ) + + if (hasCriticalErrors) { + locationGranted.value = false + emit('locationStatus', false) + messageError($q, '', validationResult.errors[0]) + return + } + + // Permission granted based on mock detection + locationGranted.value = !validationResult.isMockDetected + emit('locationStatus', !validationResult.isMockDetected) const { latitude, longitude } = position.coords // console.log('Current position:', latitude, longitude) @@ -242,6 +267,7 @@ const requestLocationPermission = () => { (error) => { // Permission denied locationGranted.value = false + emit('locationStatus', false) switch (error.code) { case error.PERMISSION_DENIED: @@ -272,6 +298,7 @@ const requestLocationPermission = () => { defineExpose({ requestLocationPermission, + locationGranted, }) diff --git a/src/composables/useLocationValidation.ts b/src/composables/useLocationValidation.ts new file mode 100644 index 0000000..27222df --- /dev/null +++ b/src/composables/useLocationValidation.ts @@ -0,0 +1,169 @@ +import { ref } from 'vue' +import { useQuasar } from 'quasar' +import { useCounterMixin } from '@/stores/mixin' + +export interface LocationValidationResult { + isValid: boolean + isMockDetected: boolean + confidence: 'low' | 'medium' | 'high' + warnings: string[] + errors: string[] +} + +export interface PositionSnapshot { + latitude: number + longitude: number + timestamp: number +} + +// Configuration constants - exported for documentation and testing purposes +export const VALIDATION_CONFIG = { + MAX_TIMESTAMP_AGE_MS: 60_000, // 60 seconds - maximum acceptable age of location data + MAX_ACCURACY_METERS: 100, // 100 meters - maximum acceptable GPS accuracy + MAX_SPEED_MS: 100, // 100 m/s (~360 km/h) - maximum plausible movement speed + POSITION_HISTORY_SIZE: 5, // number of positions to keep for pattern detection + MOCK_INDICATOR_THRESHOLD: 3, // threshold for mock detection (indicators >= 3 = mock) +} as const + +export function useLocationValidation() { + const $q = useQuasar() + const { messageError } = useCounterMixin() + + // Thai error messages - exported for i18n consistency + const errorMessages = { + MOCK_DETECTED: 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', + INVALID_COORDINATES: 'พิกัดตำแหน่งไม่ถูกต้อง กรุณาลองใหม่', + STALE_TIMESTAMP: 'ข้อมูลตำแหน่งเก่าเกินไป กรุณารับสัญญาณ GPS ใหม่', + POOR_ACCURACY: 'ความแม่นยำตำแหน่งต่ำเกินไป กรุณาตรวจสอบการรับสัญญาณ GPS', + IMPOSSIBLE_SPEED: 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', + } + + const previousPositions = ref([]) + + // คำนวณระยะห่างระหว่าง 2 จุด (Haversine formula) + const haversineDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + const R = 6371e3 // Earth's radius in meters + const φ1 = (lat1 * Math.PI) / 180 + const φ2 = (lat2 * Math.PI) / 180 + const Δφ = ((lat2 - lat1) * Math.PI) / 180 + const Δλ = ((lon2 - lon1) * Math.PI) / 180 + + const a = + Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * Math.sin(Δλ / 2) * Math.sin(Δλ / 2) + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)) + + return R * c + } + + // ตรวจสอบพิกัดถูกต้อง + const validateCoordinates = (lat: number, lon: number): boolean => { + return ( + lat >= -90 && lat <= 90 && + lon >= -180 && lon <= 180 && + !isNaN(lat) && !isNaN(lon) && + !(lat === 0 && lon === 0) // Mock มักใช้ 0,0 + ) + } + + // ตรวจสอบความแม่นยำ + const validateAccuracy = (accuracy: number | null): boolean => { + if (accuracy === null) return true + return accuracy <= VALIDATION_CONFIG.MAX_ACCURACY_METERS + } + + // ตรวจสอบ Timestamp + const validateTimestamp = (timestamp: number): boolean => { + const now = Date.now() + const age = Math.abs(now - timestamp) + return age <= VALIDATION_CONFIG.MAX_TIMESTAMP_AGE_MS + } + + // คำนวณความเร็ว + const calculateSpeed = (pos1: PositionSnapshot, pos2: PositionSnapshot): number => { + const distance = haversineDistance(pos1.latitude, pos1.longitude, pos2.latitude, pos2.longitude) + const timeDiff = Math.abs(pos2.timestamp - pos1.timestamp) / 1000 // seconds + return timeDiff > 0 ? distance / timeDiff : 0 + } + + // ตรวจสอบความเร็วปกติ + const validateSpeed = (current: PositionSnapshot, previous: PositionSnapshot): boolean => { + const speed = calculateSpeed(previous, current) + return speed <= VALIDATION_CONFIG.MAX_SPEED_MS + } + + // Main validation function + const validateLocation = (position: GeolocationPosition): LocationValidationResult => { + const warnings: string[] = [] + const errors: string[] = [] + let mockIndicators = 0 + + const { latitude, longitude, accuracy } = position.coords + const { timestamp } = position + + // 1. Coordinate validation + if (!validateCoordinates(latitude, longitude)) { + errors.push(errorMessages.INVALID_COORDINATES) + mockIndicators += 3 + } + + // 2. Timestamp validation + if (!validateTimestamp(timestamp)) { + errors.push(errorMessages.STALE_TIMESTAMP) + mockIndicators += 2 + } + + // 3. Accuracy validation + if (!validateAccuracy(accuracy)) { + warnings.push(errorMessages.POOR_ACCURACY) + mockIndicators += 1 + } + + // 4. Compare with previous positions + if (previousPositions.value.length > 0) { + const previous = previousPositions.value[previousPositions.value.length - 1] + + if (!validateSpeed({ latitude, longitude, timestamp }, previous)) { + errors.push(errorMessages.IMPOSSIBLE_SPEED) + mockIndicators += 3 + } + } + + // Store current position + previousPositions.value.push({ latitude, longitude, timestamp }) + if (previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE) { + previousPositions.value.shift() + } + + // Determine result + const isMockDetected = mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD + const isValid = errors.length === 0 + + let confidence: 'low' | 'medium' | 'high' = 'low' + if (mockIndicators >= 5) confidence = 'high' + else if (mockIndicators >= 3) confidence = 'medium' + + return { + isValid, + isMockDetected, + confidence, + warnings, + errors, + } + } + + const showMockWarning = (result: LocationValidationResult) => { + if (!result.isMockDetected) return + messageError($q, null, errorMessages.MOCK_DETECTED) + } + + const resetValidation = () => { + previousPositions.value = [] + } + + return { + validateLocation, + showMockWarning, + resetValidation, + } +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index c91f362..379b970 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -32,6 +32,8 @@ const endTimeAfternoon = ref('12:00:00') //เวลาเช็คเ const isLoadingCheckTime = ref(false) // ตัวแปรสำหรับการโหลด const disabledBtn = ref(false) +const locationGranted = ref(false) +const isMockLocationDetected = ref(false) /** * fetch เช็คเวลาต้องลงเวลาเข้าหรือออกงาน @@ -113,6 +115,21 @@ async function updateLocation( formLocation.POI = namePOI } +/** + * รับค่าสถานะ location จาก AscGISMap + */ +function onLocationStatus(status: boolean) { + locationGranted.value = status +} + +/** + * รับค่า mock location detection จาก AscGISMap + */ +function onMockDetected(result: any) { + isMockLocationDetected.value = true + disabledBtn.value = true +} + const location = ref('') // พื้นที่ใกล้เคียง const model = ref('') // สถานที่ทำงาน // ตัวเลือกสถานที่ทำงาน @@ -595,6 +612,16 @@ watch( } } ) + +watch( + () => locationGranted.value, + (newVal) => { + // Removed auto-reset of isMockLocationDetected to prevent + // clearing mock detection state when permission is granted. + // Mock detection state should only be reset after explicit user action + // or after a successful validation without mock indicators. + } +)