Просмотр исходного кода

feat(home): 세계지도 라벨 겹침 자동 회피 (확대 시 더 표시)

- 핀/라벨을 줌 변환 밖 화면좌표로 렌더(고정 크기) → 확대 시 라벨 안 커지고 간격만 벌어짐
- 라벨 겹침 판정(큰 등락 우선 배치, 겹치면 숨김) → 겹치는 라벨 자동 숨김
- 확대(휠/드래그/지역탭)하면 자리가 생겨 숨은 라벨이 나타남. 활성(호버/포커스) 핀은 항상 표시
- 핀 머리/막대는 항상 표시(위치는 유지), 화면 밖 핀 컬링

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
KIM-JINO5 2 недель назад
Родитель
Сommit
eaa2187d35
1 измененных файлов с 65 добавлено и 31 удалено
  1. 65 31
      app/(main)/_components/WorldMarketMap.tsx

+ 65 - 31
app/(main)/_components/WorldMarketMap.tsx

@@ -152,6 +152,32 @@ export default function WorldMarketMap({ rows }: Props)
 		return pins.reduce<string|null>((max, c) => (max === null || c.row.tradeDate > max ? c.row.tradeDate : max), null);
 	}, [pins]);
 
+	// 라벨 겹침 자동 회피 — 화면좌표(변환 후)에서 큰 등락 순으로 배치, 이미 놓인 라벨과 겹치면 숨김.
+	// 확대(k↑)하면 핀 간격이 벌어져 겹침이 풀리므로 더 많은 라벨이 나타난다. (핀 머리/막대는 항상 표시)
+	const labeledSet = useMemo(() => {
+		const ordered = [...pins].sort((a, b) => Math.abs(b.row.flucRateBp) - Math.abs(a.row.flucRateBp));
+		const placed: { x1: number; y1: number; x2: number; y2: number }[] = [];
+		const show = new Set<string>();
+		for (const pin of ordered) {
+			const sx = view.x + pin.x * view.k;
+			const sy = view.y + pin.y * view.k;
+			if (sx < -24 || sx > VW + 24 || sy < -24 || sy > VH + 24) {
+				continue;
+			}
+			const hy = sy - PIN_H;
+			const left = sx > VW * 0.72;
+			const w = Math.max(pin.label.length * 11.5, 46) + 6;
+			const x1 = left ? sx - (pin.radius + 4) - w : sx + (pin.radius + 4);
+			const box = { x1, y1: hy - 13, x2: x1 + w, y2: hy + 12 };
+			const hit = placed.some((p) => !(box.x2 < p.x1 || box.x1 > p.x2 || box.y2 < p.y1 || box.y1 > p.y2));
+			if (!hit) {
+				placed.push(box);
+				show.add(pin.row.countryCode);
+			}
+		}
+		return show;
+	}, [pins, view]);
+
 	// 지역 탭 클릭 → 그리드 필터 + 지도를 해당 지역으로 이동/확대
 	function selectRegion(t: RegionTab)
 	{
@@ -267,49 +293,57 @@ export default function WorldMarketMap({ rows }: Props)
 						{/* 대양 (고정 배경 — 팬/줌 영향 없음) */}
 						<rect x='0' y='0' width={VW} height={VH} rx='16' fill='url(#wm-ocean)' />
 
-						{/* 팬/줌 그룹 */}
+						{/* 팬/줌 그룹 — 육지만. 핀/라벨은 변환 밖에서 화면좌표로 그려 확대 시 크기 고정 + 라벨 겹침 회피 */}
 						<g transform={`translate(${view.x} ${view.y}) scale(${view.k})`}>
 							<g className='world-map__land'>
 								{COUNTRY_PATHS.map((c) => (
 									<path key={c.id} d={c.d} />
 								))}
 							</g>
+						</g>
 
-							<g className='world-map__pins'>
-								{pins.map((pin) => {
-									const isActive = pin.row.countryCode === active;
-									const leftSide = pin.x > VW * 0.72;
-									const lx = leftSide ? -(pin.radius + 4) : pin.radius + 4;
-									const anchor = leftSide ? 'end' : 'start';
-									return (
-										<g
-											key={pin.row.countryCode}
-											className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
-											transform={`translate(${pin.x} ${pin.y})`}
-											role='button'
-											tabIndex={0}
-											aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
-											onMouseEnter={() => setActive(pin.row.countryCode)}
-											onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
-											onFocus={() => setActive(pin.row.countryCode)}
-											onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
-											onClick={() => onPinClick(pin.row)}
-											onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
-										>
-											{/* map-pin: 지점에서 위로 뻗은 막대 + 머리(원) */}
-											<line className='world-map__pin-stem' x1='0' y1='0' x2='0' y2={-PIN_H} />
-											<circle className='world-map__pin-head' cx='0' cy={-PIN_H} r={pin.radius} />
-
-											{/* 상시 라벨 — 국가명 + 등락률 (머리 옆) */}
+						<g className='world-map__pins'>
+							{pins.map((pin) => {
+								const sx = view.x + pin.x * view.k;
+								const sy = view.y + pin.y * view.k;
+								if (sx < -24 || sx > VW + 24 || sy < -24 || sy > VH + 24) {
+									return null;
+								}
+								const isActive = pin.row.countryCode === active;
+								const showLabel = isActive || labeledSet.has(pin.row.countryCode);
+								const leftSide = sx > VW * 0.72;
+								const lx = leftSide ? -(pin.radius + 4) : pin.radius + 4;
+								const anchor = leftSide ? 'end' : 'start';
+								return (
+									<g
+										key={pin.row.countryCode}
+										className={`world-map__pin world-map__pin--${pin.dir}${isActive ? ' is-active' : ''}`}
+										transform={`translate(${sx.toFixed(1)} ${sy.toFixed(1)})`}
+										role='button'
+										tabIndex={0}
+										aria-label={`${pin.label} ${pin.row.name} ${formatFlucRate(pin.row.flucRateBp)}`}
+										onMouseEnter={() => setActive(pin.row.countryCode)}
+										onMouseLeave={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
+										onFocus={() => setActive(pin.row.countryCode)}
+										onBlur={() => setActive((cur) => (cur === pin.row.countryCode ? null : cur))}
+										onClick={() => onPinClick(pin.row)}
+										onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && TV_SYMBOL[pin.row.countryCode]) { e.preventDefault(); setChartRow(pin.row); } }}
+									>
+										{/* map-pin: 지점에서 위로 뻗은 막대 + 머리(원) */}
+										<line className='world-map__pin-stem' x1='0' y1='0' x2='0' y2={-PIN_H} />
+										<circle className='world-map__pin-head' cx='0' cy={-PIN_H} r={pin.radius} />
+
+										{/* 라벨 — 겹치지 않는 것만(활성 핀은 항상). 국가명 + 등락률 */}
+										{showLabel && (
 											<g className='world-map__label' transform={`translate(${lx} ${-PIN_H})`} textAnchor={anchor}>
 												<text className='world-map__label-name' y='-2'>{pin.label}</text>
 												<text className={`world-map__label-rate world-map__label-rate--${pin.dir}`} y='8.5'>{formatFlucRate(pin.row.flucRateBp)}</text>
 												{isActive && <text className='world-map__label-close' y='18'>{formatIndexClose(pin.row.close)}</text>}
 											</g>
-										</g>
-									);
-								})}
-							</g>
+										)}
+									</g>
+								);
+							})}
 						</g>
 					</svg>