gc-wing/apps/web/src/features/mapSettings/MapSettingsPanel.tsx
htlee 1a3dd82eb4 fix(map): 지도 설정 패널 개선
- 육지색 적용 범위 확대 (background + 전체 fill 레이어)
- UI 가독성 개선: 라벨 10px, 색상 대비 강화
- 수심 구간 '자동채우기' 토글 추가 (최소/최대 기준 보간)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 06:23:55 +09:00

222 lines
7.9 KiB
TypeScript

import { useState } from 'react';
import type { MapStyleSettings, MapLabelLanguage, DepthFontSize, DepthColorStop } from './types';
import { DEFAULT_MAP_STYLE_SETTINGS } from './types';
interface MapSettingsPanelProps {
value: MapStyleSettings;
onChange: (next: MapStyleSettings) => void;
}
const LANGUAGES: { value: MapLabelLanguage; label: string }[] = [
{ value: 'ko', label: '한국어' },
{ value: 'en', label: 'English' },
{ value: 'ja', label: '日本語' },
{ value: 'zh', label: '中文' },
{ value: 'local', label: '현지어' },
];
const FONT_SIZES: { value: DepthFontSize; label: string }[] = [
{ value: 'small', label: '소' },
{ value: 'medium', label: '중' },
{ value: 'large', label: '대' },
];
function depthLabel(depth: number): string {
return `${Math.abs(depth).toLocaleString()}m`;
}
function hexToRgb(hex: string): [number, number, number] {
return [
parseInt(hex.slice(1, 3), 16),
parseInt(hex.slice(3, 5), 16),
parseInt(hex.slice(5, 7), 16),
];
}
function rgbToHex(r: number, g: number, b: number): string {
return `#${[r, g, b].map((c) => Math.round(Math.max(0, Math.min(255, c))).toString(16).padStart(2, '0')).join('')}`;
}
function interpolateGradient(stops: DepthColorStop[]): DepthColorStop[] {
if (stops.length < 2) return stops;
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
const first = sorted[0];
const last = sorted[sorted.length - 1];
const [r1, g1, b1] = hexToRgb(first.color);
const [r2, g2, b2] = hexToRgb(last.color);
return sorted.map((stop, i) => {
if (i === 0 || i === sorted.length - 1) return stop;
const t = i / (sorted.length - 1);
return {
depth: stop.depth,
color: rgbToHex(
r1 + (r2 - r1) * t,
g1 + (g2 - g1) * t,
b1 + (b2 - b1) * t,
),
};
});
}
export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) {
const [open, setOpen] = useState(false);
const [autoGradient, setAutoGradient] = useState(false);
const update = <K extends keyof MapStyleSettings>(key: K, val: MapStyleSettings[K]) => {
onChange({ ...value, [key]: val });
};
const updateDepthStop = (index: number, color: string) => {
const next = value.depthStops.map((s, i) => (i === index ? { ...s, color } : s));
if (autoGradient && (index === 0 || index === next.length - 1)) {
update('depthStops', interpolateGradient(next));
} else {
update('depthStops', next);
}
};
const toggleAutoGradient = () => {
const next = !autoGradient;
setAutoGradient(next);
if (next) {
update('depthStops', interpolateGradient(value.depthStops));
}
};
return (
<>
<button
className={`map-settings-gear${open ? ' open' : ''}`}
onClick={() => setOpen((p) => !p)}
title="지도 설정"
type="button"
>
</button>
{open && (
<div className="map-settings-panel">
<div className="ms-title"> </div>
{/* ── Language ──────────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<select
value={value.labelLanguage}
onChange={(e) => update('labelLanguage', e.target.value as MapLabelLanguage)}
>
{LANGUAGES.map((l) => (
<option key={l.value} value={l.value}>
{l.label}
</option>
))}
</select>
</div>
{/* ── Land color ────────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.landColor}
onChange={(e) => update('landColor', e.target.value)}
/>
<span className="ms-hex">{value.landColor}</span>
</div>
</div>
{/* ── Water color ───────────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.waterBaseColor}
onChange={(e) => update('waterBaseColor', e.target.value)}
/>
<span className="ms-hex">{value.waterBaseColor}</span>
</div>
</div>
{/* ── Depth gradient ────────────────────────────── */}
<div className="ms-section">
<div className="ms-label" style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<span
className={`tog-btn${autoGradient ? ' on' : ''}`}
style={{ fontSize: 8, padding: '1px 5px', marginLeft: 8 }}
onClick={toggleAutoGradient}
title="최소/최대 색상 기준으로 중간 구간을 자동 보간합니다"
>
</span>
</div>
{value.depthStops.map((stop, i) => {
const isEdge = i === 0 || i === value.depthStops.length - 1;
const dimmed = autoGradient && !isEdge;
return (
<div className="ms-row" key={stop.depth} style={dimmed ? { opacity: 0.5 } : undefined}>
<span className="ms-depth-label">{depthLabel(stop.depth)}</span>
<input
type="color"
className="ms-color-input"
value={stop.color}
onChange={(e) => updateDepthStop(i, e.target.value)}
disabled={dimmed}
/>
<span className="ms-hex">{stop.color}</span>
</div>
);
})}
</div>
{/* ── Depth font size ───────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="tog" style={{ gap: 3 }}>
{FONT_SIZES.map((fs) => (
<div
key={fs.value}
className={`tog-btn${value.depthFontSize === fs.value ? ' on' : ''}`}
onClick={() => update('depthFontSize', fs.value)}
>
{fs.label}
</div>
))}
</div>
</div>
{/* ── Depth font color ──────────────────────────── */}
<div className="ms-section">
<div className="ms-label"> </div>
<div className="ms-row">
<input
type="color"
className="ms-color-input"
value={value.depthFontColor}
onChange={(e) => update('depthFontColor', e.target.value)}
/>
<span className="ms-hex">{value.depthFontColor}</span>
</div>
</div>
{/* ── Reset ─────────────────────────────────────── */}
<button
className="ms-reset"
type="button"
onClick={() => {
onChange(DEFAULT_MAP_STYLE_SETTINGS);
setAutoGradient(false);
}}
>
</button>
</div>
)}
</>
);
}