모바일 지도 패널을 유지하고 도시 재탭 이동 안정화

This commit is contained in:
2026-09-14 14:36:14 +00:00
parent 7522f5100e
commit 3e25c5c385
6 changed files with 269 additions and 51 deletions
@@ -2439,6 +2439,74 @@ test('touch command maps select city and nation on the first tap without changin
path: testInfo.outputPath('main-map-current-city-static-highlight-mobile.png'),
});
const mainMap = mobilePage.locator('[data-main-target="map"]');
const city = mainMap.locator('.city-base').nth(1);
await city.scrollIntoViewIfNeeded();
const beforeTap = await city.boundingBox();
await city.tap();
await expect(mainMap.locator('.map-tooltip')).toBeVisible();
await mobilePage.waitForTimeout(700);
expect(await city.boundingBox()).toEqual(beforeTap);
// 실제 Chromium touchMove를 포함한 손떨림도 두 번째 탭으로 처리한다.
const box = await city.boundingBox();
if (!box) throw new Error('Missing city geometry');
const cdp = await context.newCDPSession(mobilePage);
const point = { x: box.x + box.width / 2, y: box.y + box.height / 2, id: 0 };
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] });
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ ...point, x: point.x + 4, y: point.y + 2 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(mobilePage).toHaveURL(/current-city\?cityId=2$/u);
await mobilePage.goto('/');
await city.tap();
const dragBox = await city.boundingBox();
if (!dragBox) throw new Error('Missing drag geometry');
const dragStart = { x: dragBox.x + dragBox.width / 2, y: dragBox.y + dragBox.height / 2, id: 0 };
const scrollBeforeDrag = await mobilePage.evaluate(() => window.scrollY);
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [dragStart] });
for (let step = 1; step <= 5; step += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ ...dragStart, y: dragStart.y - step * 20 }],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect(mainMap.locator('.map-tooltip')).toHaveCount(0);
await expect(mobilePage).toHaveURL(/\/$/u);
await expect.poll(() => mobilePage.evaluate(() => window.scrollY)).toBeGreaterThan(scrollBeforeDrag);
await expect
.poll(async () => {
const y = await mobilePage.evaluate(() => window.scrollY);
await mobilePage.waitForTimeout(100);
return (await mobilePage.evaluate(() => window.scrollY)) - y;
})
.toBe(0);
await city.tap();
await expect(mobilePage).toHaveURL(/\/$/u);
await expect(mainMap.locator('.map-tooltip')).toBeVisible();
await mainMap.screenshot({ path: testInfo.outputPath('main-map-after-scroll-first-tap.png') });
await writeFile(
testInfo.outputPath('main-touch-geometry.json'),
JSON.stringify(
{
beforeTap,
scrollBeforeDrag,
afterDrag: await mobilePage.evaluate(() => window.scrollY),
map: await mainMap.evaluate((element) => ({
rect: element.getBoundingClientRect().toJSON(),
tooltip: element.querySelector('.map-tooltip')?.getBoundingClientRect().toJSON(),
touchAction: getComputedStyle(element.querySelector('.city-base')!).touchAction,
})),
},
null,
2
)
);
await cdp.detach();
await mobilePage.goto('/');
await mobilePage.getByRole('button', { name: '1턴 명령 입력', exact: true }).click();
await mobilePage.getByTestId('command-picker').getByRole('button', { name: /화계/ }).click();
let form = mobilePage.getByTestId('command-argument-form');
@@ -2447,6 +2515,22 @@ test('touch command maps select city and nation on the first tap without changin
await commandMap.locator('.city-base').nth(1).tap();
await expect(form.locator('#command-arg-destCityId')).toHaveValue('2');
await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/);
await expect(commandMap.locator('.map-tooltip .tooltip-title')).toContainText('허창');
await expect(commandMap.locator('.map-tooltip .tooltip-body')).toHaveText('적국');
await commandMap.locator('.city-base').nth(1).dispatchEvent('mouseleave');
await mobilePage.waitForTimeout(700);
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
await expect(mobilePage.locator('[data-main-target="map"] .map-tooltip')).toHaveCount(0);
await commandMap.screenshot({
path: testInfo.outputPath(
`persistent-panel-${(await form.locator('#command-arg-destCityId').count()) ? 'personal' : 'chief'}.png`
),
});
await commandMap.locator('.city-base').nth(1).tap();
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
await commandMap.getByRole('button', { name: '지도 옵션', exact: true }).tap();
await expect(commandMap.locator('.map-tooltip')).toHaveCount(0);
await expect(mobilePage).toHaveURL(/\/$/u);
await mobilePage.goto(gamePath('/chief-center'));
@@ -2460,6 +2544,22 @@ test('touch command maps select city and nation on the first tap without changin
await commandMap.locator('.city-base').nth(1).tap();
await expect(form.locator('#command-arg-destNationId')).toHaveValue('2');
await expect(commandMap.locator('.city-base').nth(1)).toHaveClass(/selected/);
await expect(commandMap.locator('.map-tooltip .tooltip-title')).toContainText('허창');
await expect(commandMap.locator('.map-tooltip .tooltip-body')).toHaveText('적국');
await commandMap.locator('.city-base').nth(1).dispatchEvent('mouseleave');
await mobilePage.waitForTimeout(700);
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
await expect(mobilePage.locator('[data-main-target="map"] .map-tooltip')).toHaveCount(0);
await commandMap.screenshot({
path: testInfo.outputPath(
`persistent-panel-${(await form.locator('#command-arg-destCityId').count()) ? 'personal' : 'chief'}.png`
),
});
await commandMap.locator('.city-base').nth(1).tap();
await expect(commandMap.locator('.map-tooltip')).toBeVisible();
await commandMap.getByRole('button', { name: '지도 옵션', exact: true }).tap();
await expect(commandMap.locator('.map-tooltip')).toHaveCount(0);
await expect(mobilePage).toHaveURL(new RegExp(`${gamePath('/chief-center')}$`, 'u'));
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
@@ -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 };
};
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { useMapCityTouch } from '../src/composables/useMapCityTouch.ts';
const point = (x: number, y = 0, identifier = 1) => ({ clientX: x, clientY: y, identifier }) as Touch;
const touchList = (touches: Touch[]): TouchList =>
Object.assign(touches, { item: (index: number) => touches[index] ?? null });
const event = (touches: Touch[], changedTouches = touches) =>
Object.assign(new Event('touchend', { cancelable: true }), {
touches: touchList(touches),
changedTouches: touchList(changedTouches),
}) as TouchEvent;
void describe('map city touch gestures', () => {
void it('accepts small movement and consumes the synthetic click only once', () => {
let taps = 0;
const touch = useMapCityTouch(
() => {
taps += 1;
},
() => undefined
);
touch.touchstart(event([point(0)]));
touch.touchmove(event([point(4, 2)]));
const end = event([], [point(4, 2)]);
touch.touchend(end);
touch.touchend(end);
assert.equal(taps, 1);
assert.equal(end.defaultPrevented, true);
});
void it('rejects a drag even if the finger returns to its starting point', () => {
let taps = 0;
const touch = useMapCityTouch(
() => {
taps += 1;
},
() => undefined
);
touch.touchstart(event([point(0)]));
touch.touchmove(event([point(20)]));
touch.touchmove(event([point(0)]));
touch.touchend(event([], [point(0)]));
assert.equal(taps, 0);
});
void it('checks the final coordinates even without a touchmove event', () => {
let taps = 0;
const touch = useMapCityTouch(
() => {
taps += 1;
},
() => undefined
);
touch.touchstart(event([point(0)]));
touch.touchend(event([], [point(30)]));
assert.equal(taps, 0);
});
void it('cancels pinch and interrupted gestures but permits a new tap', () => {
let taps = 0;
const touch = useMapCityTouch(
() => {
taps += 1;
},
() => undefined
);
touch.touchstart(event([point(0)]));
touch.touchstart(event([point(0), point(10, 0, 2)]));
touch.touchend(event([], [point(0)]));
touch.touchstart(event([point(0)]));
touch.touchcancel();
touch.touchend(event([], [point(0)]));
assert.equal(taps, 0);
touch.touchstart(event([point(0)]));
touch.touchend(event([], [point(0)]));
assert.equal(taps, 1);
});
});