diff --git a/src/components/AscGISMap.vue b/src/components/AscGISMap.vue index 81da108..d78b1bc 100644 --- a/src/components/AscGISMap.vue +++ b/src/components/AscGISMap.vue @@ -32,7 +32,7 @@ const apiKey = ref( ) const zoomMap = ref(18) -async function initializeMap() { +async function initializeMap(position: GeolocationPosition) { try { // Load modules of ArcGIS loadModules([ @@ -43,159 +43,146 @@ async function initializeMap() { 'esri/Graphic', 'esri/layers/TileLayer', ]).then(async ([esriConfig, Map, MapView, Point, Graphic, TileLayer]) => { - // Set apiKey - // esriConfig.apiKey = - // 'AAPK4f700a4324d04e9f8a1a134e0771ac45FXWawdCl-OotFfr52gz9XKxTDJTpDzw_YYcwbmKDDyAJswf14FoPyw0qBkN64DvP' - - // Create a FeatureLayer using a custom server URL - // const hillshadeLayer = new TileLayer({ - // url: `https://bmagis.bangkok.go.th/arcgis/rest/services/cache/BMA_3D_2D_Cache/MapServer`, - // }) - const map = new Map({ basemap: 'streets', - // basemap: 'arcgis-topographic', - // layers: [hillshadeLayer], }) - navigator.geolocation.getCurrentPosition(async (position) => { - const { latitude, longitude } = position.coords + const { latitude, longitude } = position.coords - const mapView = new MapView({ - container: 'mapViewDisplay', - map: map, - center: { - latitude: latitude, - longitude: longitude, - }, // Set the initial map center current position + const mapView = new MapView({ + container: 'mapViewDisplay', + map: map, + center: { + latitude: latitude, + longitude: longitude, + }, // Set the initial map center current position - zoom: zoomMap.value, - constraints: { - snapToZoom: false, // Disables snapping to the zoom level - minZoom: zoomMap.value, // Set minimum zoom level - maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom) - }, + zoom: zoomMap.value, + constraints: { + snapToZoom: false, // Disables snapping to the zoom level + minZoom: zoomMap.value, // Set minimum zoom level + maxZoom: zoomMap.value, // Set maximum zoom level (same as minZoom for fixed zoom) + }, - ui: { - components: [], // Empty array to remove all default UI components - }, - }) + ui: { + components: [], // Empty array to remove all default UI components + }, + }) - // ตำแหน่งของผู้ใช้ - const userPoint = new Point({ longitude, latitude }) - const userSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/red.png', - width: '32px', - height: '32px', - } - const userGraphic = new Graphic({ - geometry: userPoint, - symbol: userSymbol, - }) - mapView.graphics.add(userGraphic) - // Get POI place ยิงไปขอที่ server ของกทม.ก่อน - await axios - .get( - 'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/', - { - params: { - f: 'json', // Format JSON response - distance: 2000, - category: 'POI', - location: { - spatialReference: { wkid: 4326 }, - x: longitude, - y: latitude, - }, - token: apiKey.value, + // ตำแหน่งของผู้ใช้ + const userPoint = new Point({ longitude, latitude }) + const userSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/red.png', + width: '32px', + height: '32px', + } + const userGraphic = new Graphic({ + geometry: userPoint, + symbol: userSymbol, + }) + mapView.graphics.add(userGraphic) + // Get POI place ยิงไปขอที่ server ของกทม.ก่อน + await axios + .get( + 'https://bmagis.bangkok.go.th/portal/sharing/servers/e4732c3a9fe549ab8bc697573b468f68/rest/services/World/GeocodeServer/reverseGeocode/', + { + params: { + f: 'json', // Format JSON response + distance: 2000, + category: 'POI', + location: { + spatialReference: { wkid: 4326 }, + x: longitude, + y: latitude, }, - } - ) - .then((response) => { - // console.log('poi', response.data.location) - poiPlaceName.value = response.data.address - ? response.data.address.PlaceName === '' - ? response.data.address.ShortLabel - : response.data.address.PlaceName - : 'ไม่พบข้อมูล' - const poiPoint = new Point({ - longitude: response.data.location.x, - latitude: response.data.location.y, - }) - const poiSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', - width: '32px', - height: '32px', - } - const poiGraphic = new Graphic({ - geometry: poiPoint, - symbol: poiSymbol, - }) - mapView.graphics.add(poiGraphic) - // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI - mapView.goTo({ - target: [userPoint, poiPoint], - zoom: zoomMap.value, - }) - - updateLocation(latitude, longitude, poiPlaceName.value) + token: apiKey.value, + }, + } + ) + .then((response) => { + // console.log('poi', response.data.location) + poiPlaceName.value = response.data.address + ? response.data.address.PlaceName === '' + ? response.data.address.ShortLabel + : response.data.address.PlaceName + : 'ไม่พบข้อมูล' + const poiPoint = new Point({ + longitude: response.data.location.x, + latitude: response.data.location.y, }) - .catch(async (error) => { - // console.error('Error fetching points of interest:', error) - // Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token - await axios - .get( - 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/', - { - params: { - f: 'json', // Format JSON response - distance: 2000, - category: 'POI', - location: { - spatialReference: { wkid: 4326 }, - x: longitude, - y: latitude, - }, + const poiSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', + width: '32px', + height: '32px', + } + const poiGraphic = new Graphic({ + geometry: poiPoint, + symbol: poiSymbol, + }) + mapView.graphics.add(poiGraphic) + // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI + mapView.goTo({ + target: [userPoint, poiPoint], + zoom: zoomMap.value, + }) + + updateLocation(latitude, longitude, poiPlaceName.value) + }) + .catch(async (error) => { + // console.error('Error fetching points of interest:', error) + // Get POI place ยิงไปขอที่ server arcgis ไม่ต้องใช้ token + await axios + .get( + 'https://geocode.arcgis.com/arcgis/rest/services/World/GeocodeServer/reverseGeocode/', + { + params: { + f: 'json', // Format JSON response + distance: 2000, + category: 'POI', + location: { + spatialReference: { wkid: 4326 }, + x: longitude, + y: latitude, }, - } - ) - .then((response) => { - // console.log('poi', response.data.location) - poiPlaceName.value = response.data.address - ? response.data.address.PlaceName === '' - ? response.data.address.ShortLabel - : response.data.address.PlaceName - : 'ไม่พบข้อมูล' - const poiPoint = new Point({ - longitude: response.data.location.x, - latitude: response.data.location.y, - }) - const poiSymbol = { - type: 'picture-marker', - url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', - width: '32px', - height: '32px', - } - const poiGraphic = new Graphic({ - geometry: poiPoint, - symbol: poiSymbol, - }) - mapView.graphics.add(poiGraphic) - // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI - mapView.goTo({ - target: [userPoint, poiPoint], - zoom: zoomMap.value, - }) + }, + } + ) + .then((response) => { + // console.log('poi', response.data.location) + poiPlaceName.value = response.data.address + ? response.data.address.PlaceName === '' + ? response.data.address.ShortLabel + : response.data.address.PlaceName + : 'ไม่พบข้อมูล' + const poiPoint = new Point({ + longitude: response.data.location.x, + latitude: response.data.location.y, + }) + const poiSymbol = { + type: 'picture-marker', + url: 'http://maps.google.com/mapfiles/ms/icons/blue.png', + width: '32px', + height: '32px', + } + const poiGraphic = new Graphic({ + geometry: poiPoint, + symbol: poiSymbol, + }) + mapView.graphics.add(poiGraphic) + // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI + mapView.goTo({ + target: [userPoint, poiPoint], + zoom: zoomMap.value, + }) - updateLocation(latitude, longitude, poiPlaceName.value) - }) - .catch((error) => { - // console.error('Error fetching points of interest:', error) - }) - }) - }) + updateLocation(latitude, longitude, poiPlaceName.value) + }) + .catch((error) => { + // console.error('Error fetching points of interest:', error) + }) + }) }) } catch (error) { console.error('Error loading the map', error) @@ -236,7 +223,7 @@ const requestLocationPermission = () => { } // Check for critical errors (invalid coordinates) that prevent showing location - const hasCriticalErrors = validationResult.errors.some(error => + const hasCriticalErrors = validationResult.errors.some((error) => error.includes('พิกัดตำแหน่งไม่ถูกต้อง') ) @@ -261,7 +248,7 @@ const requestLocationPermission = () => { // Center map on user's location if map is initialized if (privacyStore.isAccepted) { - await initializeMap() + await initializeMap(position) } }, (error) => { diff --git a/src/composables/useLocationValidation.ts b/src/composables/useLocationValidation.ts index 27222df..2522d07 100644 --- a/src/composables/useLocationValidation.ts +++ b/src/composables/useLocationValidation.ts @@ -23,6 +23,7 @@ export const VALIDATION_CONFIG = { 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) + SUSPICIOUS_ACCURACY_MAX: 5, // accuracy ≤ 5m AND integer = suspicious (real GPS never rounds to whole number) } as const export function useLocationValidation() { @@ -31,17 +32,26 @@ export function useLocationValidation() { // Thai error messages - exported for i18n consistency const errorMessages = { - MOCK_DETECTED: 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', + MOCK_DETECTED: + 'ตรวจพบว่าตำแหน่ง GPS อาจไม่ถูกต้อง กรุณาปิดแอปจำลองตำแหน่งและลองใหม่', INVALID_COORDINATES: 'พิกัดตำแหน่งไม่ถูกต้อง กรุณาลองใหม่', STALE_TIMESTAMP: 'ข้อมูลตำแหน่งเก่าเกินไป กรุณารับสัญญาณ GPS ใหม่', POOR_ACCURACY: 'ความแม่นยำตำแหน่งต่ำเกินไป กรุณาตรวจสอบการรับสัญญาณ GPS', - IMPOSSIBLE_SPEED: 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', + IMPOSSIBLE_SPEED: + 'ตรวจพบการเคลื่อนที่ด้วยความเร็วผิดปกติ อาจเป็นการจำลองตำแหน่ง', + SUSPICIOUS_ACCURACY: 'ตรวจพบค่าความแม่นยำที่ผิดปกติ อาจเป็นการจำลองตำแหน่ง', + DUPLICATE_POSITION: 'ตรวจพบพิกัดตำแหน่งซ้ำกัน อาจเป็นการจำลองตำแหน่ง', } const previousPositions = ref([]) // คำนวณระยะห่างระหว่าง 2 จุด (Haversine formula) - const haversineDistance = (lat1: number, lon1: number, lat2: number, lon2: number): number => { + 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 @@ -59,9 +69,12 @@ export function useLocationValidation() { // ตรวจสอบพิกัดถูกต้อง const validateCoordinates = (lat: number, lon: number): boolean => { return ( - lat >= -90 && lat <= 90 && - lon >= -180 && lon <= 180 && - !isNaN(lat) && !isNaN(lon) && + lat >= -90 && + lat <= 90 && + lon >= -180 && + lon <= 180 && + !isNaN(lat) && + !isNaN(lon) && !(lat === 0 && lon === 0) // Mock มักใช้ 0,0 ) } @@ -80,20 +93,49 @@ export function useLocationValidation() { } // คำนวณความเร็ว - const calculateSpeed = (pos1: PositionSnapshot, pos2: PositionSnapshot): number => { - const distance = haversineDistance(pos1.latitude, pos1.longitude, pos2.latitude, pos2.longitude) + 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 validateSpeed = ( + current: PositionSnapshot, + previous: PositionSnapshot + ): boolean => { const speed = calculateSpeed(previous, current) return speed <= VALIDATION_CONFIG.MAX_SPEED_MS } + // ตรวจสอบค่าความแม่นยำที่ผิดปกติ (mock apps มักรายงานค่าที่เป็นเลขจำนวนเต็มสวยงาม เช่น 5.0, 3.0) + const hasSuspiciousPerfectAccuracy = (accuracy: number | null): boolean => { + if (accuracy === null) return false + return ( + Number.isInteger(accuracy) && + accuracy <= VALIDATION_CONFIG.SUSPICIOUS_ACCURACY_MAX + ) + } + + // ตรวจสอบพิกัดซ้ำกันทุกค่า (real GPS มีการสั่นเล็กน้อย mock app มักคืนพิกัดเดิมซ้ำ) + const hasDuplicateCoordinates = (lat: number, lon: number): boolean => { + return previousPositions.value.some( + (p) => p.latitude === lat && p.longitude === lon + ) + } + // Main validation function - const validateLocation = (position: GeolocationPosition): LocationValidationResult => { + const validateLocation = ( + position: GeolocationPosition + ): LocationValidationResult => { const warnings: string[] = [] const errors: string[] = [] let mockIndicators = 0 @@ -121,7 +163,8 @@ export function useLocationValidation() { // 4. Compare with previous positions if (previousPositions.value.length > 0) { - const previous = previousPositions.value[previousPositions.value.length - 1] + const previous = + previousPositions.value[previousPositions.value.length - 1] if (!validateSpeed({ latitude, longitude, timestamp }, previous)) { errors.push(errorMessages.IMPOSSIBLE_SPEED) @@ -129,14 +172,32 @@ export function useLocationValidation() { } } + // 5. Suspiciously perfect accuracy — real GPS never rounds to a whole-number ≤ 5m + if (hasSuspiciousPerfectAccuracy(accuracy)) { + warnings.push(errorMessages.SUSPICIOUS_ACCURACY) + mockIndicators += 2 + } + + // 6. Exact duplicate coordinates — real GPS always drifts slightly between readings + if ( + previousPositions.value.length > 0 && + hasDuplicateCoordinates(latitude, longitude) + ) { + warnings.push(errorMessages.DUPLICATE_POSITION) + mockIndicators += 2 + } + // Store current position previousPositions.value.push({ latitude, longitude, timestamp }) - if (previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE) { + if ( + previousPositions.value.length > VALIDATION_CONFIG.POSITION_HISTORY_SIZE + ) { previousPositions.value.shift() } // Determine result - const isMockDetected = mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD + const isMockDetected = + mockIndicators >= VALIDATION_CONFIG.MOCK_INDICATOR_THRESHOLD const isValid = errors.length === 0 let confidence: 'low' | 'medium' | 'high' = 'low' @@ -154,7 +215,7 @@ export function useLocationValidation() { const showMockWarning = (result: LocationValidationResult) => { if (!result.isMockDetected) return - messageError($q, null, errorMessages.MOCK_DETECTED) + messageError($q, '', errorMessages.MOCK_DETECTED) } const resetValidation = () => {