모바일 지도 패널을 유지하고 도시 재탭 이동 안정화
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMapCityTouch } from '../../composables/useMapCityTouch';
|
||||
interface MapCityView {
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -40,24 +41,10 @@ const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
|
||||
let touchOnTrack = false;
|
||||
|
||||
const touchstart = () => {
|
||||
touchOnTrack = true;
|
||||
};
|
||||
|
||||
const touchmove = () => {
|
||||
touchOnTrack = false;
|
||||
};
|
||||
|
||||
const touchend = (event: TouchEvent) => {
|
||||
if (touchOnTrack) {
|
||||
event.stopPropagation();
|
||||
emit('touch', props.city.id, event);
|
||||
return;
|
||||
}
|
||||
emit('touchleave');
|
||||
};
|
||||
const { touchstart, touchmove, touchend, touchcancel } = useMapCityTouch(
|
||||
(event) => emit('touch', props.city.id, event),
|
||||
() => emit('touchleave')
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -89,6 +76,7 @@ const touchend = (event: TouchEvent) => {
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchmove"
|
||||
@touchend="touchend"
|
||||
@touchcancel="touchcancel"
|
||||
@click.stop="selectCity"
|
||||
>
|
||||
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
|
||||
@@ -117,6 +105,7 @@ const touchend = (event: TouchEvent) => {
|
||||
font-size: 0.65rem;
|
||||
color: rgba(232, 221, 196, 0.8);
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RouterLink } from 'vue-router';
|
||||
import { useMapCityTouch } from '../../composables/useMapCityTouch';
|
||||
import { buildAssetUrl, normalizeColorToken } from '../../utils/mapAssets';
|
||||
|
||||
interface MapCityView {
|
||||
@@ -155,24 +156,10 @@ const selectCity = () => {
|
||||
if (!props.readonly) emit('select', props.city.id);
|
||||
};
|
||||
|
||||
let touchOnTrack = false;
|
||||
|
||||
const touchstart = () => {
|
||||
touchOnTrack = true;
|
||||
};
|
||||
|
||||
const touchmove = () => {
|
||||
touchOnTrack = false;
|
||||
};
|
||||
|
||||
const touchend = (event: TouchEvent) => {
|
||||
if (touchOnTrack) {
|
||||
event.stopPropagation();
|
||||
emit('touch', props.city.id, event);
|
||||
return;
|
||||
}
|
||||
emit('touchleave');
|
||||
};
|
||||
const { touchstart, touchmove, touchend, touchcancel } = useMapCityTouch(
|
||||
(event) => emit('touch', props.city.id, event),
|
||||
() => emit('touchleave')
|
||||
);
|
||||
|
||||
const cityStateStyle = computed(() => ({
|
||||
width: `${12 * props.mapScale}px`,
|
||||
@@ -204,6 +191,7 @@ const cityStateStyle = computed(() => ({
|
||||
@touchstart="touchstart"
|
||||
@touchmove="touchmove"
|
||||
@touchend="touchend"
|
||||
@touchcancel="touchcancel"
|
||||
@click.stop="selectCity"
|
||||
>
|
||||
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
|
||||
@@ -231,6 +219,7 @@ const cityStateStyle = computed(() => ({
|
||||
font-size: 0.65rem;
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
touch-action: manipulation;
|
||||
text-decoration: none;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, ref, useId, watch } from 'vue';
|
||||
import { storeToRefs } from 'pinia';
|
||||
import { onClickOutside, useElementSize, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||
import { onClickOutside, useElementSize, useEventListener, useMediaQuery, useMouseInElement } from '@vueuse/core';
|
||||
import SkeletonLines from '../ui/SkeletonLines.vue';
|
||||
import MapCityBasic from './MapCityBasic.vue';
|
||||
import MapCityDetail from './MapCityDetail.vue';
|
||||
@@ -143,7 +143,6 @@ const {
|
||||
showCityName,
|
||||
detailMode: storeDetailMode,
|
||||
singleTapNavigation,
|
||||
hoveredCityId,
|
||||
selectedCityId: storeSelectedCityId,
|
||||
} = storeToRefs(mapStore);
|
||||
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
|
||||
@@ -561,6 +560,9 @@ const detailProps = computed(() =>
|
||||
: {}
|
||||
);
|
||||
|
||||
const hoveredCityId = ref<number | null>(null);
|
||||
const touchPreviewCityId = ref<number | null>(null);
|
||||
|
||||
const hoveredCity = computed(() => {
|
||||
if (!hoveredCityId.value) {
|
||||
return null;
|
||||
@@ -581,10 +583,11 @@ const tooltipPosition = computed(() => {
|
||||
const mapPixelWidth = BASE_MAP_WIDTH * mapScale.value;
|
||||
const mapPixelHeight = BASE_MAP_HEIGHT * mapScale.value;
|
||||
const tooltipHeight = tooltipElement.value?.offsetHeight ?? TOOLTIP_FALLBACK_HEIGHT;
|
||||
const left = elementX.value + width + offset > mapPixelWidth ? elementX.value - width - 5 : elementX.value + offset;
|
||||
const belowTop = elementY.value + TOOLTIP_VERTICAL_OFFSET;
|
||||
const top =
|
||||
belowTop + tooltipHeight > mapPixelHeight ? elementY.value - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop;
|
||||
const x = touchPreviewCityId.value ? (hoveredCity.value?.x ?? elementX.value) : elementX.value;
|
||||
const y = touchPreviewCityId.value ? (hoveredCity.value?.y ?? elementY.value) : elementY.value;
|
||||
const left = x + width + offset > mapPixelWidth ? x - width - 5 : x + offset;
|
||||
const belowTop = y + TOOLTIP_VERTICAL_OFFSET;
|
||||
const top = belowTop + tooltipHeight > mapPixelHeight ? y - tooltipHeight - TOOLTIP_VERTICAL_OFFSET : belowTop;
|
||||
return {
|
||||
left: `${Math.max(0, left)}px`,
|
||||
top: `${Math.max(0, top)}px`,
|
||||
@@ -592,11 +595,10 @@ const tooltipPosition = computed(() => {
|
||||
});
|
||||
|
||||
const setHoveredCity = (cityId: number | null) => {
|
||||
mapStore.setHoveredCity(cityId);
|
||||
if (touchPreviewCityId.value !== null) return;
|
||||
hoveredCityId.value = cityId;
|
||||
};
|
||||
|
||||
const touchPreviewCityId = ref<number | null>(null);
|
||||
|
||||
const clearTouchPreview = () => {
|
||||
touchPreviewCityId.value = null;
|
||||
setHoveredCity(null);
|
||||
@@ -612,13 +614,28 @@ const toggleMapOptions = () => {
|
||||
|
||||
onClickOutside(mapControls, closeMapOptions);
|
||||
|
||||
// 맵 인스턴스별 패널을 유지하고, 도시 이외의 다음 입력에서 닫는다.
|
||||
useEventListener(
|
||||
document,
|
||||
'pointerdown',
|
||||
(event) => {
|
||||
const target = event.target;
|
||||
const city = target instanceof Element ? target.closest('.city-base, .map-city') : null;
|
||||
if (!city || !mapArea.value?.contains(city)) clearTouchPreview();
|
||||
},
|
||||
{ capture: true }
|
||||
);
|
||||
useEventListener(document, 'keydown', (event) => {
|
||||
if (event.key === 'Escape') clearTouchPreview();
|
||||
});
|
||||
|
||||
const touchCity = (cityId: number, event: TouchEvent) => {
|
||||
if (touchPreviewCityId.value !== cityId) {
|
||||
touchPreviewCityId.value = cityId;
|
||||
setHoveredCity(cityId);
|
||||
if (!isSelectionMap.value && !singleTapNavigation.value) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const activate = isSelectionMap.value || singleTapNavigation.value || touchPreviewCityId.value === cityId;
|
||||
touchPreviewCityId.value = cityId;
|
||||
hoveredCityId.value = cityId;
|
||||
if (activate && !props.readonly && event.currentTarget instanceof HTMLElement) {
|
||||
// 기존 RouterLink/button 경로를 한 번 실행하여 선택과 이동 계약을 보존한다.
|
||||
event.currentTarget.click();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// 도시의 작은 터치 영역에서도 손떨림은 탭으로, 스크롤·핀치는 취소로 처리한다.
|
||||
export const useMapCityTouch = (onTap: (event: TouchEvent) => void, onCancel: () => void) => {
|
||||
const tapSlop = 10;
|
||||
let start: { id: number; x: number; y: number } | null = null;
|
||||
|
||||
const touchcancel = () => {
|
||||
start = null;
|
||||
onCancel();
|
||||
};
|
||||
const touchstart = (event: TouchEvent) => {
|
||||
const touch = event.touches[0];
|
||||
if (event.touches.length !== 1 || !touch) {
|
||||
touchcancel();
|
||||
return;
|
||||
}
|
||||
start = { id: touch.identifier, x: touch.clientX, y: touch.clientY };
|
||||
};
|
||||
const touchmove = (event: TouchEvent) => {
|
||||
const touch = Array.from(event.touches).find((item) => item.identifier === start?.id);
|
||||
if (
|
||||
!start ||
|
||||
event.touches.length !== 1 ||
|
||||
!touch ||
|
||||
Math.hypot(touch.clientX - start.x, touch.clientY - start.y) > tapSlop
|
||||
) {
|
||||
touchcancel();
|
||||
}
|
||||
};
|
||||
const touchend = (event: TouchEvent) => {
|
||||
const touch = Array.from(event.changedTouches).find((item) => item.identifier === start?.id);
|
||||
const tapped =
|
||||
start &&
|
||||
touch &&
|
||||
event.touches.length === 0 &&
|
||||
Math.hypot(touch.clientX - start.x, touch.clientY - start.y) <= tapSlop;
|
||||
start = null;
|
||||
// 브라우저의 합성 click 유무에 기대지 않고 유효한 탭만 한 번 활성화한다.
|
||||
if (event.cancelable) event.preventDefault();
|
||||
if (tapped) {
|
||||
event.stopPropagation();
|
||||
onTap(event);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
return { touchstart, touchmove, touchend, touchcancel };
|
||||
};
|
||||
Reference in New Issue
Block a user