|
|
@@ -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>
|
|
|
|