merge: 지도 모바일 두 번 탭 이동을 main에 통합

This commit is contained in:
2026-08-21 00:59:48 +00:00
5 changed files with 208 additions and 1 deletions
+106
View File
@@ -467,6 +467,112 @@ test('국가 정보의 작위는 Ref 국가 등급 이름으로 표시된다', a
await expect(root).not.toContainText('작 위1');
});
test('map keeps desktop hover navigation and lets touch users choose one-tap or two-tap city navigation', async ({
browser,
page,
}, testInfo) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
await go(page, 'global-info');
const desktopCity = page.locator('.city-base').first();
await expect(page.locator('.map-toggle-single-tap')).toHaveCount(0);
await desktopCity.hover();
await expect(page.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
await desktopCity.click();
await expect(page).toHaveURL(/\/current-city\?cityId=1$/u);
const configuredBaseUrl = testInfo.project.use.baseURL;
if (typeof configuredBaseUrl !== 'string') {
throw new Error('Playwright baseURL is required for the mobile map contract');
}
const context = await browser.newContext({
baseURL: configuredBaseUrl,
viewport: { width: 390, height: 844 },
screen: { width: 390, height: 844 },
deviceScaleFactor: 1,
isMobile: true,
hasTouch: true,
colorScheme: 'dark',
});
const mobilePage = await context.newPage();
try {
await install(mobilePage);
await go(mobilePage, 'global-info');
const twoTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' });
await expect(twoTapButton).toBeVisible();
await expect(twoTapButton).toHaveAttribute('aria-pressed', 'false');
const controlGeometry = await twoTapButton.evaluate((element) => {
const rect = element.getBoundingClientRect();
const mapRect = element.closest('.map-area')?.getBoundingClientRect();
const style = getComputedStyle(element);
return {
right: rect.right,
bottom: rect.bottom,
mapRight: mapRect?.right,
mapBottom: mapRect?.bottom,
fontSize: style.fontSize,
lineHeight: style.lineHeight,
documentWidth: document.documentElement.scrollWidth,
viewportWidth: document.documentElement.clientWidth,
overflowing: Array.from(document.querySelectorAll<HTMLElement>('body *'))
.filter(
(candidate) => candidate.getBoundingClientRect().right > document.documentElement.clientWidth
)
.map((candidate) => ({
tag: candidate.tagName,
className: candidate.className,
right: candidate.getBoundingClientRect().right,
}))
.slice(0, 8),
};
});
expect(controlGeometry).toMatchObject({
fontSize: '11px',
lineHeight: '18px',
viewportWidth: 500,
overflowing: [],
});
expect(controlGeometry.documentWidth).toBeLessThanOrEqual(controlGeometry.viewportWidth + 1);
expect(controlGeometry.mapRight! - controlGeometry.right).toBeCloseTo(4, 1);
expect(controlGeometry.mapBottom! - controlGeometry.bottom).toBeCloseTo(4, 1);
const mobileCities = mobilePage.locator('.city-base');
await mobileCities.nth(0).tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|특】업');
await mobileCities.nth(1).tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
await expect(mobilePage.locator('.map-tooltip .tooltip-title')).toHaveText('【하북|수】성2');
await mobilePage.screenshot({ path: testInfo.outputPath('mobile-map-first-tap-tooltip.png'), fullPage: true });
await mobileCities.nth(1).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=2$/u);
await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 끄기' }).click();
const singleTapButton = mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' });
await expect(singleTapButton).toHaveAttribute('aria-pressed', 'true');
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('yes');
await mobilePage.reload();
await expect(singleTapButton).toBeVisible();
await mobilePage.locator('.city-base').nth(2).tap();
await expect(mobilePage).toHaveURL(/\/current-city\?cityId=3$/u);
await go(mobilePage, 'global-info');
await mobilePage.getByRole('button', { name: '두번 탭 해 도시 이동 켜기' }).click();
expect(await mobilePage.evaluate(() => localStorage.getItem('sam.toggleSingleTap'))).toBe('no');
await mobilePage.locator('.city-base').first().tap();
await expect(mobilePage).toHaveURL(/\/global-info$/u);
} finally {
await context.close();
}
});
test('global-info renders the ref nation summary columns beside the map', async ({ page }) => {
await install(page);
await page.setViewportSize({ width: 1200, height: 900 });
@@ -28,6 +28,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -37,6 +39,25 @@ const stateOffset = computed(() => -6 * props.mapScale);
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');
};
</script>
<template>
@@ -59,6 +80,9 @@ const selectCity = () => {
:style="{ left: `${props.city.x}px`, top: `${props.city.y}px` }"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div class="city-dot" :style="{ backgroundColor: props.city.color, width: `${size}px`, height: `${size}px` }">
@@ -54,6 +54,8 @@ const props = defineProps<{
const emit = defineEmits<{
(event: 'hover', cityId: number): void;
(event: 'leave'): void;
(event: 'touch', cityId: number, touchEvent: TouchEvent): void;
(event: 'touchleave'): void;
(event: 'select', cityId: number): void;
}>();
@@ -148,6 +150,25 @@ 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 cityStateStyle = computed(() => ({
width: `${12 * props.mapScale}px`,
height: `${12 * props.mapScale}px`,
@@ -174,6 +195,9 @@ const cityStateStyle = computed(() => ({
:style="cityBaseStyle"
@mouseenter="emit('hover', props.city.id)"
@mouseleave="emit('leave')"
@touchstart="touchstart"
@touchmove="touchmove"
@touchend="touchend"
@click.stop="selectCity"
>
<div v-if="cityBgStyle" class="city-bg" :style="[cityBgWrapperStyle, cityBgStyle]" />
@@ -94,9 +94,11 @@ const mapStore = useMapViewerStore();
const {
showCityName,
detailMode: storeDetailMode,
singleTapNavigation,
hoveredCityId,
selectedCityId: storeSelectedCityId,
} = storeToRefs(mapStore);
const hasTouchInput = useMediaQuery('(any-pointer: coarse)');
const mapArea = ref<HTMLElement | null>(null);
const mapBody = ref<HTMLElement | null>(null);
@@ -404,6 +406,28 @@ const setHoveredCity = (cityId: number | null) => {
mapStore.setHoveredCity(cityId);
};
const touchPreviewCityId = ref<number | null>(null);
const clearTouchPreview = () => {
touchPreviewCityId.value = null;
setHoveredCity(null);
};
const touchCity = (cityId: number, event: TouchEvent) => {
if (touchPreviewCityId.value !== cityId) {
touchPreviewCityId.value = cityId;
setHoveredCity(cityId);
if (!singleTapNavigation.value) {
event.preventDefault();
}
}
};
const toggleSingleTapNavigation = () => {
clearTouchPreview();
mapStore.toggleSingleTapNavigation();
};
const selectCity = (cityId: number) => {
if (props.readonly) return;
emit('select-city', cityId);
@@ -433,6 +457,7 @@ const selectCity = (cityId: number) => {
class="map-area"
:class="[mapThemeClass, mapSeasonClass]"
:style="{ width: mapWidth, height: mapHeight }"
@click="clearTouchPreview"
>
<div class="map-layer map-bglayer1" :style="mapBackgroundStyle" />
<div class="map-layer map-bglayer2" />
@@ -449,6 +474,8 @@ const selectCity = (cityId: number) => {
v-bind="detailProps"
@hover="setHoveredCity"
@leave="setHoveredCity(null)"
@touch="touchCity"
@touchleave="clearTouchPreview"
@select="selectCity"
/>
<div
@@ -466,9 +493,18 @@ const selectCity = (cityId: number) => {
<div class="tooltip-body">{{ hoveredCity.nationId > 0 ? hoveredCity.nationName : '' }}</div>
</div>
<div class="map-controls">
<button class="map-toggle" :class="{ active: showCityName }" @click="mapStore.toggleCityName">
<button class="map-toggle" :class="{ active: showCityName }" @click.stop="mapStore.toggleCityName">
도시명 표기 {{ showCityName ? '끄기' : '켜기' }}
</button>
<button
v-if="hasTouchInput"
class="map-toggle map-toggle-single-tap"
:class="{ active: singleTapNavigation }"
:aria-pressed="singleTapNavigation"
@click.stop="toggleSingleTapNavigation"
>
두번 도시 이동 {{ singleTapNavigation ? '켜기' : '끄기' }}
</button>
</div>
</div>
</div>
@@ -555,6 +591,8 @@ const selectCity = (cityId: number) => {
right: 4px;
bottom: 4px;
display: flex;
flex-direction: column;
align-items: flex-end;
}
.map-toggle {
+15
View File
@@ -3,14 +3,23 @@ import { defineStore } from 'pinia';
interface MapViewerState {
showCityName: boolean;
detailMode: boolean;
singleTapNavigation: boolean;
hoveredCityId: number | null;
selectedCityId: number | null;
}
const SINGLE_TAP_STORAGE_KEY = 'sam.toggleSingleTap';
const loadSingleTapNavigation = (): boolean => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(SINGLE_TAP_STORAGE_KEY) === 'yes';
};
export const useMapViewerStore = defineStore('mapViewer', {
state: (): MapViewerState => ({
showCityName: true,
detailMode: true,
singleTapNavigation: loadSingleTapNavigation(),
hoveredCityId: null,
selectedCityId: null,
}),
@@ -21,6 +30,12 @@ export const useMapViewerStore = defineStore('mapViewer', {
toggleDetailMode() {
this.detailMode = !this.detailMode;
},
toggleSingleTapNavigation() {
this.singleTapNavigation = !this.singleTapNavigation;
if (typeof window !== 'undefined') {
window.localStorage.setItem(SINGLE_TAP_STORAGE_KEY, this.singleTapNavigation ? 'yes' : 'no');
}
},
setHoveredCity(cityId: number | null) {
this.hoveredCityId = cityId;
},