feat(travel): MiniMap 컴포넌트 — 접기/펼치기 + 전체보기

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-24 01:19:33 +09:00
parent 1072a5eb21
commit 201601dc95
2 changed files with 272 additions and 0 deletions

View File

@@ -0,0 +1,106 @@
/* ── MiniMap ── */
.minimap-wrapper {
width: 100%;
}
/* toolbar */
.minimap-toolbar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 8px;
}
.minimap-toggle-btn,
.minimap-clear-btn {
background: var(--tv-surface);
color: var(--tv-muted);
border: 1px solid var(--tv-line-bright);
border-radius: var(--tv-r-sm);
padding: 5px 14px;
font: 11px var(--tv-mono);
letter-spacing: 0.04em;
cursor: pointer;
transition: color 0.2s, border-color 0.2s;
}
.minimap-toggle-btn:hover,
.minimap-clear-btn:hover {
color: var(--tv-text);
border-color: var(--tv-accent);
}
/* container */
.minimap-container {
position: relative;
height: var(--minimap-h, 200px);
border-radius: var(--tv-r-lg);
border: 1px solid var(--tv-line-bright);
box-shadow: 0 2px 16px rgba(0, 0, 0, 0.35);
overflow: hidden;
transition: height 0.35s ease, opacity 0.35s ease;
opacity: 1;
}
.minimap-collapsed {
height: 0 !important;
opacity: 0;
pointer-events: none;
border: none;
box-shadow: none;
}
/* leaflet overrides */
.minimap-leaflet {
background: var(--tv-bg);
}
.minimap-leaflet .leaflet-tile-pane {
filter: brightness(0.7) saturate(0.4);
}
/* hint overlay */
.minimap-hint {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
z-index: 800;
font: 10px var(--tv-mono);
letter-spacing: 0.18em;
color: var(--tv-dim);
background: rgba(15, 12, 9, 0.65);
padding: 4px 14px;
border-radius: var(--tv-r-sm);
pointer-events: none;
}
/* tooltip */
.minimap-tooltip {
background: var(--tv-surface) !important;
color: var(--tv-text) !important;
border: 1px solid var(--tv-line-bright) !important;
border-radius: var(--tv-r-sm) !important;
font: 11px var(--tv-mono) !important;
padding: 3px 10px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4) !important;
}
.minimap-tooltip::before {
border-top-color: var(--tv-surface) !important;
}
/* mobile */
@media (max-width: 768px) {
.minimap-container {
height: 150px;
}
}
/* reduced motion */
@media (prefers-reduced-motion: reduce) {
.minimap-container {
transition: none;
}
}

View File

@@ -0,0 +1,166 @@
import React, { useState, useCallback } from 'react';
import { MapContainer, TileLayer, GeoJSON, useMap } from 'react-leaflet';
import 'leaflet/dist/leaflet.css';
import { useIsMobile } from '../../hooks/useIsMobile';
import './MiniMap.css';
/* ─────────────────────────────────────────────
Region accent palette
───────────────────────────────────────────── */
export const REGION_PALETTE = {
japan: '#e05c4b',
korea: '#d64f6e',
china: '#c84b3a',
europe: '#5b8fc4',
france: '#6f8fc4',
italy: '#78a46e',
spain: '#c4844a',
sea: '#4aad8b',
thailand: '#4aad8b',
vietnam: '#5faa78',
bali: '#7aac5a',
indonesia: '#8aaa4a',
america: '#b4885c',
usa: '#b4885c',
canada: '#6a9890',
africa: '#c47c3c',
middle: '#c4a24a',
dubai: '#c4a24a',
default: '#c8905e',
};
export function getRegionAccent(regionId = '') {
const id = regionId.toLowerCase();
for (const [key, color] of Object.entries(REGION_PALETTE)) {
if (key !== 'default' && id.includes(key)) return color;
}
return REGION_PALETTE.default;
}
/* ─────────────────────────────────────────────
MapLayer — internal component
───────────────────────────────────────────── */
function MapLayer({ geojson, selectedRegionId, onSelectRegion }) {
const map = useMap();
const style = useCallback(
(feature) => {
const rid = feature.properties?.id || feature.properties?.name || '';
const isSelected =
selectedRegionId && rid.toLowerCase() === selectedRegionId.toLowerCase();
const accent = getRegionAccent(rid);
return {
fillColor: isSelected ? accent : 'rgba(232,221,208,0.12)',
fillOpacity: isSelected ? 0.45 : 0.18,
color: isSelected ? accent : 'rgba(232,221,208,0.25)',
weight: isSelected ? 2.5 : 1,
};
},
[selectedRegionId],
);
const onEachFeature = useCallback(
(feature, layer) => {
const name =
feature.properties?.name_ko ||
feature.properties?.name ||
feature.properties?.id ||
'';
if (name) {
layer.bindTooltip(name, {
className: 'minimap-tooltip',
sticky: true,
});
}
layer.on('click', () => {
const rid = feature.properties?.id || feature.properties?.name || '';
onSelectRegion(rid);
const bounds = layer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 6 });
}
});
},
[map, onSelectRegion],
);
if (!geojson) return null;
return (
<GeoJSON
key={selectedRegionId || '__all__'}
data={geojson}
style={style}
onEachFeature={onEachFeature}
/>
);
}
/* ─────────────────────────────────────────────
MiniMap
───────────────────────────────────────────── */
export default function MiniMap({
geojson,
selectedRegionId,
onSelectRegion,
onClearRegion,
}) {
const [expanded, setExpanded] = useState(true);
const isMobile = useIsMobile();
const toggleExpanded = () => setExpanded((v) => !v);
return (
<div className="minimap-wrapper">
{/* toolbar */}
<div className="minimap-toolbar">
<button
className="minimap-toggle-btn"
onClick={toggleExpanded}
aria-label={expanded ? '지도 접기' : '지도 펼치기'}
>
{expanded ? '▲ 지도 접기' : '▼ 지도 펼치기'}
</button>
{selectedRegionId && (
<button className="minimap-clear-btn" onClick={onClearRegion}>
전체 보기
</button>
)}
</div>
{/* map container */}
<div
className={`minimap-container${expanded ? '' : ' minimap-collapsed'}`}
style={{
'--minimap-h': isMobile ? '150px' : '200px',
}}
>
<MapContainer
center={[30, 125]}
zoom={2}
minZoom={2}
maxZoom={7}
zoomControl={false}
attributionControl={false}
className="minimap-leaflet"
style={{ width: '100%', height: '100%' }}
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution=""
/>
<MapLayer
geojson={geojson}
selectedRegionId={selectedRegionId}
onSelectRegion={onSelectRegion}
/>
</MapContainer>
{!selectedRegionId && expanded && (
<div className="minimap-hint">CLICK A REGION</div>
)}
</div>
</div>
);
}