From 650888adb7708dc66178bcc686f380423f7471f6 Mon Sep 17 00:00:00 2001 From: htlee Date: Mon, 16 Feb 2026 06:17:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(map):=20=EC=A7=80=EB=8F=84=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=ED=8C=A8=EB=84=90=20+=20=EC=88=98=EC=8B=AC=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 나침반/줌 컨트롤 분리, 기어 버튼으로 설정 패널 토글 - 설정 항목: 레이블 언어, 육지/물/수심 색상, 수심 폰트 크기/색상 - 런타임 map.setPaintProperty/setLayoutProperty로 즉시 적용 - 수심 색상 범례 (좌하단 그라데이션 바 + 눈금) - 초기화 버튼으로 디폴트 복원 Co-Authored-By: Claude Opus 4.6 --- apps/web/src/app/styles.css | 176 ++++++++++++++++++ .../features/mapSettings/MapSettingsPanel.tsx | 157 ++++++++++++++++ apps/web/src/features/mapSettings/types.ts | 32 ++++ .../web/src/pages/dashboard/DashboardPage.tsx | 8 + apps/web/src/widgets/legend/DepthLegend.tsx | 33 ++++ apps/web/src/widgets/map3d/Map3D.tsx | 4 + .../web/src/widgets/map3d/hooks/useMapInit.ts | 3 +- .../map3d/hooks/useMapStyleSettings.ts | 165 ++++++++++++++++ .../src/widgets/map3d/layers/bathymetry.ts | 7 +- apps/web/src/widgets/map3d/types.ts | 2 + 10 files changed, 584 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/features/mapSettings/MapSettingsPanel.tsx create mode 100644 apps/web/src/features/mapSettings/types.ts create mode 100644 apps/web/src/widgets/legend/DepthLegend.tsx create mode 100644 apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts diff --git a/apps/web/src/app/styles.css b/apps/web/src/app/styles.css index a959fd6..1037b54 100644 --- a/apps/web/src/app/styles.css +++ b/apps/web/src/app/styles.css @@ -921,6 +921,182 @@ body { border-radius: 8px; } +/* ── Map Settings Panel ────────────────────────────────────────────── */ + +.map-settings-gear { + position: absolute; + top: 95px; + 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: 10px; + 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: 8px; + font-weight: 700; + color: var(--muted); + letter-spacing: 1px; + margin-bottom: 4px; +} + +.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: var(--muted); + font-family: monospace; +} + +.map-settings-panel .ms-depth-label { + font-size: 9px; + 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) { .app { grid-template-columns: 1fr; diff --git a/apps/web/src/features/mapSettings/MapSettingsPanel.tsx b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx new file mode 100644 index 0000000..f7dc386 --- /dev/null +++ b/apps/web/src/features/mapSettings/MapSettingsPanel.tsx @@ -0,0 +1,157 @@ +import { useState } from 'react'; +import type { MapStyleSettings, MapLabelLanguage, DepthFontSize } 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`; +} + +export function MapSettingsPanel({ value, onChange }: MapSettingsPanelProps) { + const [open, setOpen] = useState(false); + + const update = (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)); + update('depthStops', next); + }; + + return ( + <> + + + {open && ( +
+
지도 설정
+ + {/* ── Language ──────────────────────────────────── */} +
+
레이블 언어
+ +
+ + {/* ── Land color ────────────────────────────────── */} +
+
육지 색상
+
+ update('landColor', e.target.value)} + /> + {value.landColor} +
+
+ + {/* ── Water color ───────────────────────────────── */} +
+
물 기본색
+
+ update('waterBaseColor', e.target.value)} + /> + {value.waterBaseColor} +
+
+ + {/* ── Depth gradient ────────────────────────────── */} +
+
수심 구간 색상
+ {value.depthStops.map((stop, i) => ( +
+ {depthLabel(stop.depth)} + updateDepthStop(i, e.target.value)} + /> + {stop.color} +
+ ))} +
+ + {/* ── Depth font size ───────────────────────────── */} +
+
수심 폰트 크기
+
+ {FONT_SIZES.map((fs) => ( +
update('depthFontSize', fs.value)} + > + {fs.label} +
+ ))} +
+
+ + {/* ── Depth font color ──────────────────────────── */} +
+
수심 폰트 색상
+
+ update('depthFontColor', e.target.value)} + /> + {value.depthFontColor} +
+
+ + {/* ── Reset ─────────────────────────────────────── */} + +
+ )} + + ); +} diff --git a/apps/web/src/features/mapSettings/types.ts b/apps/web/src/features/mapSettings/types.ts new file mode 100644 index 0000000..546a126 --- /dev/null +++ b/apps/web/src/features/mapSettings/types.ts @@ -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', +}; diff --git a/apps/web/src/pages/dashboard/DashboardPage.tsx b/apps/web/src/pages/dashboard/DashboardPage.tsx index bbc2489..62a5302 100644 --- a/apps/web/src/pages/dashboard/DashboardPage.tsx +++ b/apps/web/src/pages/dashboard/DashboardPage.tsx @@ -24,6 +24,10 @@ import { Topbar } from "../../widgets/topbar/Topbar"; import { VesselInfoPanel } from "../../widgets/info/VesselInfoPanel"; import { SubcableInfoPanel } from "../../widgets/subcableInfo/SubcableInfoPanel"; 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 { buildLegacyHitMap, computeCountsByType, @@ -111,6 +115,7 @@ export function DashboardPage() { const [baseMap, setBaseMap] = useState("enhanced"); const [projection, setProjection] = useState("mercator"); + const [mapStyleSettings, setMapStyleSettings] = useState(DEFAULT_MAP_STYLE_SETTINGS); const [overlays, setOverlays] = useState({ pairLines: true, @@ -722,7 +727,10 @@ export function DashboardPage() { hoveredCableId={hoveredCableId} onHoverCable={setHoveredCableId} onClickCable={(id) => setSelectedCableId((prev) => (prev === id ? null : id))} + mapStyleSettings={mapStyleSettings} /> + + {selectedLegacyVessel ? ( setSelectedMmsi(null)} onSelectMmsi={setSelectedMmsi} /> diff --git a/apps/web/src/widgets/legend/DepthLegend.tsx b/apps/web/src/widgets/legend/DepthLegend.tsx new file mode 100644 index 0000000..48747e8 --- /dev/null +++ b/apps/web/src/widgets/legend/DepthLegend.tsx @@ -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 ( +
+
+
+ {sorted.map((s) => ( + {Math.abs(s.depth).toLocaleString()}m + ))} +
+
+ ); +} diff --git a/apps/web/src/widgets/map3d/Map3D.tsx b/apps/web/src/widgets/map3d/Map3D.tsx index 19e919a..c3a534f 100644 --- a/apps/web/src/widgets/map3d/Map3D.tsx +++ b/apps/web/src/widgets/map3d/Map3D.tsx @@ -26,6 +26,7 @@ import { useGlobeOverlays } from './hooks/useGlobeOverlays'; import { useGlobeInteraction } from './hooks/useGlobeInteraction'; import { useDeckLayers } from './hooks/useDeckLayers'; import { useSubcablesLayer } from './hooks/useSubcablesLayer'; +import { useMapStyleSettings } from './hooks/useMapStyleSettings'; export type { Map3DSettings, BaseMapId, MapProjectionId } from './types'; @@ -64,6 +65,7 @@ export function Map3D({ hoveredCableId = null, onHoverCable, onClickCable, + mapStyleSettings, }: Props) { void onHoverFleet; void onClearFleetHover; @@ -448,6 +450,8 @@ export function Map3D({ { baseMap, projection, showSeamark: settings.showSeamark, mapSyncEpoch, pulseMapSync }, ); + useMapStyleSettings(mapRef, mapStyleSettings, { baseMap, mapSyncEpoch }); + useZonesLayer( mapRef, projectionBusyRef, reorderGlobeFeatureLayers, { zones, overlays, projection, baseMap, hoveredZoneId, mapSyncEpoch }, diff --git a/apps/web/src/widgets/map3d/hooks/useMapInit.ts b/apps/web/src/widgets/map3d/hooks/useMapInit.ts index 26fb2de..6e64376 100644 --- a/apps/web/src/widgets/map3d/hooks/useMapInit.ts +++ b/apps/web/src/widgets/map3d/hooks/useMapInit.ts @@ -91,7 +91,8 @@ export function useMapInit( scrollZoom: { around: 'center' }, }); - map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: true }), 'top-left'); + map.addControl(new maplibregl.NavigationControl({ showZoom: true, showCompass: false }), 'top-left'); + map.addControl(new maplibregl.NavigationControl({ showZoom: false, showCompass: true }), 'top-left'); map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left'); mapRef.current = map; diff --git a/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts new file mode 100644 index 0000000..1f6fece --- /dev/null +++ b/apps/web/src/widgets/map3d/hooks/useMapStyleSettings.ts @@ -0,0 +1,165 @@ +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 = { + 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 }).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 landRegex = /(land|landcover|landuse|earth|continent|terrain|park)/i; + for (const layer of style.layers) { + if (layer.type !== 'fill') continue; + const id = layer.id; + const sourceLayer = String((layer as Record)['source-layer'] ?? ''); + if (!landRegex.test(id) && !landRegex.test(sourceLayer)) continue; + try { + map.setPaintProperty(id, 'fill-color', color); + } 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)['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, + 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]); +} diff --git a/apps/web/src/widgets/map3d/layers/bathymetry.ts b/apps/web/src/widgets/map3d/layers/bathymetry.ts index 65d3909..afcf223 100644 --- a/apps/web/src/widgets/map3d/layers/bathymetry.ts +++ b/apps/web/src/widgets/map3d/layers/bathymetry.ts @@ -6,6 +6,9 @@ import maplibregl, { import type { BaseMapId, BathyZoomRange, MapProjectionId } from '../types'; 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[] = [ { id: 'bathymetry-fill', mercator: [6, 24], globe: [8, 24] }, { id: 'bathymetry-borders', mercator: [6, 24], globe: [8, 24] }, @@ -209,8 +212,8 @@ export function injectOceanBathymetryLayers(style: StyleSpecification, maptilerK // Brighten base-map water fills so shallow coasts, rivers & lakes blend naturally // with the bathymetry gradient instead of appearing as near-black voids. const waterRegex = /(water|sea|ocean|river|lake|coast|bay)/i; - const SHALLOW_WATER_FILL = '#14606e'; - const SHALLOW_WATER_LINE = '#114f5c'; + const SHALLOW_WATER_FILL = SHALLOW_WATER_FILL_DEFAULT; + const SHALLOW_WATER_LINE = SHALLOW_WATER_LINE_DEFAULT; for (const layer of layers) { const id = getLayerId(layer); if (!id) continue; diff --git a/apps/web/src/widgets/map3d/types.ts b/apps/web/src/widgets/map3d/types.ts index 98358f6..16d1d1f 100644 --- a/apps/web/src/widgets/map3d/types.ts +++ b/apps/web/src/widgets/map3d/types.ts @@ -4,6 +4,7 @@ import type { SubcableGeoJson } from '../../entities/subcable/model/types'; import type { ZonesGeoJson } from '../../entities/zone/api/useZones'; import type { MapToggleState } from '../../features/mapToggles/MapToggles'; import type { FcLink, FleetCircle, PairLink } from '../../features/legacyDashboard/model/types'; +import type { MapStyleSettings } from '../../features/mapSettings/types'; export type Map3DSettings = { showSeamark: boolean; @@ -50,6 +51,7 @@ export interface Map3DProps { hoveredCableId?: string | null; onHoverCable?: (cableId: string | null) => void; onClickCable?: (cableId: string | null) => void; + mapStyleSettings?: MapStyleSettings; } export type DashSeg = {