Compare commits
6 커밋
f5ef24c02f
...
3acda7432e
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
| 3acda7432e | |||
| d2178a6134 | |||
| 1a3dd82eb4 | |||
| 650888adb7 | |||
| 4c257a2883 | |||
| 289f1bebc0 |
@ -921,6 +921,182 @@ body {
|
|||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Map Settings Panel ────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.map-settings-gear {
|
||||||
|
position: absolute;
|
||||||
|
top: 100px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 850;
|
||||||
|
width: 29px;
|
||||||
|
height: 29px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 16px;
|
||||||
|
transition: color 0.15s, border-color 0.15s;
|
||||||
|
user-select: none;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-gear:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-gear.open {
|
||||||
|
color: var(--accent);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 48px;
|
||||||
|
z-index: 850;
|
||||||
|
background: rgba(15, 23, 42, 0.95);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
width: 240px;
|
||||||
|
max-height: calc(100vh - 80px);
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-title {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 1px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-section {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
background: transparent;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input::-webkit-color-swatch-wrapper {
|
||||||
|
padding: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-color-input::-webkit-color-swatch {
|
||||||
|
border: none;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-hex {
|
||||||
|
font-size: 9px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-depth-label {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text);
|
||||||
|
min-width: 48px;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel select {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel select:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-reset {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 9px;
|
||||||
|
padding: 5px 8px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.map-settings-panel .ms-reset:hover {
|
||||||
|
color: var(--text);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Depth Legend ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.depth-legend {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 44px;
|
||||||
|
left: 10px;
|
||||||
|
z-index: 800;
|
||||||
|
background: rgba(15, 23, 42, 0.92);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-legend__bar {
|
||||||
|
width: 14px;
|
||||||
|
border-radius: 3px;
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.depth-legend__ticks {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
font-size: 8px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-family: monospace;
|
||||||
|
padding: 1px 0;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 920px) {
|
@media (max-width: 920px) {
|
||||||
.app {
|
.app {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
221
apps/web/src/features/mapSettings/MapSettingsPanel.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
32
apps/web/src/features/mapSettings/types.ts
Normal file
32
apps/web/src/features/mapSettings/types.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
export type MapLabelLanguage = 'ko' | 'en' | 'ja' | 'zh' | 'local';
|
||||||
|
export type DepthFontSize = 'small' | 'medium' | 'large';
|
||||||
|
|
||||||
|
export interface DepthColorStop {
|
||||||
|
depth: number;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MapStyleSettings {
|
||||||
|
labelLanguage: MapLabelLanguage;
|
||||||
|
landColor: string;
|
||||||
|
waterBaseColor: string;
|
||||||
|
depthStops: DepthColorStop[];
|
||||||
|
depthFontSize: DepthFontSize;
|
||||||
|
depthFontColor: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_MAP_STYLE_SETTINGS: MapStyleSettings = {
|
||||||
|
labelLanguage: 'ko',
|
||||||
|
landColor: '#1a1a2e',
|
||||||
|
waterBaseColor: '#14606e',
|
||||||
|
depthStops: [
|
||||||
|
{ depth: -8000, color: '#010610' },
|
||||||
|
{ depth: -4000, color: '#030c1c' },
|
||||||
|
{ depth: -2000, color: '#041022' },
|
||||||
|
{ depth: -1000, color: '#051529' },
|
||||||
|
{ depth: -500, color: '#061a30' },
|
||||||
|
{ depth: -100, color: '#08263d' },
|
||||||
|
],
|
||||||
|
depthFontSize: 'medium',
|
||||||
|
depthFontColor: '#e2e8f0',
|
||||||
|
};
|
||||||
@ -24,6 +24,10 @@ import { Topbar } from "../../widgets/topbar/Topbar";
|
|||||||
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel";
|
||||||
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel";
|
||||||
import { VesselList } from "../../widgets/vesselList/VesselList";
|
import { VesselList } from "../../widgets/vesselList/VesselList";
|
||||||
|
import { MapSettingsPanel } from "../../features/mapSettings/MapSettingsPanel";
|
||||||
|
import { DepthLegend } from "../../widgets/legend/DepthLegend";
|
||||||
|
import { DEFAULT_MAP_STYLE_SETTINGS } from "../../features/mapSettings/types";
|
||||||
|
import type { MapStyleSettings } from "../../features/mapSettings/types";
|
||||||
import {
|
import {
|
||||||
buildLegacyHitMap,
|
buildLegacyHitMap,
|
||||||
computeCountsByType,
|
computeCountsByType,
|
||||||
@ -109,8 +113,11 @@ export function DashboardPage() {
|
|||||||
const [showTargets, setShowTargets] = useState(true);
|
const [showTargets, setShowTargets] = useState(true);
|
||||||
const [showOthers, setShowOthers] = useState(false);
|
const [showOthers, setShowOthers] = useState(false);
|
||||||
|
|
||||||
const [baseMap, setBaseMap] = useState<BaseMapId>("enhanced");
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 등 추가 시 재활용
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
const [baseMap, _setBaseMap] = useState<BaseMapId>("enhanced");
|
||||||
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
const [projection, setProjection] = useState<MapProjectionId>("mercator");
|
||||||
|
const [mapStyleSettings, setMapStyleSettings] = useState<MapStyleSettings>(DEFAULT_MAP_STYLE_SETTINGS);
|
||||||
|
|
||||||
const [overlays, setOverlays] = useState<MapToggleState>({
|
const [overlays, setOverlays] = useState<MapToggleState>({
|
||||||
pairLines: true,
|
pairLines: true,
|
||||||
@ -352,30 +359,28 @@ export function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb">
|
<div className="sb">
|
||||||
<div className="sb-t">지도 표시 설정</div>
|
<div className="sb-t" style={{ display: "flex", alignItems: "center" }}>
|
||||||
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
지도 표시 설정
|
||||||
<div style={{ fontSize: 9, fontWeight: 700, color: "var(--muted)", letterSpacing: 1.5, marginTop: 8, marginBottom: 6 }}>
|
<div style={{ flex: 1 }} />
|
||||||
베이스맵
|
<div
|
||||||
|
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
||||||
|
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
||||||
|
title="3D 지구본 투영: 드래그로 회전, 휠로 확대/축소"
|
||||||
|
style={{ fontSize: 9, padding: "2px 8px" }}
|
||||||
|
>
|
||||||
|
3D
|
||||||
</div>
|
</div>
|
||||||
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center" }}>
|
</div>
|
||||||
|
<MapToggles value={overlays} onToggle={(k) => setOverlays((prev) => ({ ...prev, [k]: !prev[k] }))} />
|
||||||
|
{/* 베이스맵 선택 — 현재 enhanced 단일 맵 사용, 레거시는 비활성
|
||||||
|
<div className="tog" style={{ flexWrap: "nowrap", alignItems: "center", marginTop: 8 }}>
|
||||||
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
<div className={`tog-btn ${baseMap === "enhanced" ? "on" : ""}`} onClick={() => setBaseMap("enhanced")} title="현재 지도(해저지형/3D 표현 포함)">
|
||||||
기본
|
기본
|
||||||
</div>
|
</div>
|
||||||
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
<div className={`tog-btn ${baseMap === "legacy" ? "on" : ""}`} onClick={() => setBaseMap("legacy")} title="레거시 대시보드(Carto Dark) 베이스맵">
|
||||||
레거시
|
레거시
|
||||||
</div>
|
</div>
|
||||||
|
</div> */}
|
||||||
<div style={{ flex: 1 }} />
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={`tog-btn ${projection === "globe" ? "on" : ""}`}
|
|
||||||
onClick={() => setProjection((p) => (p === "globe" ? "mercator" : "globe"))}
|
|
||||||
title="지구본(globe) 투영: 드래그로 회전, 휠로 확대/축소"
|
|
||||||
>
|
|
||||||
지구본
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/* Attribution (license) stays visible in the map UI; no need to repeat it here. */}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="sb">
|
<div className="sb">
|
||||||
@ -722,7 +727,10 @@ export function DashboardPage() {
|
|||||||
hoveredCableId={hoveredCableId}
|
hoveredCableId={hoveredCableId}
|
||||||
onHoverCable={setHoveredCableId}
|
onHoverCable={setHoveredCableId}
|
||||||
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))}
|
||||||
|
mapStyleSettings={mapStyleSettings}
|
||||||
/>
|
/>
|
||||||
|
<MapSettingsPanel value={mapStyleSettings} onChange={setMapStyleSettings} />
|
||||||
|
<DepthLegend depthStops={mapStyleSettings.depthStops} />
|
||||||
<MapLegend />
|
<MapLegend />
|
||||||
{selectedLegacyVessel ? (
|
{selectedLegacyVessel ? (
|
||||||
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
<VesselInfoPanel vessel={selectedLegacyVessel} allVessels={legacyVesselsAll} onClose={() => setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} />
|
||||||
|
|||||||
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
33
apps/web/src/widgets/legend/DepthLegend.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useMemo } from 'react';
|
||||||
|
import type { DepthColorStop } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
|
interface DepthLegendProps {
|
||||||
|
depthStops: DepthColorStop[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DepthLegend({ depthStops }: DepthLegendProps) {
|
||||||
|
const sorted = useMemo(
|
||||||
|
() => [...depthStops].sort((a, b) => a.depth - b.depth),
|
||||||
|
[depthStops],
|
||||||
|
);
|
||||||
|
|
||||||
|
const gradient = useMemo(() => {
|
||||||
|
if (sorted.length === 0) return 'transparent';
|
||||||
|
const stops = sorted.map((s, i) => {
|
||||||
|
const pct = (i / (sorted.length - 1)) * 100;
|
||||||
|
return `${s.color} ${pct.toFixed(0)}%`;
|
||||||
|
});
|
||||||
|
return `linear-gradient(to bottom, ${stops.join(', ')})`;
|
||||||
|
}, [sorted]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="depth-legend">
|
||||||
|
<div className="depth-legend__bar" style={{ background: gradient }} />
|
||||||
|
<div className="depth-legend__ticks">
|
||||||
|
{sorted.map((s) => (
|
||||||
|
<span key={s.depth}>{Math.abs(s.depth).toLocaleString()}m</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -26,6 +26,7 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays';
|
|||||||
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
import { useGlobeInteraction } from './hooks/useGlobeInteraction';
|
||||||
import { useDeckLayers } from './hooks/useDeckLayers';
|
import { useDeckLayers } from './hooks/useDeckLayers';
|
||||||
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
import { useSubcablesLayer } from './hooks/useSubcablesLayer';
|
||||||
|
import { useMapStyleSettings } from './hooks/useMapStyleSettings';
|
||||||
|
|
||||||
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
export type { Map3DSettings, BaseMapId, MapProjectionId } from './types';
|
||||||
|
|
||||||
@ -64,6 +65,7 @@ export function Map3D({
|
|||||||
hoveredCableId = null,
|
hoveredCableId = null,
|
||||||
onHoverCable,
|
onHoverCable,
|
||||||
onClickCable,
|
onClickCable,
|
||||||
|
mapStyleSettings,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
void onHoverFleet;
|
void onHoverFleet;
|
||||||
void onClearFleetHover;
|
void onClearFleetHover;
|
||||||
@ -448,6 +450,8 @@ export function Map3D({
|
|||||||
{ baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync },
|
{ baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch });
|
||||||
|
|
||||||
useZonesLayer(
|
useZonesLayer(
|
||||||
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
mapRef, projectionBusyRef, reorderGlobeFeatureLayers,
|
||||||
{ zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch },
|
{ zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch },
|
||||||
|
|||||||
@ -3,7 +3,6 @@ import {
|
|||||||
OVERLAY_RGB,
|
OVERLAY_RGB,
|
||||||
rgba as rgbaCss,
|
rgba as rgbaCss,
|
||||||
} from '../../shared/lib/map/palette';
|
} from '../../shared/lib/map/palette';
|
||||||
import type { BathyZoomRange } from './types';
|
|
||||||
|
|
||||||
// ── Re-export palette aliases used throughout Map3D ──
|
// ── Re-export palette aliases used throughout Map3D ──
|
||||||
|
|
||||||
@ -158,9 +157,5 @@ export const FLEET_LINE_ML = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.65);
|
|||||||
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
export const FLEET_LINE_ML_HL = rgbaCss(OVERLAY_FLEET_RANGE_RGB, 0.95);
|
||||||
|
|
||||||
// ── Bathymetry zoom ranges ──
|
// ── Bathymetry zoom ranges ──
|
||||||
|
// NOTE: BATHY_ZOOM_RANGES는 bathymetry.ts에서 로컬 정의 + applyBathymetryZoomProfile()에서 사용
|
||||||
export const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
// 이 파일의 export는 사용처가 없어 제거됨 (2026-02-16)
|
||||||
{ id: 'bathymetry-fill', mercator: [5, 24], globe: [7, 24] },
|
|
||||||
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [7, 24] },
|
|
||||||
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [7, 24] },
|
|
||||||
];
|
|
||||||
|
|||||||
179
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
179
apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { useEffect, useRef, type MutableRefObject } from 'react';
|
||||||
|
import maplibregl from 'maplibre-gl';
|
||||||
|
import type { MapStyleSettings, MapLabelLanguage, DepthColorStop, DepthFontSize } from '../../../features/mapSettings/types';
|
||||||
|
import type { BaseMapId } from '../types';
|
||||||
|
import { kickRepaint, onMapStyleReady } from '../lib/mapCore';
|
||||||
|
|
||||||
|
/* ── Depth font size presets ──────────────────────────────────────── */
|
||||||
|
const DEPTH_FONT_SIZE_MAP: Record<DepthFontSize, unknown[]> = {
|
||||||
|
small: ['interpolate', ['linear'], ['zoom'], 7, 8, 9, 9, 11, 11, 13, 13],
|
||||||
|
medium: ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||||
|
large: ['interpolate', ['linear'], ['zoom'], 7, 12, 9, 15, 11, 18, 13, 20],
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ── Helpers ──────────────────────────────────────────────────────── */
|
||||||
|
function darkenHex(hex: string, factor = 0.85): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `#${[r, g, b].map((c) => Math.round(c * factor).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function lightenHex(hex: string, factor = 1.3): string {
|
||||||
|
const r = parseInt(hex.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hex.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hex.slice(5, 7), 16);
|
||||||
|
return `#${[r, g, b].map((c) => Math.min(255, Math.round(c * factor)).toString(16).padStart(2, '0')).join('')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Apply functions ──────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
function applyLabelLanguage(map: maplibregl.Map, lang: MapLabelLanguage) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
if (layer.type !== 'symbol') continue;
|
||||||
|
const layout = (layer as { layout?: Record<string, unknown> }).layout;
|
||||||
|
if (!layout?.['text-field']) continue;
|
||||||
|
if (layer.id === 'bathymetry-labels') continue;
|
||||||
|
const textField =
|
||||||
|
lang === 'local'
|
||||||
|
? ['get', 'name']
|
||||||
|
: ['coalesce', ['get', `name:${lang}`], ['get', 'name']];
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layer.id, 'text-field', textField);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLandColor(map: maplibregl.Map, color: string) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const darkVariant = darkenHex(color, 0.8);
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
const id = layer.id;
|
||||||
|
if (id.startsWith('bathymetry-')) continue;
|
||||||
|
if (id.startsWith('subcables-')) continue;
|
||||||
|
if (id.startsWith('zones-')) continue;
|
||||||
|
if (id.startsWith('ships-')) continue;
|
||||||
|
if (id.startsWith('pair-')) continue;
|
||||||
|
if (id.startsWith('fc-')) continue;
|
||||||
|
if (id.startsWith('fleet-')) continue;
|
||||||
|
if (id.startsWith('predict-')) continue;
|
||||||
|
if (id === 'deck-globe') continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||||
|
const isWater = waterRegex.test(id) || waterRegex.test(sourceLayer);
|
||||||
|
if (isWater) continue;
|
||||||
|
try {
|
||||||
|
if (layer.type === 'background') {
|
||||||
|
map.setPaintProperty(id, 'background-color', color);
|
||||||
|
} else if (layer.type === 'fill') {
|
||||||
|
map.setPaintProperty(id, 'fill-color', darkVariant);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyWaterBaseColor(map: maplibregl.Map, fillColor: string) {
|
||||||
|
const style = map.getStyle();
|
||||||
|
if (!style?.layers) return;
|
||||||
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
|
const lineColor = darkenHex(fillColor, 0.85);
|
||||||
|
for (const layer of style.layers) {
|
||||||
|
const id = layer.id;
|
||||||
|
if (id.startsWith('bathymetry-')) continue;
|
||||||
|
const sourceLayer = String((layer as Record<string, unknown>)['source-layer'] ?? '');
|
||||||
|
if (!waterRegex.test(id) && !waterRegex.test(sourceLayer)) continue;
|
||||||
|
try {
|
||||||
|
if (layer.type === 'fill') {
|
||||||
|
map.setPaintProperty(id, 'fill-color', fillColor);
|
||||||
|
} else if (layer.type === 'line') {
|
||||||
|
map.setPaintProperty(id, 'line-color', lineColor);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthGradient(map: maplibregl.Map, stops: DepthColorStop[]) {
|
||||||
|
if (!map.getLayer('bathymetry-fill')) return;
|
||||||
|
const depth = ['to-number', ['get', 'depth']];
|
||||||
|
const sorted = [...stops].sort((a, b) => a.depth - b.depth);
|
||||||
|
const expr: unknown[] = ['interpolate', ['linear'], depth];
|
||||||
|
const deepest = sorted[0];
|
||||||
|
if (deepest) expr.push(-11000, darkenHex(deepest.color, 0.5));
|
||||||
|
for (const s of sorted) {
|
||||||
|
expr.push(s.depth, s.color);
|
||||||
|
}
|
||||||
|
const shallowest = sorted[sorted.length - 1];
|
||||||
|
if (shallowest) expr.push(0, lightenHex(shallowest.color, 1.8));
|
||||||
|
try {
|
||||||
|
map.setPaintProperty('bathymetry-fill', 'fill-color', expr as never);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthFontSize(map: maplibregl.Map, size: DepthFontSize) {
|
||||||
|
const expr = DEPTH_FONT_SIZE_MAP[size];
|
||||||
|
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||||
|
if (!map.getLayer(layerId)) continue;
|
||||||
|
try {
|
||||||
|
map.setLayoutProperty(layerId, 'text-size', expr);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDepthFontColor(map: maplibregl.Map, color: string) {
|
||||||
|
for (const layerId of ['bathymetry-labels', 'bathymetry-landforms']) {
|
||||||
|
if (!map.getLayer(layerId)) continue;
|
||||||
|
try {
|
||||||
|
map.setPaintProperty(layerId, 'text-color', color);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hook ──────────────────────────────────────────────────────────── */
|
||||||
|
export function useMapStyleSettings(
|
||||||
|
mapRef: MutableRefObject<maplibregl.Map | null>,
|
||||||
|
settings: MapStyleSettings | undefined,
|
||||||
|
opts: { baseMap: BaseMapId; mapSyncEpoch: number },
|
||||||
|
) {
|
||||||
|
const settingsRef = useRef(settings);
|
||||||
|
useEffect(() => {
|
||||||
|
settingsRef.current = settings;
|
||||||
|
});
|
||||||
|
|
||||||
|
const { baseMap, mapSyncEpoch } = opts;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
const s = settingsRef.current;
|
||||||
|
if (!map || !s) return;
|
||||||
|
|
||||||
|
const stop = onMapStyleReady(map, () => {
|
||||||
|
applyLabelLanguage(map, s.labelLanguage);
|
||||||
|
applyLandColor(map, s.landColor);
|
||||||
|
applyWaterBaseColor(map, s.waterBaseColor);
|
||||||
|
if (baseMap === 'enhanced') {
|
||||||
|
applyDepthGradient(map, s.depthStops);
|
||||||
|
applyDepthFontSize(map, s.depthFontSize);
|
||||||
|
applyDepthFontColor(map, s.depthFontColor);
|
||||||
|
}
|
||||||
|
kickRepaint(map);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => stop();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [settings, baseMap, mapSyncEpoch]);
|
||||||
|
}
|
||||||
@ -6,10 +6,13 @@ import maplibregl, {
|
|||||||
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
|
import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types';
|
||||||
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
|
import { getLayerId, getMapTilerKey } from '../lib/mapCore';
|
||||||
|
|
||||||
|
export const SHALLOW_WATER_FILL_DEFAULT = '#14606e';
|
||||||
|
export const SHALLOW_WATER_LINE_DEFAULT = '#114f5c';
|
||||||
|
|
||||||
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
const BATHY_ZOOM_RANGES: BathyZoomRange[] = [
|
||||||
{ id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] },
|
{ id: 'bathymetry-fill', mercator: [3, 24], globe: [3, 24] },
|
||||||
{ id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] },
|
{ id: 'bathymetry-borders', mercator: [5, 24], globe: [5, 24] },
|
||||||
{ id: 'bathymetry-borders-major', mercator: [4, 24], globe: [8, 24] },
|
{ id: 'bathymetry-borders-major', mercator: [3, 24], globe: [3, 24] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerKey: string) {
|
||||||
@ -66,7 +69,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
type: 'fill',
|
type: 'fill',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour',
|
'source-layer': 'contour',
|
||||||
minzoom: 5,
|
minzoom: 3,
|
||||||
maxzoom: 24,
|
maxzoom: 24,
|
||||||
paint: {
|
paint: {
|
||||||
'fill-color': bathyFillColor,
|
'fill-color': bathyFillColor,
|
||||||
@ -79,7 +82,7 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
type: 'line',
|
type: 'line',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour',
|
'source-layer': 'contour',
|
||||||
minzoom: 5,
|
minzoom: 5, // fill은 3부터, borders는 5부터
|
||||||
maxzoom: 24,
|
maxzoom: 24,
|
||||||
paint: {
|
paint: {
|
||||||
'line-color': 'rgba(255,255,255,0.06)',
|
'line-color': 'rgba(255,255,255,0.06)',
|
||||||
@ -159,22 +162,22 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'contour_line',
|
'source-layer': 'contour_line',
|
||||||
minzoom: 10,
|
minzoom: 7,
|
||||||
filter: bathyMajorDepthFilter as unknown as unknown[],
|
filter: bathyMajorDepthFilter as unknown as unknown[],
|
||||||
layout: {
|
layout: {
|
||||||
'symbol-placement': 'line',
|
'symbol-placement': 'line',
|
||||||
'text-field': depthLabel,
|
'text-field': depthLabel,
|
||||||
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
'text-font': ['Noto Sans Regular', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 10, 12, 12, 14, 14, 15],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 7, 10, 9, 12, 11, 14, 13, 16],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-padding': 2,
|
'text-padding': 4,
|
||||||
'text-rotation-alignment': 'map',
|
'text-rotation-alignment': 'map',
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-color': 'rgba(226,232,240,0.72)',
|
'text-color': 'rgba(226,232,240,0.78)',
|
||||||
'text-halo-color': 'rgba(2,6,23,0.82)',
|
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||||
'text-halo-width': 1.0,
|
'text-halo-width': 1.2,
|
||||||
'text-halo-blur': 0.6,
|
'text-halo-blur': 0.5,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
@ -183,21 +186,21 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
type: 'symbol',
|
type: 'symbol',
|
||||||
source: oceanSourceId,
|
source: oceanSourceId,
|
||||||
'source-layer': 'landform',
|
'source-layer': 'landform',
|
||||||
minzoom: 8,
|
minzoom: 6,
|
||||||
filter: ['has', 'name'] as unknown as unknown[],
|
filter: ['has', 'name'] as unknown as unknown[],
|
||||||
layout: {
|
layout: {
|
||||||
'text-field': ['get', 'name'] as unknown as unknown[],
|
'text-field': ['get', 'name'] as unknown as unknown[],
|
||||||
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
|
'text-font': ['Noto Sans Italic', 'Noto Sans Regular', 'Open Sans Italic', 'Open Sans Regular'],
|
||||||
'text-size': ['interpolate', ['linear'], ['zoom'], 8, 11, 10, 12, 12, 13],
|
'text-size': ['interpolate', ['linear'], ['zoom'], 6, 10, 8, 12, 10, 13, 12, 14],
|
||||||
'text-allow-overlap': false,
|
'text-allow-overlap': false,
|
||||||
'text-anchor': 'center',
|
'text-anchor': 'center',
|
||||||
'text-offset': [0, 0.0],
|
'text-offset': [0, 0.0],
|
||||||
},
|
},
|
||||||
paint: {
|
paint: {
|
||||||
'text-color': 'rgba(148,163,184,0.70)',
|
'text-color': 'rgba(148,163,184,0.75)',
|
||||||
'text-halo-color': 'rgba(2,6,23,0.85)',
|
'text-halo-color': 'rgba(2,6,23,0.88)',
|
||||||
'text-halo-width': 1.0,
|
'text-halo-width': 1.2,
|
||||||
'text-halo-blur': 0.7,
|
'text-halo-blur': 0.6,
|
||||||
},
|
},
|
||||||
} as unknown as LayerSpecification;
|
} as unknown as LayerSpecification;
|
||||||
|
|
||||||
@ -209,8 +212,8 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK
|
|||||||
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
|
// Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally
|
||||||
// with the bathymetry gradient instead of appearing as near-black voids.
|
// with the bathymetry gradient instead of appearing as near-black voids.
|
||||||
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i;
|
||||||
const SHALLOW_WATER_FILL = '#14606e';
|
const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT;
|
||||||
const SHALLOW_WATER_LINE = '#114f5c';
|
const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT;
|
||||||
for (const layer of layers) {
|
for (const layer of layers) {
|
||||||
const id = getLayerId(layer);
|
const id = getLayerId(layer);
|
||||||
if (!id) continue;
|
if (!id) continue;
|
||||||
@ -272,6 +275,19 @@ export function applyBathymetryZoomProfile(map: maplibregl.Map, baseMap: BaseMap
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyKoreanLabels(style: StyleSpecification) {
|
||||||
|
if (!style.layers) return;
|
||||||
|
const koTextField = ['coalesce', ['get', 'name:ko'], ['get', 'name']];
|
||||||
|
for (const layer of style.layers as unknown as LayerSpecification[]) {
|
||||||
|
if ((layer as { type?: string }).type !== 'symbol') continue;
|
||||||
|
const layout = (layer as Record<string, unknown>).layout as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
|
if (!layout?.['text-field']) continue;
|
||||||
|
layout['text-field'] = koTextField;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
export async function resolveInitialMapStyle(signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
const key = getMapTilerKey();
|
const key = getMapTilerKey();
|
||||||
if (!key) return '/map/styles/osm-seamark.json';
|
if (!key) return '/map/styles/osm-seamark.json';
|
||||||
@ -282,11 +298,14 @@ export async function resolveInitialMapStyle(signal: AbortSignal): Promise<strin
|
|||||||
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
const res = await fetch(styleUrl, { signal, headers: { accept: 'application/json' } });
|
||||||
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
if (!res.ok) throw new Error(`MapTiler style fetch failed: ${res.status} ${res.statusText}`);
|
||||||
const json = (await res.json()) as StyleSpecification;
|
const json = (await res.json()) as StyleSpecification;
|
||||||
|
applyKoreanLabels(json);
|
||||||
injectOceanBathymetryLayers(json, key);
|
injectOceanBathymetryLayers(json, key);
|
||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
export async function resolveMapStyle(baseMap: BaseMapId, signal: AbortSignal): Promise<string | StyleSpecification> {
|
||||||
if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
// 레거시 베이스맵 비활성 — 향후 위성/라이트 테마 등 추가 시 재활용
|
||||||
|
// if (baseMap === 'legacy') return '/map/styles/carto-dark.json';
|
||||||
|
void baseMap;
|
||||||
return resolveInitialMapStyle(signal);
|
return resolveInitialMapStyle(signal);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types';
|
|||||||
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
import type { ZonesGeoJson } from '../../entities/zone/api/useZones';
|
||||||
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
import type { MapToggleState } from '../../features/mapToggles/MapToggles';
|
||||||
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types';
|
||||||
|
import type { MapStyleSettings } from '../../features/mapSettings/types';
|
||||||
|
|
||||||
export type Map3DSettings = {
|
export type Map3DSettings = {
|
||||||
showSeamark: boolean;
|
showSeamark: boolean;
|
||||||
@ -50,6 +51,7 @@ export interface Map3DProps {
|
|||||||
hoveredCableId?: string | null;
|
hoveredCableId?: string | null;
|
||||||
onHoverCable?: (cableId: string | null) => void;
|
onHoverCable?: (cableId: string | null) => void;
|
||||||
onClickCable?: (cableId: string | null) => void;
|
onClickCable?: (cableId: string | null) => void;
|
||||||
|
mapStyleSettings?: MapStyleSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DashSeg = {
|
export type DashSeg = {
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user