hrms-checkin/src/components/AscGISMap.vue

815 lines
24 KiB
Vue

<script setup lang="ts">
import {
ref,
shallowRef,
onBeforeUnmount,
onMounted,
watch,
nextTick,
} from 'vue'
import { loadModules } from 'esri-loader'
import axios from 'axios'
import { useCounterMixin } from '@/stores/mixin'
import { useQuasar } from 'quasar'
import { usePrivacyStore } from '@/stores/privacy'
const mixin = useCounterMixin()
const { messageError } = mixin
const privacyStore = usePrivacyStore()
const emit = defineEmits(['update:location'])
// Accept initial POI from parent to prevent skeleton on re-mount
const props = defineProps<{
initialPOI?: string
}>()
const $q = useQuasar()
// Throttle mechanism to prevent excessive location updates
const LOCATION_UPDATE_THROTTLE_MS = 500
let lastLocationUpdate = 0
// Track if map is initialized to prevent re-initialization
const isMapInitialized = ref<boolean>(false)
const isInitializing = ref<boolean>(false)
const currentScreenSize = ref<boolean>($q.screen.gt.xs) // Track screen size to detect changes
const mobileMapExpanded = ref<boolean>(true)
function updateLocation(latitude: number, longitude: number, namePOI: string) {
const now = Date.now()
// Skip update if called too frequently (within throttle period)
if (now - lastLocationUpdate < LOCATION_UPDATE_THROTTLE_MS) {
return
}
lastLocationUpdate = now
// ส่ง event ไปยัง parent component เพื่ออัพเดทค่า props
emit('update:location', latitude, longitude, namePOI)
}
const poiPlaceName = ref<string>(props.initialPOI || '') // ชื่อพื้นที่ใกล้เคียง - use initialPOI if provided
const mapView = shallowRef<any>(null) // Store mapView reference for cleanup (use shallowRef to avoid reactivity issues)
const desktopMapContainerRef = ref<HTMLElement | null>(null)
const mobileMapContainerRef = ref<HTMLElement | null>(null)
// Unique container ID for each component instance to avoid conflicts
const mapContainerId = `mapViewDisplay_${Math.random()
.toString(36)
.substring(2, 9)}`
// ArcGIS API key from environment variable
const apiKey = ref<string>(
'YLATgWuywoeRLHn6KImj5rg7UaP8bJoR9jiTldoCVBHlqFIebwMSA5wIXEmcYhwXwMHkmNISEYtUz3x0oiGIIx0bIXXnUwi0OzupoOEtDrQIsRPVtor7gaPpXEmH8TrNaMT3snf6zO_yujHLGzborg-L9aeAjTJn4ndL6f8qFmRzYcX93E2vyA-7XCufLYTRsdTE5Aq-9hnx1q9PmYVMqhAZpL7dWqn3JgO33fRXetk.'
)
const zoomMap = ref<number>(18)
const textTooltip = ref<string>(
'พื้นที่ใกล้เคียงคือ สถานที่สำคัญรอบตัวคุณ (ไม่ใช่ตำแหน่งปัจจุบัน)'
)
const MAP_DEBUG = true
let attachRetryTimer: number | undefined
let mapInitVersion = 0
let isRecoveryReinitializing = false
function logMapDebug(event: string, payload?: Record<string, unknown>) {
if (!MAP_DEBUG) {
return
}
const mode = $q.screen.gt.xs ? 'desktop' : 'mobile'
console.log('[AscGISMap]', event, {
mode,
isMapInitialized: isMapInitialized.value,
isInitializing: isInitializing.value,
hasMapView: !!mapView.value,
mapDestroyed: !!mapView.value?.destroyed,
...payload,
})
}
function getActiveMapContainer() {
return $q.screen.gt.xs
? desktopMapContainerRef.value
: mobileMapContainerRef.value
}
async function waitForActiveMapContainer(maxAttempts = 25, delayMs = 120) {
for (let i = 0; i < maxAttempts; i += 1) {
await nextTick()
const container = getActiveMapContainer()
if (i === 0 || i === maxAttempts - 1) {
logMapDebug('waitForContainer:attempt', {
attempt: i + 1,
maxAttempts,
width: container?.clientWidth || 0,
height: container?.clientHeight || 0,
})
}
if (container && container.clientWidth > 0 && container.clientHeight > 0) {
logMapDebug('waitForContainer:ready', {
attempt: i + 1,
width: container.clientWidth,
height: container.clientHeight,
})
return container
}
await new Promise((resolve) => setTimeout(resolve, delayMs))
}
logMapDebug('waitForContainer:failed')
return null
}
function clearAttachRetryTimer() {
if (attachRetryTimer) {
clearTimeout(attachRetryTimer)
attachRetryTimer = undefined
}
}
async function recoverMapByReinitialize() {
if (isRecoveryReinitializing) {
logMapDebug('recover:skipAlreadyRunning')
return
}
isRecoveryReinitializing = true
logMapDebug('recover:start', { mapInitVersion })
try {
mapInitVersion += 1
isInitializing.value = false
clearAttachRetryTimer()
if (mapView.value && !mapView.value.destroyed) {
mapView.value.destroy()
mapView.value = null
}
isMapInitialized.value = false
await nextTick()
await initializeMap()
logMapDebug('recover:initializeMapCompleted', { mapInitVersion })
} finally {
isRecoveryReinitializing = false
logMapDebug('recover:end', { mapInitVersion })
}
}
function reattachAndResizeMap(retry = 0) {
if (!mapView.value || mapView.value.destroyed) {
logMapDebug('reattach:skipNoMapView', { retry })
return
}
const activeContainer = getActiveMapContainer()
if (
!activeContainer ||
activeContainer.clientWidth === 0 ||
activeContainer.clientHeight === 0
) {
logMapDebug('reattach:containerNotReady', {
retry,
width: activeContainer?.clientWidth || 0,
height: activeContainer?.clientHeight || 0,
})
if (retry < 15) {
clearAttachRetryTimer()
attachRetryTimer = window.setTimeout(() => {
reattachAndResizeMap(retry + 1)
}, 140)
} else if (!isRecoveryReinitializing) {
logMapDebug('reattach:triggerRecovery')
void recoverMapByReinitialize()
}
return
}
clearAttachRetryTimer()
nextTick(() => {
if (!mapView.value || mapView.value.destroyed) {
logMapDebug('reattach:skipDestroyedAfterTick', { retry })
return
}
if (mapView.value.container !== activeContainer) {
logMapDebug('reattach:rebindContainer', {
width: activeContainer.clientWidth,
height: activeContainer.clientHeight,
})
mapView.value.container = null
mapView.value.container = activeContainer
}
logMapDebug('reattach:resize', {
retry,
width: activeContainer.clientWidth,
height: activeContainer.clientHeight,
center: mapView.value.center,
zoom: mapView.value.zoom,
})
mapView.value.resize()
mapView.value.requestRender?.()
mapView.value
.goTo(
{
center: mapView.value.center,
zoom: mapView.value.zoom,
},
{ animate: false }
)
.catch(() => {
// Ignore interrupted goTo during rapid orientation changes.
})
})
}
async function initializeMap() {
if (isInitializing.value || (mapView.value && !mapView.value.destroyed)) {
logMapDebug('initialize:skipAlreadyRunningOrReady')
return
}
const initVersion = ++mapInitVersion
logMapDebug('initialize:start', { initVersion })
isInitializing.value = true
try {
// Load modules of ArcGIS
const [esriConfig, Map, MapView, Point, Graphic, TileLayer] =
await loadModules([
'esri/config',
'esri/Map',
'esri/views/MapView',
'esri/geometry/Point',
'esri/Graphic',
'esri/layers/TileLayer',
])
logMapDebug('initialize:modulesLoaded', { initVersion })
// 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],
})
if (initVersion !== mapInitVersion) {
logMapDebug('initialize:cancelledAfterMapCreated', {
initVersion,
mapInitVersion,
})
return
}
const position = await new Promise<GeolocationPosition>(
(resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
})
}
)
if (initVersion !== mapInitVersion) {
logMapDebug('initialize:cancelledAfterGeolocation', {
initVersion,
mapInitVersion,
})
return
}
const { latitude, longitude } = position.coords
logMapDebug('initialize:geolocationSuccess', {
initVersion,
latitude,
longitude,
})
const mapContainer = await waitForActiveMapContainer()
if (!mapContainer || initVersion !== mapInitVersion) {
logMapDebug('initialize:containerUnavailableOrCancelled', {
initVersion,
mapInitVersion,
})
isMapInitialized.value = false
return
}
logMapDebug('initialize:containerReady', {
initVersion,
width: mapContainer.clientWidth,
height: mapContainer.clientHeight,
})
mapView.value = new MapView({
container: mapContainer,
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)
},
ui: {
components: [], // Empty array to remove all default UI components
},
})
await mapView.value.when()
logMapDebug('initialize:mapViewReady', { initVersion })
if (initVersion !== mapInitVersion) {
logMapDebug('initialize:cancelledAfterMapViewReady', {
initVersion,
mapInitVersion,
})
if (mapView.value && !mapView.value.destroyed) {
mapView.value.destroy()
}
mapView.value = null
return
}
isMapInitialized.value = true
reattachAndResizeMap()
// ตำแหน่งของผู้ใช้
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.value.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,
// },
// }
// )
// .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.value.graphics.add(poiGraphic)
// // อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
// mapView.value.goTo({
// target: [userPoint, poiPoint],
// zoom: zoomMap.value,
// })
// // Mark map as initialized
// isMapInitialized.value = true
// 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) => {
if (initVersion !== mapInitVersion || !mapView.value) {
logMapDebug('initialize:poiIgnoredByVersionOrMap', {
initVersion,
mapInitVersion,
})
return
}
// 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.value.graphics.add(poiGraphic)
// อัปเดตการแสดงผลให้แสดงทั้งตำแหน่งของผู้ใช้และ POI
mapView.value.goTo({
target: [userPoint, poiPoint],
zoom: zoomMap.value,
})
updateLocation(latitude, longitude, poiPlaceName.value)
logMapDebug('initialize:poiSuccess', {
initVersion,
poiPlaceName: poiPlaceName.value,
})
})
.catch(() => {
if (initVersion !== mapInitVersion) {
logMapDebug('initialize:poiErrorIgnoredByVersion', {
initVersion,
mapInitVersion,
})
return
}
poiPlaceName.value = poiPlaceName.value || 'ไม่พบข้อมูล'
updateLocation(latitude, longitude, poiPlaceName.value)
logMapDebug('initialize:poiErrorFallback', {
initVersion,
poiPlaceName: poiPlaceName.value,
})
})
if (initVersion === mapInitVersion) {
reattachAndResizeMap()
}
} catch (error) {
if (initVersion === mapInitVersion) {
isMapInitialized.value = false
}
logMapDebug('initialize:error', {
initVersion,
mapInitVersion,
error: error instanceof Error ? error.message : String(error),
})
console.error('Error loading the map', error)
} finally {
if (initVersion === mapInitVersion) {
isInitializing.value = false
}
logMapDebug('initialize:end', {
initVersion,
mapInitVersion,
isMapInitialized: isMapInitialized.value,
})
}
}
const locationGranted = ref<boolean>(false)
// Function to request location permission
const requestLocationPermission = () => {
// เช็คสิทธิ์ privacy ก่อนเข้าถึงแผนที่
if (!privacyStore.isAccepted) {
$q.notify({
type: 'warning',
message: 'กรุณายอมรับนโยบายคุ้มครองข้อมูลส่วนบุคคลก่อนใช้งานแผนที่',
position: 'top',
})
return
}
if (!navigator.geolocation) {
messageError(
$q,
'',
'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เบราว์เซอร์ของคุณไม่รองรับ Geolocation'
)
return
}
navigator.geolocation.getCurrentPosition(
async (position) => {
// Permission granted
locationGranted.value = true
const { latitude, longitude } = position.coords
// console.log('Current position:', latitude, longitude)
if (!latitude || !longitude) {
messageError($q, '', 'ไม่สามารถระบุตำแหน่งปัจจุบันได้')
return
}
// Center map on user's location if map is initialized
if (privacyStore.isAccepted) {
await initializeMap()
}
},
(error) => {
// Permission denied
locationGranted.value = false
switch (error.code) {
case error.PERMISSION_DENIED:
messageError(
$q,
'',
'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เนื่องจากคุณปฏิเสธการเข้าถึงตำแหน่ง กรุณาเปิดการเข้าถึงตำแหน่ง'
)
break
case error.POSITION_UNAVAILABLE:
messageError($q, '', 'ไม่สามารถระบุตำแหน่งปัจจุบันได้')
break
case error.TIMEOUT:
messageError($q, '', 'การร้องขอตำแหน่งหมดเวลา กรุณาลองอีกครั้ง')
break
default:
messageError(
$q,
'',
'ไม่สามารถระบุตำแหน่งปัจจุบันได้ เกิดข้อผิดพลาดไม่ทราบสาเหตุในการเข้าถึงตำแหน่ง'
)
break
}
},
{ enableHighAccuracy: true }
)
}
// Also add a resize event listener as a fallback for orientation changes
// that might not trigger the Quasar screen watcher
let resizeTimeout: number | undefined
const handleWindowResize = () => {
// Debounce resize events to avoid excessive calls
if (resizeTimeout) {
clearTimeout(resizeTimeout)
}
resizeTimeout = window.setTimeout(() => {
handleMapResize()
}, 300)
}
/**
* Handle map resize when screen orientation changes
* ArcGIS MapView needs to be manually resized when its container changes dimensions
* Only call resize() if the map is fully initialized
*/
function handleMapResize() {
logMapDebug('handleMapResize:called')
if (mapView.value && !mapView.value.destroyed && isMapInitialized.value) {
// Use nextTick to ensure the DOM has finished updating before resizing
reattachAndResizeMap()
}
}
/**
* Watch for screen size/orientation changes
* When the screen size changes (e.g., portrait to landscape), we need to re-initialize the map
* because the container div changes between desktop and mobile versions
*/
watch(
() => $q.screen.gt.xs,
async (newValue) => {
// Skip if no actual change (same layout)
if (newValue === currentScreenSize.value) {
logMapDebug('screenWatch:skipNoChange', { newValue })
return
}
logMapDebug('screenWatch:changed', {
oldValue: currentScreenSize.value,
newValue,
})
currentScreenSize.value = newValue
if (!newValue) {
mobileMapExpanded.value = true
}
mapInitVersion += 1
isInitializing.value = false
clearAttachRetryTimer()
// Always destroy and re-initialize when screen size changes
// This ensures the map is attached to the correct container
if (mapView.value && !mapView.value.destroyed) {
logMapDebug('screenWatch:destroyMapView')
mapView.value.destroy()
mapView.value = null
}
// Reset initialization state
isMapInitialized.value = false
// Wait for DOM to update with new layout
await nextTick()
await waitForActiveMapContainer(30, 100)
// Re-initialize the map
if (privacyStore.isAccepted) {
await initializeMap()
if (!mapView.value || mapView.value.destroyed) {
logMapDebug('screenWatch:missingMapAfterInitialize, triggerRecovery')
await recoverMapByReinitialize()
}
reattachAndResizeMap()
logMapDebug('screenWatch:reinitialized', {
hasMapViewAfterReinit: !!mapView.value,
mapDestroyedAfterReinit: !!mapView.value?.destroyed,
})
}
}
)
// Watch for initialPOI prop changes to update poiPlaceName
watch(
() => props.initialPOI,
(newValue) => {
if (newValue && newValue !== poiPlaceName.value) {
poiPlaceName.value = newValue
}
}
)
// Set up resize event listener on mount
onMounted(async () => {
logMapDebug('lifecycle:mounted')
window.addEventListener('resize', handleWindowResize)
// Component can be remounted after orientation/layout switches.
// Recreate map automatically when consent is already granted.
if (privacyStore.isAccepted && (!mapView.value || mapView.value.destroyed)) {
logMapDebug('lifecycle:mounted:triggerInitialize')
await waitForActiveMapContainer(30, 100)
await initializeMap()
reattachAndResizeMap()
} else {
logMapDebug('lifecycle:mounted:skipInitialize', {
privacyAccepted: privacyStore.isAccepted,
hasMapView: !!mapView.value,
mapDestroyed: !!mapView.value?.destroyed,
})
}
})
// Cleanup map resources when component unmounts
onBeforeUnmount(() => {
logMapDebug('lifecycle:beforeUnmount')
clearAttachRetryTimer()
if (mapView.value) {
mapView.value.destroy()
mapView.value = null
}
// Clean up resize event listener
window.removeEventListener('resize', handleWindowResize)
// Clear timeout if it exists
if (resizeTimeout) {
clearTimeout(resizeTimeout)
}
})
defineExpose({
requestLocationPermission,
})
</script>
<template>
<!-- Loading skeleton - show while map is initializing -->
<div v-if="isInitializing && !isMapInitialized" class="col-12">
<q-skeleton
:height="$q.screen.gt.xs ? '35vh' : '45px'"
width="100%"
:style="$q.screen.gt.xs ? ';' : 'border-radius: 20px'"
class="bg-grey-4"
/>
</div>
<q-card
bordered
flat
class="col-12 bg-grey-2 shadow-0"
:style="$q.screen.gt.xs ? ';' : 'border-radius: 20px'"
>
<div v-if="$q.screen.gt.xs">
<div
:id="mapContainerId"
ref="desktopMapContainerRef"
style="height: 35vh; pointer-events: none"
></div>
<div
:class="
$q.screen.gt.xs
? 'q-pa-xs text-weight-medium text-grey-8'
: 'q-pa-xs text-weight-medium text-grey-8'
"
>
นทใกลเคยง
<q-icon name="mdi-information-outline" size="xs" class="q-mr-xs" />
<q-tooltip anchor="top middle" self="bottom middle" :offset="[0, 5]">
{{ textTooltip }}
</q-tooltip>
<span class="q-px-sm">:</span>
{{ poiPlaceName }}
</div>
</div>
<q-card v-else style="border-radius: 20px">
<q-expansion-item
v-model="mobileMapExpanded"
class="shadow-1 overflow-hidden bg-grey-4 text-left q-pa-xs"
style="border-radius: 20px"
dense
default-opened
>
<template v-slot:header>
<q-item-section avatar class="q-pr-none expanAS">
<q-icon name="mdi-map-marker" color="primary" />
</q-item-section>
<q-item-section>
<q-item-label caption class="text-grey-7">
นทใกลเคยง
<q-icon
name="mdi-information-outline"
size="xs"
class="q-mr-xs cursor-pointer"
@click.stop="$q.dialog({ message: textTooltip, ok: 'ตกลง' })"
/>
</q-item-label>
<q-item-label class="text-weight-medium text-grey-9">
{{ poiPlaceName || '-' }}
</q-item-label>
</q-item-section>
</template>
<div
:id="mapContainerId"
ref="mobileMapContainerRef"
style="height: 20vh"
></div>
</q-expansion-item>
</q-card>
</q-card>
</template>
<style>
.expanAS.q-item__section--avatar {
min-width: 40px !important;
}
</style>