From d14222242aba0b8248c186a78fad98481620ba78 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 14:35:42 +0900 Subject: [PATCH] =?UTF-8?q?refactor(B04/B05/=EA=B3=B5=ED=86=B5):=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EC=83=89=EC=83=81=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=ED=99=94,=20=EB=B0=B0=EC=88=98=EC=9C=A0=EC=97=AD=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20i18n,=20=EC=86=8C=EA=B0=9C=20=ED=8E=98=EC=9D=B4?= =?UTF-8?q?=EC=A7=80=20=EB=8B=A8=EA=B3=84=EB=AA=85=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ui_template_theme.css에 2D 지도 벡터 팔레트(--map-*) 33종을 유일한 정의처로 등록하고, 캔버스에서 CSS 변수를 읽는 공통 유틸 ui_template_palette.ts(themeColor)를 신설. 값은 한 번만 읽어 캐시하고 data-theme 변경 시 비운다(다크 전환 대응). - B04 지도·유역 오버레이·흐름 화살표와 B05 배수유역도·유역선 편집·배관 마커의 하드코딩 색상을 전부 토큰 조회로 교체. 계획선 색·굵기, 후광색은 공용 함수로 일원화. - B05 배수유역 패널의 사용자 문구를 전부 ui_locales로 이관(제목, 레이어 토글 5종, 도구 버튼 5종, 툴팁, 상태 문구 8종, 유역 제원 표기). B04 유역 분석 오버레이의 버튼·갈래 토글·진행/실패 안내도 함께 전환. 관리자 진단용 결과 판독문은 원문 유지. - A02 프로그램 소개의 6단계 제목과 A01 히어로 문구의 단계 나열을 진행단계 이름 (전처리/종단설계/횡단설계/상세설계/수량산출/설계도서)과 일치시킴. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_wf1_Surface_UI_FlowArrows.ts | 13 +- .../B04_wf1_Surface_UI_MapRender.ts | 21 +-- .../B04_wf1_Surface_UI_MapViewer.ts | 37 ++--- .../B04_wf1_Surface_UI_Watershed.ts | 116 +++++++++------ .../B05_wf2_Route_UI_Drainage_Boundary.ts | 17 ++- .../B05_wf2_Route_UI_Drainage_Panel.ts | 136 ++++++++++-------- .../B05_wf2_Route_UI_Drainage_Pipes.ts | 10 +- ui_template/ui_template_locale.ts | 112 +++++++++++++-- ui_template/ui_template_palette.ts | 38 +++++ ui_template/ui_template_theme.css | 53 +++++++ 10 files changed, 408 insertions(+), 145 deletions(-) create mode 100644 ui_template/ui_template_palette.ts diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts index 632c197f..d11b67ed 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_FlowArrows.ts @@ -11,6 +11,9 @@ * 화면 변환은 호출부가 `project`로 넘긴다 — 이 파일은 좌표계를 모른다. * ========================================================================== */ +import { themeColor } from "@ui/ui_template_palette"; +import { haloColor } from "./B04_wf1_Surface_UI_MapRender"; + /** 화살표 1개 — [가로, 세로, 방위(도), 도로 도달, 셀 수]. 앞 두 값의 좌표계는 호출부가 정한다. */ export type FlowArrow = [number, number, number, boolean, number]; @@ -23,9 +26,9 @@ const MIN_LENGTH_PX = 9; /** 화면을 가득 채우지 않도록 두는 상한(px). */ const MAX_LENGTH_PX = 40; -const TO_ROAD_COLOR = "rgba(153, 27, 27, 0.95)"; -const AWAY_COLOR = "rgba(30, 64, 175, 0.95)"; -const HALO_COLOR = "rgba(255, 255, 255, 0.9)"; +/* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다. */ +const toRoadColor = (): string => themeColor("--map-flow-to-road-line", "rgba(153, 27, 27, 0.95)"); +const awayColor = (): string => themeColor("--map-flow-away-arrow", "rgba(30, 64, 175, 0.95)"); /** 화살표 좌표를 캔버스 픽셀로 옮기는 함수. */ export type ArrowProjector = (a: number, b: number) => readonly [number, number]; @@ -69,8 +72,8 @@ export function drawFlowArrows( const tipY = y + unitY * reach; // 어두운 배경·채움색 위에서도 읽히도록 흰 테두리를 한 겹 깔고 그 위에 색을 얹는다. for (const [color, lineWidth] of [ - [HALO_COLOR, width + 1.4] as const, - [reaches ? TO_ROAD_COLOR : AWAY_COLOR, width] as const, + [haloColor(), width + 1.4] as const, + [reaches ? toRoadColor() : awayColor(), width] as const, ]) { context.strokeStyle = color; context.lineWidth = lineWidth; diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts index 0baf2398..a3bc4b08 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapRender.ts @@ -1,3 +1,4 @@ +import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "./B04_wf1_Surface_Api_Fetch"; // 2D 지도 벡터 레이어 렌더 엔진. @@ -22,13 +23,17 @@ export type GeoJsonCollection = { export type MarkerKind = "dot" | "x"; /** 상류 세류망 강조 색 — 유역 판정의 기준선이라 가장 굵고 진하게 둔다. */ -const UPSTREAM_LINE_COLOR = "rgba(29, 78, 216, 0.95)"; +const upstreamLineColor = (): string => themeColor("--map-upstream", "rgba(29, 78, 216, 0.95)"); + +/** 배경 위에서 선·글자가 묻히지 않게 뒤에 까는 흰 테두리. */ +export const haloColor = (): string => themeColor("--map-halo", "rgba(255, 255, 255, 0.9)"); /** - * 계획선(노선) 표기 색 — 주황. B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다. + * 계획선(노선) 표기 색 — B04 2D 지도와 B05 배수유역도가 **같은 값**을 쓴다. * 두 화면에서 같은 선을 다른 색으로 그리면 같은 것인지 알아볼 수 없다. + * 색 값 자체는 `ui_template_theme.css`의 `--map-route`가 유일한 정의처다. */ -export const ROUTE_LINE_COLOR = "#f97316"; +export const routeLineColor = (): string => themeColor("--map-route", "#f97316"); /** 계획선 굵기(px) — 다른 레이어보다 굵게 둬야 배경 위에서 바로 눈에 띈다. */ export const ROUTE_LINE_WIDTH = 2.4; @@ -513,7 +518,7 @@ export function drawPreparedLabels( if (x < -margin || x > view.width + margin) continue; if (y < -margin || y > view.height + margin) continue; context.lineWidth = 3; - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.strokeStyle = haloColor(); context.strokeText(feature.labelText, x, y); context.fillStyle = color; context.fillText(feature.labelText, x, y); @@ -567,13 +572,13 @@ export function drawFilledRing( context.arc(centerX, centerY, 11, 0, Math.PI * 2); context.fillStyle = color; context.fill(); - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.strokeStyle = haloColor(); context.lineWidth = 1.5; context.stroke(); context.font = "600 12px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; - context.fillStyle = "#1f2937"; + context.fillStyle = themeColor("--map-label-text", "#1f2937"); context.fillText(entry.label, centerX, centerY); } @@ -591,7 +596,7 @@ export function drawUpstreamLines( context.lineWidth = 4; context.lineCap = "round"; context.lineJoin = "round"; - context.strokeStyle = UPSTREAM_LINE_COLOR; + context.strokeStyle = upstreamLineColor(); lines.forEach((line) => { if (line.length < 2) return; context.beginPath(); @@ -628,7 +633,7 @@ export function drawRidgeRing( }); context.closePath(); context.save(); - context.strokeStyle = "#92400e"; + context.strokeStyle = themeColor("--map-basin-outline", "#92400e"); context.lineWidth = 1.8; context.setLineDash([7, 4]); context.stroke(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts index 2b5c2892..207ed456 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_MapViewer.ts @@ -1,5 +1,6 @@ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; +import { themeColor } from "@ui/ui_template_palette"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson, @@ -18,7 +19,7 @@ import { drawPreparedLayer, prepareLayer, prepareMetricPolyline, - ROUTE_LINE_COLOR, + routeLineColor, ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, @@ -75,18 +76,22 @@ const GIS_DEFAULT_ON: Record = { /** 등고 라벨(계곡선 수치) 기본 표시 여부. */ const CONTOUR_LABEL_DEFAULT_ON = false; -const GIS_LAYER_COLORS: Record = { - 지적도: "#f97316", - 행정구역_시군구: "#7c3aed", - 행정구역_읍면동: "#22c55e", - 등고선: "#fdba74", - 도엽_등고선: "#a5b4fc", - 도엽_하천중심선: "#2563eb", - 도엽_표고점: "#f9a8d4", - 도엽_성절토: "#f43f5e", - 도엽_옹벽석축: "#0f766e", +/** 레이어별 색은 `ui_template_theme.css`의 `--map-*`가 정의처다. 여기는 이름만 잇는다. + * (fallback 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */ +const GIS_LAYER_COLOR_TOKENS: Record = { + 지적도: ["--map-cadastral", "#f97316"], + 행정구역_시군구: ["--map-sigungu", "#7c3aed"], + 행정구역_읍면동: ["--map-eupmyeondong", "#22c55e"], + 등고선: ["--map-contour", "#fdba74"], + 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], + 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], + 도엽_표고점: ["--map-sheet-elev-point", "#f9a8d4"], + 도엽_성절토: ["--map-sheet-cutfill", "#f43f5e"], + 도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"], }; +const gisLayerColor = (layer: GisLayer): string => themeColor(...GIS_LAYER_COLOR_TOKENS[layer]); + // 등고 라벨 표기 대상 레이어와 표고 속성 키 (gpkg=CTRLN_HG, 도엽=등고수치) const CONTOUR_LABEL_KEYS: Partial> = { 등고선: ["CTRLN_HG"], @@ -261,7 +266,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { routeButton.type = "button"; routeButton.className = "b04-map__layer-button b04-map__layer-button--gis is-active"; routeButton.textContent = L("B04_Surface_Map_PlannedRoute"); - routeButton.style.setProperty("--b04-layer-color", ROUTE_LINE_COLOR); + routeButton.style.setProperty("--b04-layer-color", routeLineColor()); routeButton.setAttribute("aria-pressed", "true"); routeButton.addEventListener("click", () => { showRoute = !showRoute; @@ -273,7 +278,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { GIS_LAYERS.forEach((layer) => { gisButtons.append( - makeLayerButton(gisLabels[layer], activeGisLayers, layer, GIS_LAYER_COLORS[layer]), + makeLayerButton(gisLabels[layer], activeGisLayers, layer, gisLayerColor(layer)), ); }); @@ -392,7 +397,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const prepared = preparedLayers.get(layer); if (!prepared) return; context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5; - context.strokeStyle = GIS_LAYER_COLORS[layer]; + context.strokeStyle = gisLayerColor(layer); drawPreparedLayer(context, prepared, view, layer === "도엽_표고점" ? "x" : "dot"); }); if (showContourLabels) { @@ -402,7 +407,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { (Object.keys(CONTOUR_LABEL_KEYS) as GisLayer[]).forEach((layer) => { if (!activeGisLayers.has(layer)) return; const prepared = preparedLayers.get(layer); - if (prepared) drawPreparedLabels(context, prepared, view, GIS_LAYER_COLORS[layer]); + if (prepared) drawPreparedLabels(context, prepared, view, gisLayerColor(layer)); }); } // 배수유역 오버레이는 GIS 레이어 위에 얹는다 — 격자·화살표가 등고선을 덮어야 읽힌다. @@ -410,7 +415,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 계획선은 맨 위에 둔다 — 다른 레이어에 덮이면 노선이 어디로 지나는지 읽을 수 없다. if (showRoute && routeLayer) { context.lineWidth = ROUTE_LINE_WIDTH; - context.strokeStyle = ROUTE_LINE_COLOR; + context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } updateImageTransform(); diff --git a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts index 57dee145..d669abc6 100644 --- a/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts +++ b/B04_wf1_Surface/B04_wf1_Surface_UI_Watershed.ts @@ -1,7 +1,13 @@ +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; +import { themeColor } from "@ui/ui_template_palette"; import { fetchWatershedAnalysis, type WatershedAnalysis } from "./B04_wf1_Surface_Api_Fetch"; import { drawFlowArrows } from "./B04_wf1_Surface_UI_FlowArrows"; import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Surface_UI_MapRender"; +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + /* ============================================================================= * 배수유역 분석 오버레이 (B04 — 관리자 확인용) * @@ -15,32 +21,57 @@ import { drawUpstreamLines, type Normalizer, type ViewState } from "./B04_wf1_Su * · 2차 전체 배수유역 외곽선(갈색 파선) · 기본 관 위치 * ========================================================================== */ +/* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다. + (인자로 준 값은 CSS가 아직 안 붙은 첫 프레임 대비용 안전값) */ // 해석 격자 셀 선 — 등고선·세류 위에 얹으므로 흰색으로 둔다. -const GRID_LINE_COLOR = "rgba(255, 255, 255, 0.55)"; +const gridLineColor = (): string => themeColor("--map-grid-line", "rgba(255, 255, 255, 0.55)"); // 흐름 판정 색 — 도로로 물이 오는 셀은 적색, 오지 않는 셀은 파랑 채움 + 백색 화살표. -const FLOW_TO_ROAD_FILL = "rgba(220, 38, 38, 0.28)"; -const FLOW_TO_ROAD_LINE = "rgba(153, 27, 27, 0.95)"; -const FLOW_AWAY_FILL = "rgba(37, 99, 235, 0.22)"; -const FLOW_AWAY_LINE = "rgba(255, 255, 255, 0.95)"; +const flowToRoadFill = (): string => + themeColor("--map-flow-to-road-fill", "rgba(220, 38, 38, 0.28)"); +const flowToRoadLine = (): string => + themeColor("--map-flow-to-road-line", "rgba(153, 27, 27, 0.95)"); +const flowAwayFill = (): string => themeColor("--map-flow-away-fill", "rgba(37, 99, 235, 0.22)"); +const flowAwayLine = (): string => themeColor("--map-flow-away-line", "rgba(255, 255, 255, 0.95)"); /** 등고선 TIN 밖이라 표고가 없어 판정하지 못한 셀 — 미도달(파랑)과 구분한다. */ -const FLOW_UNKNOWN_FILL = "rgba(120, 113, 108, 0.18)"; +const flowUnknownFill = (): string => + themeColor("--map-flow-unknown-fill", "rgba(120, 113, 108, 0.18)"); /** 화살표가 이보다 작으면 뭉개져 읽히지 않으므로 채움색만 남긴다(px). */ const ARROW_MIN_PX = 7; /** 화면상 화살표 간격 목표(px). 1m 격자를 도엽 배율로 보면 셀이 1~2px라 셀마다 그릴 수 없다. * 이 간격이 되도록 셀을 건너뛰며 표본만 그린다 — 흐름장을 읽는 표준 방식이다. */ const ARROW_SPACING_PX = 22; /** 2차 전체 배수유역 외곽선 = 분수령. */ -const BASIN_RING_COLOR = "rgba(146, 64, 14, 0.95)"; +const basinRingColor = (): string => themeColor("--map-basin-ring", "rgba(146, 64, 14, 0.95)"); /** 기본 관 마커. */ -const PIPE_COLOR = "rgba(249, 115, 22, 0.95)"; +const pipeMarkerColor = (): string => themeColor("--map-pipe-marker", "rgba(249, 115, 22, 0.95)"); -/** 개별로 켜고 끌 수 있는 오버레이 갈래. */ +/** 개별로 켜고 끌 수 있는 오버레이 갈래. 문구는 locale, 색은 theme.css가 정의처다. */ const PARTS = [ - { key: "primary", label: "1차 유역", color: "#059669" }, - { key: "basin", label: "2차 유역", color: "#92400e" }, - { key: "flow", label: "유역 방향", color: "#2563eb" }, - { key: "arrows", label: "평균 흐름", color: "#7c3aed" }, -] as const; + { + key: "primary", + labelKey: "B04_Surface_Watershed_Part_Primary", + token: ["--map-primary-region", "#059669"], + }, + { + key: "basin", + labelKey: "B04_Surface_Watershed_Part_Basin", + token: ["--map-basin-outline", "#92400e"], + }, + { + key: "flow", + labelKey: "B04_Surface_Watershed_Part_Flow", + token: ["--map-flow-region", "#2563eb"], + }, + { + key: "arrows", + labelKey: "B04_Surface_Watershed_Part_Arrows", + token: ["--map-flow-arrow", "#7c3aed"], + }, +] as const satisfies ReadonlyArray<{ + key: string; + labelKey: keyof typeof ui_locales; + token: readonly [string, string]; +}>; type PartKey = (typeof PARTS)[number]["key"]; export interface WatershedOverlay { @@ -82,13 +113,10 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const button = document.createElement("button"); button.type = "button"; button.className = "b04-map__layer-button b04-map__layer-button--gis"; - button.textContent = "유역 분석"; - button.style.setProperty("--b04-layer-color", "#dc2626"); + button.textContent = L("B04_Surface_Watershed_Btn"); + button.style.setProperty("--b04-layer-color", themeColor("--color-danger", "#dc2626")); button.setAttribute("aria-pressed", "false"); - button.title = - "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. " + - "30초 안팎이 걸리며 결과는 영구저장소에 남습니다. " + - "저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다."; + button.title = L("B04_Surface_Watershed_Btn_Tip"); // 갈래별 표시 여부. 전체 토글(button)이 꺼져 있으면 이 값과 무관하게 아무것도 안 그린다. // 1차 유역·유역 방향은 격자가 지도를 덮어 판독을 방해하므로 기본 꺼짐(2026-08-01 사용자 지시). @@ -104,8 +132,8 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const initialActive = shownParts[part.key]; element.className = "b04-map__layer-button b04-map__layer-button--gis" + (initialActive ? " is-active" : ""); - element.textContent = part.label; - element.style.setProperty("--b04-layer-color", part.color); + element.textContent = L(part.labelKey); + element.style.setProperty("--b04-layer-color", themeColor(part.token[0], part.token[1])); element.setAttribute("aria-pressed", String(initialActive)); element.addEventListener("click", () => { shownParts[part.key] = !shownParts[part.key]; @@ -201,7 +229,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { if (!bytes) { // 흐름 판정 전 — 격자만 흰 선으로 보여 준다. if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; + context.strokeStyle = gridLineColor(); context.lineWidth = 0.5; context.beginPath(); for (let col = colStart; col <= colEnd; col += 1) { @@ -254,19 +282,19 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { // 표고가 없어 판정 못한 셀 — 미도달(파랑 채움)과 구분해야 오독이 없다. const unanalyzed = azimuth === codes.invalid; context.fillStyle = unanalyzed - ? FLOW_UNKNOWN_FILL + ? flowUnknownFill() : reaches - ? FLOW_TO_ROAD_FILL - : FLOW_AWAY_FILL; + ? flowToRoadFill() + : flowAwayFill(); context.fillRect(x, y, cellW, cellH); if (cellPx >= 2) { - context.strokeStyle = GRID_LINE_COLOR; + context.strokeStyle = gridLineColor(); context.lineWidth = 0.5; context.strokeRect(x, y, cellW, cellH); } // arrowPx = 0 이면 표본에서 빠진 셀이라 채움만 하고 끝낸다. if (arrowPx < ARROW_MIN_PX || unanalyzed) return; - const stroke = reaches ? FLOW_TO_ROAD_LINE : FLOW_AWAY_LINE; + const stroke = reaches ? flowToRoadLine() : flowAwayLine(); const midX = x + cellW / 2; const midY = y + cellH / 2; if (azimuth === codes.sink) { @@ -377,7 +405,7 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { if (region.basin_polygon_lonlat.length > 2) { context.setLineDash([8, 5]); context.lineWidth = 2.5; - context.strokeStyle = BASIN_RING_COLOR; + context.strokeStyle = basinRingColor(); strokeLonLat(context, region.basin_polygon_lonlat, map, view); } context.setLineDash([]); @@ -390,12 +418,13 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { const y = (1 - (pipe.lat - map.latMin) / map.latRange) * ay + by; context.beginPath(); context.arc(x, y, 7, 0, Math.PI * 2); - context.fillStyle = PIPE_COLOR; + context.fillStyle = pipeMarkerColor(); context.fill(); context.lineWidth = 1.5; - context.strokeStyle = "#111827"; + const markerText = themeColor("--map-marker-text", "#111827"); + context.strokeStyle = markerText; context.stroke(); - context.fillStyle = "#111827"; + context.fillStyle = markerText; context.font = "bold 10px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; @@ -449,11 +478,13 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { if (!projectId || busy) return; busy = true; button.disabled = true; - button.textContent = refresh ? "분석 중…" : "불러오는 중…"; + button.textContent = L( + refresh ? "B04_Surface_Watershed_Btn_Analyzing" : "B04_Surface_Watershed_Btn_Loading", + ); say( - refresh - ? "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…" - : "저장된 배수유역 분석을 불러오는 중…", + L( + refresh ? "B04_Surface_Watershed_Status_Analyzing" : "B04_Surface_Watershed_Status_Loading", + ), ); const started = performance.now(); try { @@ -462,24 +493,27 @@ export function createWatershedOverlay(onChange: () => void): WatershedOverlay { button.classList.add("is-active"); button.setAttribute("aria-pressed", "true"); const seconds = ((performance.now() - started) / 1000).toFixed(1); - const origin = analysis.from_cache ? "저장분" : `재산정 ${seconds}초`; + const origin = analysis.from_cache + ? L("B04_Surface_Watershed_Origin_Cached") + : L("B04_Surface_Watershed_Origin_Recomputed").replace("{seconds}", seconds); say(`[${origin}] ${regionSummary(analysis)}`); } catch (error) { analysis = null; shown = false; button.classList.remove("is-active"); button.setAttribute("aria-pressed", "false"); - const message = error instanceof Error ? error.message : "배수유역을 불러오지 못했습니다."; + const message = + error instanceof Error ? error.message : L("B04_Surface_Watershed_LoadFailed"); // 저장분이 아직 없는 것은 오류가 아니다 — 무엇을 눌러야 하는지 알려 준다. say( refresh - ? `유역 분석 실패: ${message}` - : "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", + ? L("B04_Surface_Watershed_Failed").replace("{message}", message) + : L("B04_Surface_Watershed_NoSaved"), ); } finally { busy = false; button.disabled = false; - button.textContent = "유역 분석"; + button.textContent = L("B04_Surface_Watershed_Btn"); onChange(); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts index 747930a3..706b6cfd 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Boundary.ts @@ -1,4 +1,9 @@ -import type { Normalizer, ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; +import { themeColor } from "@ui/ui_template_palette"; +import { + haloColor, + type Normalizer, + type ViewState, +} from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; /* ============================================================================= * 2차 전체 배수유역 외곽선 편집 (B05 배수유역도) @@ -14,8 +19,10 @@ import type { Normalizer, ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_U /** 편집 핸들 반경(px)과 잡을 수 있는 여유. */ const HANDLE_RADIUS_PX = 4; const HANDLE_HIT_PX = 9; -const HANDLE_COLOR = "rgba(146, 64, 14, 0.95)"; -const HANDLE_MOVED_COLOR = "rgba(220, 38, 38, 0.95)"; +/* 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다 — 여기서 값을 새로 정하지 않는다. */ +const handleColor = (): string => themeColor("--map-boundary-handle", "rgba(146, 64, 14, 0.95)"); +const handleMovedColor = (): string => + themeColor("--map-boundary-handle-moved", "rgba(220, 38, 38, 0.95)"); /** 같은 자리로 볼 오차(도). 대략 0.1m 수준. */ const SAME_POINT_EPSILON = 1e-6; @@ -123,14 +130,14 @@ export function createBoundaryEditor(onChange: () => void): BoundaryEditor { const affine = affineOf(view); context.save(); context.lineWidth = 1.2; - context.strokeStyle = "rgba(255, 255, 255, 0.9)"; + context.strokeStyle = haloColor(); points.forEach((point, index) => { const [x, y] = toScreen(point, normalizer, affine); if (x < -20 || y < -20 || x > view.width + 20 || y > view.height + 20) return; const base = bases[index]; context.beginPath(); context.arc(x, y, HANDLE_RADIUS_PX, 0, Math.PI * 2); - context.fillStyle = base && !samePoint(point, base) ? HANDLE_MOVED_COLOR : HANDLE_COLOR; + context.fillStyle = base && !samePoint(point, base) ? handleMovedColor() : handleColor(); context.fill(); context.stroke(); }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index 3cfc61c2..0ccaadb7 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -1,5 +1,7 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; +import { themeColor } from "@ui/ui_template_palette"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, @@ -16,7 +18,7 @@ import { drawUpstreamLines, prepareLayer, prepareMetricPolyline, - ROUTE_LINE_COLOR, + routeLineColor, ROUTE_LINE_WIDTH, type GeoJsonCollection, type MapRect, @@ -47,24 +49,25 @@ import { createProgressCircle } from "@ui/ui_template_progress"; const DRAINAGE_LAYERS = DRAINAGE_SHEET_LAYERS; type DrainageLayer = (typeof DRAINAGE_LAYERS)[number]; -const LAYER_COLORS: Record = { - 도엽_등고선: "#a5b4fc", - 도엽_하천중심선: "#2563eb", +/** 색 값의 정의처는 `ui_template_theme.css`(`--map-*`)다. 여기는 이름만 잇는다. */ +const LAYER_COLOR_TOKENS: Record = { + 도엽_등고선: ["--map-sheet-contour", "#a5b4fc"], + 도엽_하천중심선: ["--map-sheet-stream", "#2563eb"], }; -const LAYER_LABELS: Record = { - 도엽_등고선: "등고선", - 도엽_하천중심선: "세류", +const layerColor = (layer: DrainageLayer): string => themeColor(...LAYER_COLOR_TOKENS[layer]); + +const LAYER_LABEL_KEYS: Record = { + 도엽_등고선: "B05_Drainage_Layer_Contour", + 도엽_하천중심선: "B05_Drainage_Layer_Stream", }; /** 도엽 레이어가 아닌 표시 토글의 띠 색 — 지도에 그려지는 선 색과 맞춘다. */ -const ARROW_TOGGLE_COLOR = "#7c3aed"; -const UPSTREAM_TOGGLE_COLOR = "#1d4ed8"; +const arrowToggleColor = (): string => themeColor("--map-flow-arrow", "#7c3aed"); +const upstreamToggleColor = (): string => themeColor("--map-upstream-toggle", "#1d4ed8"); /** 위성사진은 선이 아니라 배경이라 맞출 선 색이 없다 — 중립 회색을 띠 색으로 쓴다. */ -const SATELLITE_TOGGLE_COLOR = "#64748b"; +const satelliteToggleColor = (): string => themeColor("--map-satellite-toggle", "#64748b"); -/** 계획선 색은 B04 2D 지도와 같은 값을 쓴다(정의처: MapRender). */ -const ROUTE_COLOR = ROUTE_LINE_COLOR; const COLLAPSED_KEY = "b05-route-drainage-collapsed"; /** 드래그로 조절한 패널 폭(px) 보관 키 — 브라우저 세션 동안만 유지한다. */ const WIDTH_KEY = "b05-route-drainage-width"; @@ -73,8 +76,8 @@ const MIN_PANEL_WIDTH = 320; /** 상한은 하단 패널 폭의 70%까지(사용자 지시) — 종단면도가 최소한 30%는 남아야 한다. */ const MAX_PANEL_WIDTH_RATIO = 0.7; -/** 유역 오버레이 파스텔 색상. 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). */ -const BASIN_COLORS = [ +/** 유역 오버레이 파스텔 색상 8종(정의처: `--map-basin-1`~`-8`). 번호 순으로 돌려쓴다. */ +const BASIN_COLOR_FALLBACKS = [ "rgba(167, 216, 199, 0.45)", "rgba(247, 208, 168, 0.45)", "rgba(186, 199, 240, 0.45)", @@ -85,6 +88,16 @@ const BASIN_COLORS = [ "rgba(240, 219, 168, 0.45)", ] as const; +/** 유역 번호(1부터)에 대응하는 채움색. 8종을 넘어가면 처음부터 다시 쓴다. */ +function basinColor(index: number): string { + const slot = (index - 1) % BASIN_COLOR_FALLBACKS.length; + return themeColor(`--map-basin-${slot + 1}`, BASIN_COLOR_FALLBACKS[slot]); +} + +function L(key: keyof typeof ui_locales): string { + return ui_locales[key][currentLanguageIndex]; +} + export interface DrainagePanel { root: HTMLElement; /** 프로젝트가 정해지면 배경지도·도엽 레이어를 불러온다. */ @@ -108,7 +121,7 @@ export function createDrainagePanel(): DrainagePanel { const header = document.createElement("div"); header.className = "b05-drainage__header"; const title = document.createElement("h3"); - title.textContent = "배수유역도"; + title.textContent = L("B05_Drainage_Title"); const layerButtons = document.createElement("div"); layerButtons.className = "b05-drainage__layers"; header.append(title, layerButtons); @@ -118,35 +131,31 @@ export function createDrainagePanel(): DrainagePanel { const analyzeButton = document.createElement("button"); analyzeButton.type = "button"; analyzeButton.className = "b05-drainage__analyze"; - analyzeButton.textContent = "세부유역 산정"; - analyzeButton.title = - "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. " + - "분석 결과가 없으면 B04에서 먼저 실행해야 합니다."; + analyzeButton.textContent = L("B05_Drainage_Btn_Analyze"); + analyzeButton.title = L("B05_Drainage_Btn_Analyze_Tip"); // 배관 편집 토글 — 켜면 계획선 클릭으로 배관 추가, 마커 드래그로 이동. const editButton = document.createElement("button"); editButton.type = "button"; editButton.className = "b05-drainage__analyze b05-drainage__tool"; - editButton.textContent = "배관 편집"; + editButton.textContent = L("B05_Drainage_Btn_PipeEdit"); editButton.setAttribute("aria-pressed", "false"); // 선택된 배관 삭제 — 편집 모드에서 마커를 선택해야 활성화된다. const deleteButton = document.createElement("button"); deleteButton.type = "button"; deleteButton.className = "b05-drainage__analyze b05-drainage__tool"; - deleteButton.textContent = "선택 삭제"; + deleteButton.textContent = L("B05_Drainage_Btn_DeleteSelected"); deleteButton.disabled = true; // 자동 제안으로 되돌리기 — 편집한 배관 배치를 버리고 백엔드 자동 제안으로 재산정. const autoButton = document.createElement("button"); autoButton.type = "button"; autoButton.className = "b05-drainage__analyze b05-drainage__tool"; - autoButton.textContent = "자동 제안"; + autoButton.textContent = L("B05_Drainage_Btn_Auto"); // 유역선 편집 토글 — 켜면 외곽선 위 핸들을 잡아 유역 경계를 손으로 고친다. const boundaryButton = document.createElement("button"); boundaryButton.type = "button"; boundaryButton.className = "b05-drainage__analyze b05-drainage__tool"; - boundaryButton.textContent = "유역선 편집"; - boundaryButton.title = - "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. " + - "옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다."; + boundaryButton.textContent = L("B05_Drainage_Btn_BoundaryEdit"); + boundaryButton.title = L("B05_Drainage_Btn_BoundaryEdit_Tip"); boundaryButton.setAttribute("aria-pressed", "false"); header.append(analyzeButton, editButton, deleteButton, autoButton, boundaryButton); @@ -154,13 +163,13 @@ export function createDrainagePanel(): DrainagePanel { viewport.className = "b05-drainage__viewport"; const backgroundImage = document.createElement("img"); backgroundImage.className = "b05-drainage__image"; - backgroundImage.alt = "배경 위성지도"; + backgroundImage.alt = L("B05_Drainage_ImageAlt"); backgroundImage.draggable = false; const canvas = document.createElement("canvas"); canvas.className = "b05-drainage__canvas"; const status = document.createElement("span"); status.className = "b05-drainage__status"; - status.textContent = "노선을 확정하면 배수유역도가 표시됩니다."; + status.textContent = L("B05_Drainage_Status_NeedRoute"); // 지도 정중앙 로딩 서클 — 배경도·도엽 레이어·유역 산정이 끝날 때까지 화면이 비어 보인다. const progress = createProgressCircle({ overlay: true }); progress.root.hidden = true; @@ -255,39 +264,39 @@ export function createDrainagePanel(): DrainagePanel { // 배경 위성사진 — 등고선 앞에 둔다(2026-08-01 사용자 지시). 사진이 어두워 유역 채움색이 // 묻힐 때 끄고 본다. 캔버스가 아니라 배경 이미지라 표시 여부만 직접 바꾼다. addLayerToggle( - "위성사진", - SATELLITE_TOGGLE_COLOR, + L("B05_Drainage_Layer_Satellite"), + satelliteToggleColor(), true, (next) => { backgroundImage.hidden = !next; }, - "배경 위성사진을 보이거나 숨깁니다.", + L("B05_Drainage_Layer_Satellite_Tip"), ); DRAINAGE_LAYERS.forEach((layer) => { - addLayerToggle(LAYER_LABELS[layer], LAYER_COLORS[layer], true, (next) => { + addLayerToggle(L(LAYER_LABEL_KEYS[layer]), layerColor(layer), true, (next) => { if (next) activeLayers.add(layer); else activeLayers.delete(layer); }); }); // 흐름 화살표 — 도면이 지저분해질 때 끄기 위한 토글(등고선·세류와 같은 줄·같은 양식). addLayerToggle( - "흐름 화살표", - ARROW_TOGGLE_COLOR, + L("B05_Drainage_Layer_Arrows"), + arrowToggleColor(), showArrows, (next) => { showArrows = next; }, - "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.", + L("B05_Drainage_Layer_Arrows_Tip"), ); // 상류 세류선 강조 — 유역 판정의 기준선이라 항상 같은 굵기·색으로 얹는다. addLayerToggle( - "상류 세류", - UPSTREAM_TOGGLE_COLOR, + L("B05_Drainage_Layer_Upstream"), + upstreamToggleColor(), showUpstream, (next) => { showUpstream = next; }, - "유역 안쪽 상류 세류망을 굵게 강조합니다.", + L("B05_Drainage_Layer_Upstream_Tip"), ); function updateImageTransform(): void { @@ -317,7 +326,7 @@ export function createDrainagePanel(): DrainagePanel { // 세부유역 채움을 가장 아래에 깔아 등고선·세류 판독을 가리지 않게 한다. if (normalizer) { basins.forEach((basin) => { - const color = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + const color = basinColor(basin.index); drawFilledRing( context, { ring: basin.polygon_lonlat, label: String(basin.index) }, @@ -339,7 +348,7 @@ export function createDrainagePanel(): DrainagePanel { const prepared = preparedLayers.get(layer); if (!prepared) return; context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; - context.strokeStyle = LAYER_COLORS[layer]; + context.strokeStyle = layerColor(layer); drawPreparedLayer(context, prepared, view, "dot"); }); // 상류 세류선 강조 — 유역 채움 위, 흐름 화살표 아래(2026-08-01 사용자 지시). @@ -349,7 +358,7 @@ export function createDrainagePanel(): DrainagePanel { } if (routeLayer) { context.lineWidth = ROUTE_LINE_WIDTH; - context.strokeStyle = ROUTE_COLOR; + context.strokeStyle = routeLineColor(); drawPreparedLayer(context, routeLayer, view, "dot"); } // 평균 흐름 화살표 — 유역 채움 위, 배관 마커 아래. 좌표는 사업지 CRS(m)라 @@ -391,8 +400,8 @@ export function createDrainagePanel(): DrainagePanel { /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ function pipeColor(chainage: number): string { const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51); - if (!basin) return "#e5e7eb"; - return BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length].replace(/0\.45\)$/, "1)"); + if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); + return basinColor(basin.index).replace(/0\.45\)$/, "1)"); } /** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */ @@ -426,16 +435,23 @@ export function createDrainagePanel(): DrainagePanel { const badge = document.createElement("span"); badge.className = "b05-drainage__basin-index"; badge.textContent = String(basin.index); - badge.style.background = BASIN_COLORS[(basin.index - 1) % BASIN_COLORS.length]; + badge.style.background = basinColor(basin.index); const metrics = document.createElement("span"); metrics.className = "b05-drainage__basin-metrics"; // 관경은 수식 미확정이라 백엔드가 null을 주며, 확정 전까지 "미정"으로 표기한다. const pipe = - basin.pipe_diameter_mm === null ? "미정" : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; - metrics.textContent = - `면적 ${formatArea(basin.area_m2)} · 표고 ${basin.relief_m.toFixed(1)}m · ` + - `유하 ${Math.round(basin.flow_length_m)}m · 관경 ${pipe}`; - row.title = `측점 누가거리 ${basin.chainage_m.toFixed(1)}m`; + basin.pipe_diameter_mm === null + ? L("B05_Drainage_Basin_Undecided") + : `Ø${Math.round(basin.pipe_diameter_mm)}mm`; + metrics.textContent = L("B05_Drainage_Basin_Metrics") + .replace("{area}", formatArea(basin.area_m2)) + .replace("{relief}", basin.relief_m.toFixed(1)) + .replace("{flow}", String(Math.round(basin.flow_length_m))) + .replace("{pipe}", pipe); + row.title = L("B05_Drainage_Basin_Chainage").replace( + "{chainage}", + basin.chainage_m.toFixed(1), + ); row.append(badge, metrics); row.addEventListener("click", () => { selectedBasin = selectedBasin === basin.index ? null : basin.index; @@ -456,8 +472,8 @@ export function createDrainagePanel(): DrainagePanel { if (!projectId) return; analyzeButton.disabled = true; status.hidden = false; - status.textContent = "세부유역을 산정하는 중…"; - showProgress(null, "세부유역을 산정하는 중…"); + status.textContent = L("B05_Drainage_Status_Analyzing"); + showProgress(null, L("B05_Drainage_Status_Analyzing")); try { const chainages = !auto && pipeEditor.pipes().length > 0 ? pipeEditor.chainages() : undefined; const response = await fetchDrainageBasins(projectId, chainages); @@ -480,11 +496,12 @@ export function createDrainagePanel(): DrainagePanel { renderBasinList(); syncPipeSelection(); status.hidden = basins.length > 0; - if (basins.length === 0) status.textContent = "산정된 배수유역이 없습니다."; + if (basins.length === 0) status.textContent = L("B05_Drainage_Status_NoBasin"); scheduleDraw(); } catch (error) { status.hidden = false; - status.textContent = error instanceof Error ? error.message : "세부유역 산정에 실패했습니다."; + status.textContent = + error instanceof Error ? error.message : L("B05_Drainage_Status_AnalyzeFailed"); } finally { analyzeButton.disabled = false; showProgress(null, null); @@ -550,11 +567,11 @@ export function createDrainagePanel(): DrainagePanel { preparedLayers.clear(); backgroundImage.removeAttribute("src"); status.hidden = false; - status.textContent = "배경도를 불러오는 중…"; - showProgress(0, "배경도를 불러오는 중…"); + status.textContent = L("B05_Drainage_Status_LoadingBase"); + showProgress(0, L("B05_Drainage_Status_LoadingBase")); try { const nextMeta = await fetchVWorldMeta(activeProjectId, "satellite"); - showProgress(1 / 3, "도엽 레이어를 불러오는 중…"); + showProgress(1 / 3, L("B05_Drainage_Status_LoadingSheets")); const loaded = await Promise.all( DRAINAGE_LAYERS.map(async (layer) => { try { @@ -580,16 +597,17 @@ export function createDrainagePanel(): DrainagePanel { if (routePoints.length > 1) routeLayer = prepareMetricPolyline(routePoints, nextMeta); pipeEditor.setContext(nextMeta, routePoints); status.hidden = featureCount > 0; - if (featureCount === 0) status.textContent = "도엽 레이어가 없습니다. B04에서 임포트하세요."; + if (featureCount === 0) status.textContent = L("B05_Drainage_Status_NoSheets"); fitToRoute(); scheduleDraw(); - showProgress(2 / 3, "세부유역을 산정하는 중…"); + showProgress(2 / 3, L("B05_Drainage_Status_Analyzing")); // B04 분석 결과를 읽어 오는 것뿐이라 즉시 끝난다 — 페이지에 들어오면 바로 보여 준다. void analyze(true); } catch (error) { if (sequence !== loadSequence) return; status.hidden = false; - status.textContent = error instanceof Error ? error.message : "배경도를 불러오지 못했습니다."; + status.textContent = + error instanceof Error ? error.message : L("B05_Drainage_Status_LoadFailed"); showProgress(null, null); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts index a7ab7101..ab1b90ef 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Pipes.ts @@ -1,3 +1,4 @@ +import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import type { ViewState } from "../B04_wf1_Surface/B04_wf1_Surface_UI_MapRender"; @@ -224,9 +225,12 @@ export function createPipeEditor(onChange: () => void): PipeEditor { context.fillStyle = colorOf(pipe.chainage_m, position); context.fill(); context.lineWidth = isSelected ? 2.5 : 1.5; - context.strokeStyle = isSelected ? "#111827" : "#374151"; + const markerText = themeColor("--map-marker-text", "#111827"); + context.strokeStyle = isSelected + ? markerText + : themeColor("--map-marker-outline", "#374151"); context.stroke(); - context.fillStyle = "#111827"; + context.fillStyle = markerText; context.font = "bold 10px sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; @@ -234,7 +238,7 @@ export function createPipeEditor(onChange: () => void): PipeEditor { // 누가거리 라벨 — 마커 우상단. context.font = "10px sans-serif"; context.textAlign = "left"; - context.fillStyle = "#1f2937"; + context.fillStyle = themeColor("--map-label-text", "#1f2937"); context.fillText( `${pipe.chainage_m.toFixed(0)}m`, screen.x + radius + 3, diff --git a/ui_template/ui_template_locale.ts b/ui_template/ui_template_locale.ts index 4b7f7e7e..c91df5f3 100644 --- a/ui_template/ui_template_locale.ts +++ b/ui_template/ui_template_locale.ts @@ -133,9 +133,10 @@ export const ui_locales = { A07_Register_Title: ["회원가입", "Sign up"], /* --- A01_Home 상세 --- */ + /* 단계를 늘어놓는 문구는 진행단계 이름(WF_Step_*)과 같은 말을 쓴다(2026-08-01 사용자 지시). */ A01_Home_Hero_Subtitle: [ - "LAS 지형 데이터부터 최적 경로, 종횡단, 견적까지 한 번에.", - "From LAS terrain data to optimal routes, cross-sections, and estimates — all in one.", + "LAS 지형 데이터부터 종단설계·횡단설계, 수량산출, 설계도서까지 한 번에.", + "From LAS terrain data to profile and cross design, quantities, and design documents — all in one.", ], A01_Home_Hero_CtaPrimary: ["지금 시작하기", "Get Started"], A01_Home_Hero_CtaSecondary: ["프로그램 살펴보기", "Explore Features"], @@ -167,32 +168,34 @@ export const ui_locales = { A02_ProgDetail_Hero_Cta: ["무료로 시작하기", "Start for Free"], A02_ProgDetail_Workflow_SectionTitle: ["6단계 설계 워크플로우", "6-Stage Design Workflow"], - A02_ProgDetail_Step1_Title: ["1. 지표면 분석", "1. Surface Analysis"], + /* 단계 이름은 진행단계 오버레이(WF_Step_*)와 같은 말을 쓴다 — 소개 페이지와 실제 화면의 + 단계 이름이 다르면 사용자가 같은 단계인지 알 수 없다(2026-08-01 사용자 지시). */ + A02_ProgDetail_Step1_Title: ["1. 전처리", "1. Preprocess"], A02_ProgDetail_Step1_Desc: [ "LAS 포인트클라우드에서 지면점을 필터링하고 15종 지표면 모델을 생성합니다.", "Filter ground points from the LAS point cloud and generate 15 surface models.", ], - A02_ProgDetail_Step2_Title: ["2. 경로 설계", "2. Route Design"], + A02_ProgDetail_Step2_Title: ["2. 종단설계", "2. Profile Design"], A02_ProgDetail_Step2_Desc: [ "경사·곡선반경·회피구역을 반영해 최적 임도 노선을 자동 탐색합니다.", "Auto-search optimal routes reflecting grade, curve radius, and avoidance zones.", ], - A02_ProgDetail_Step3_Title: ["3. 종·횡단 생성", "3. Profile & Cross-section"], + A02_ProgDetail_Step3_Title: ["3. 횡단설계", "3. Cross Design"], A02_ProgDetail_Step3_Desc: [ "확정 노선을 따라 종단면과 횡단면을 자동 추출합니다.", "Automatically extract longitudinal and cross sections along the confirmed route.", ], - A02_ProgDetail_Step4_Title: ["4. 상세 설계", "4. Detailed Design"], + A02_ProgDetail_Step4_Title: ["4. 상세설계", "4. Detail Design"], A02_ProgDetail_Step4_Desc: [ "구조물, 배수, 절·성토 등 세부 설계 요소를 편집합니다.", "Edit detailed design elements such as structures, drainage, and cut/fill.", ], - A02_ProgDetail_Step5_Title: ["5. 수량 산출", "5. Quantity Takeoff"], + A02_ProgDetail_Step5_Title: ["5. 수량산출", "5. Quantity"], A02_ProgDetail_Step5_Desc: [ "토공량과 구조물 수량을 자동 계산합니다.", "Automatically calculate earthwork volumes and structure quantities.", ], - A02_ProgDetail_Step6_Title: ["6. 견적 / 문서", "6. Estimation / Docs"], + A02_ProgDetail_Step6_Title: ["6. 설계도서", "6. Design Docs"], A02_ProgDetail_Step6_Desc: [ "견적서와 설계도서를 Excel / DWG / PDF로 출력합니다.", "Export estimates and design documents as Excel / DWG / PDF.", @@ -652,6 +655,40 @@ export const ui_locales = { B04_Surface_Map_GisLayer: ["국가 GIS 레이어", "National GIS Layer"], B04_Surface_Map_None: ["없음", "None"], B04_Surface_Map_PlannedRoute: ["계획선", "Planned route"], + /* 배수유역 분석 오버레이(관리자 확인용) — 조작 문구만 다국어로 둔다. + 결과 상세 판독문(regionSummary)은 진단용 원문이라 그대로 둔다. */ + B04_Surface_Watershed_Btn: ["유역 분석", "Basin analysis"], + B04_Surface_Watershed_Btn_Tip: [ + "계획 노선(B03 업로드)과 도엽 등고선·세류선으로 배수유역을 처음부터 다시 분석합니다. 30초 안팎이 걸리며 결과는 영구저장소에 남습니다. 저장된 결과는 지도를 열 때 자동으로 표시되므로, 조건을 바꿨을 때만 누르면 됩니다.", + "Re-runs the drainage analysis from scratch using the planned route (uploaded in B03) and the sheet contours and streams. It takes about 30 seconds and the result is stored permanently. A stored result is shown automatically when the map opens, so press this only after changing the inputs.", + ], + B04_Surface_Watershed_Btn_Analyzing: ["분석 중…", "Analyzing…"], + B04_Surface_Watershed_Btn_Loading: ["불러오는 중…", "Loading…"], + B04_Surface_Watershed_Part_Primary: ["1차 유역", "Primary region"], + B04_Surface_Watershed_Part_Basin: ["2차 유역", "Full basin"], + B04_Surface_Watershed_Part_Flow: ["유역 방향", "Flow cells"], + B04_Surface_Watershed_Part_Arrows: ["평균 흐름", "Mean flow"], + B04_Surface_Watershed_Status_Analyzing: [ + "배수유역을 처음부터 다시 분석하는 중입니다. 30초 안팎 걸립니다…", + "Re-running the drainage analysis from scratch. This takes about 30 seconds…", + ], + B04_Surface_Watershed_Status_Loading: [ + "저장된 배수유역 분석을 불러오는 중…", + "Loading the stored drainage analysis…", + ], + B04_Surface_Watershed_LoadFailed: [ + "배수유역을 불러오지 못했습니다.", + "Failed to load the drainage analysis.", + ], + /* {message}=원인 */ + B04_Surface_Watershed_Failed: ["유역 분석 실패: {message}", "Basin analysis failed: {message}"], + B04_Surface_Watershed_NoSaved: [ + "저장된 배수유역 분석이 없습니다. [유역 분석]을 누르세요.", + "No stored drainage analysis. Press [Basin analysis].", + ], + B04_Surface_Watershed_Origin_Cached: ["저장분", "Cached"], + /* {seconds}=재산정에 걸린 시간(초) */ + B04_Surface_Watershed_Origin_Recomputed: ["재산정 {seconds}초", "Recomputed in {seconds}s"], B04_Surface_Map_PlannedRouteEmpty: [ "B03에서 계획노선 파일을 올리면 표시됩니다.", "Shown after the planned route file is uploaded in B03.", @@ -691,6 +728,65 @@ export const ui_locales = { B04_Surface_Analyze_Failed: ["지표면 분석에 실패했습니다.", "Surface analysis failed."], B04_Surface_Load_Failed: ["모델 목록을 불러오지 못했습니다.", "Failed to load models."], + /* --- B05_wf2_Route 배수유역도 패널 (하단 패널 안쪽 우측) --- */ + B05_Drainage_Title: ["배수유역도", "Drainage Basins"], + B05_Drainage_Layer_Satellite: ["위성사진", "Satellite"], + B05_Drainage_Layer_Satellite_Tip: [ + "배경 위성사진을 보이거나 숨깁니다.", + "Show or hide the satellite basemap.", + ], + B05_Drainage_Layer_Contour: ["등고선", "Contours"], + B05_Drainage_Layer_Stream: ["세류", "Streams"], + B05_Drainage_Layer_Arrows: ["흐름 화살표", "Flow arrows"], + B05_Drainage_Layer_Arrows_Tip: [ + "B04에서 산출한 평균 흐름 방향을 보이거나 숨깁니다.", + "Show or hide the mean flow direction computed in B04.", + ], + B05_Drainage_Layer_Upstream: ["상류 세류", "Upstream streams"], + B05_Drainage_Layer_Upstream_Tip: [ + "유역 안쪽 상류 세류망을 굵게 강조합니다.", + "Emphasize the upstream stream network inside the basin.", + ], + B05_Drainage_Btn_Analyze: ["세부유역 산정", "Compute sub-basins"], + B05_Drainage_Btn_Analyze_Tip: [ + "B04에서 분석해 둔 배수유역을 불러와 관을 보충하고 세부유역을 나눕니다. 분석 결과가 없으면 B04에서 먼저 실행해야 합니다.", + "Loads the drainage analysis from B04, fills in culverts, and splits sub-basins. Run the analysis in B04 first if none exists.", + ], + B05_Drainage_Btn_PipeEdit: ["배관 편집", "Edit culverts"], + B05_Drainage_Btn_DeleteSelected: ["선택 삭제", "Delete selected"], + B05_Drainage_Btn_Auto: ["자동 제안", "Auto suggestion"], + B05_Drainage_Btn_BoundaryEdit: ["유역선 편집", "Edit basin outline"], + B05_Drainage_Btn_BoundaryEdit_Tip: [ + "2차 전체 배수유역 외곽선 위 포인트를 끌어 경계를 고칩니다. 옮긴 값은 종단 경로 확정 시 저장 여부를 묻습니다.", + "Drag the points on the overall basin outline to correct it. You will be asked whether to save the moved points when the route is confirmed.", + ], + B05_Drainage_ImageAlt: ["배경 위성지도", "Satellite basemap"], + B05_Drainage_Status_NeedRoute: [ + "노선을 확정하면 배수유역도가 표시됩니다.", + "The drainage map appears once the route is confirmed.", + ], + B05_Drainage_Status_Analyzing: ["세부유역을 산정하는 중…", "Computing sub-basins…"], + B05_Drainage_Status_NoBasin: ["산정된 배수유역이 없습니다.", "No drainage basin was computed."], + B05_Drainage_Status_AnalyzeFailed: [ + "세부유역 산정에 실패했습니다.", + "Failed to compute sub-basins.", + ], + B05_Drainage_Status_LoadingBase: ["배경도를 불러오는 중…", "Loading the basemap…"], + B05_Drainage_Status_LoadingSheets: ["도엽 레이어를 불러오는 중…", "Loading map sheet layers…"], + B05_Drainage_Status_NoSheets: [ + "도엽 레이어가 없습니다. B04에서 임포트하세요.", + "No map sheet layer found. Import them in B04.", + ], + B05_Drainage_Status_LoadFailed: ["배경도를 불러오지 못했습니다.", "Failed to load the basemap."], + B05_Drainage_Basin_Undecided: ["미정", "TBD"], + /* {area}=면적, {relief}=표고차, {flow}=유하장, {pipe}=관경 */ + B05_Drainage_Basin_Metrics: [ + "면적 {area} · 표고 {relief}m · 유하 {flow}m · 관경 {pipe}", + "Area {area} · Relief {relief}m · Flow {flow}m · Pipe {pipe}", + ], + /* {chainage}=측점 누가거리(m) */ + B05_Drainage_Basin_Chainage: ["측점 누가거리 {chainage}m", "Station chainage {chainage}m"], + /* --- B05_wf2_Route 경로 설계 --- */ B05_Route_Title: ["종단설계", "Profile Design"], B05_Route_Group_Points: ["경로 제어점", "Route Control Points"], diff --git a/ui_template/ui_template_palette.ts b/ui_template/ui_template_palette.ts new file mode 100644 index 00000000..4f04adf3 --- /dev/null +++ b/ui_template/ui_template_palette.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * ui_template_palette.ts + * 캔버스(2D 지도)용 색 조회 — 색 값의 정의처는 `ui_template_theme.css` 하나뿐이다. + * + * CSS로 칠할 수 없는 `` 그림도 색은 테마 변수에서 읽어야 화면마다 값이 + * 갈라지지 않는다. `getComputedStyle`은 호출할 때마다 스타일 재계산을 유발하므로 + * 한 번 읽은 값은 캐시하고, 테마가 바뀔 때(`data-theme` 변경)만 비운다. + * ========================================================================== */ + +const cache = new Map(); +let watching = false; + +/** 테마가 바뀌면 캐시를 버린다 — 다음 렌더에서 새 값으로 다시 읽는다. */ +function watchThemeChange(): void { + if (watching || typeof MutationObserver === "undefined") return; + watching = true; + new MutationObserver(() => cache.clear()).observe(document.documentElement, { + attributes: true, + attributeFilter: ["data-theme"], + }); +} + +/** + * 테마 CSS 변수 하나를 읽는다. + * + * @param name 변수 이름(`--map-route` 처럼 두 하이픈까지 포함) + * @param fallback 스타일이 아직 붙기 전(초기 렌더)이나 변수가 없을 때 쓸 값 + */ +export function themeColor(name: string, fallback: string): string { + const cached = cache.get(name); + if (cached !== undefined) return cached; + watchThemeChange(); + const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + const resolved = value || fallback; + // 값이 비어 있으면(스타일 미적용) 캐시하지 않는다 — 다음 렌더에서 다시 읽게 둔다. + if (value) cache.set(name, resolved); + return resolved; +} diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index a10ce886..2822e644 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -191,6 +191,59 @@ --z-overlay: 900; --z-modal: 1000; --z-toast: 1100; + + /* --------------------------------------------------------------------------- + * 9. 2D 지도 벡터 팔레트 (B04 배경지도 · B05 배수유역도 공용) + * 캔버스에 그리는 색이라 CSS로 직접 칠할 수 없다 — TS가 이 변수를 읽어 쓴다 + * (`ui_template_palette.ts`). 여기가 유일한 정의처이며 화면별 복제 금지. + * 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다. + * ------------------------------------------------------------------------ */ + --map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */ + --map-cadastral: #f97316; + --map-sigungu: #7c3aed; + --map-eupmyeondong: #22c55e; + --map-contour: #fdba74; + --map-sheet-contour: #a5b4fc; + --map-sheet-stream: #2563eb; + --map-sheet-elev-point: #f9a8d4; + --map-sheet-cutfill: #f43f5e; + --map-sheet-wall: #0f766e; + --map-flow-arrow: #7c3aed; + --map-upstream: rgba(29, 78, 216, 0.95); /* 상류 세류망 강조선 */ + --map-upstream-toggle: #1d4ed8; /* 토글 버튼 색띠 */ + --map-satellite-toggle: #64748b; /* 배경사진은 선 색이 없어 중립 회색 */ + --map-pipe-orphan: #e5e7eb; /* 유역이 없는 배관 마커 */ + --map-pipe-marker: rgba(249, 115, 22, 0.95); /* B04 기본 관 마커 */ + + /* 배수유역 오버레이(B04 지도) */ + --map-halo: rgba(255, 255, 255, 0.9); /* 선·글자 뒤에 까는 흰 테두리 */ + --map-label-text: #1f2937; /* 지도 위 라벨 글자 */ + --map-marker-text: #111827; /* 관 마커 번호 글자·선택된 마커 테두리 */ + --map-marker-outline: #374151; /* 선택되지 않은 마커 테두리 */ + --map-basin-outline: #92400e; /* 2차 유역 외곽선(분수령) */ + --map-basin-ring: rgba(146, 64, 14, 0.95); + --map-boundary-handle: rgba(146, 64, 14, 0.95); /* 유역선 편집 핸들 */ + --map-boundary-handle-moved: rgba(220, 38, 38, 0.95); /* 사용자가 옮긴 핸들 */ + --map-primary-region: #059669; /* 1차 유역 */ + --map-flow-region: #2563eb; /* 유역 방향 토글 */ + --map-grid-line: rgba(255, 255, 255, 0.55); /* 해석 격자 셀 선 */ + --map-flow-to-road-fill: rgba(220, 38, 38, 0.28); + --map-flow-to-road-line: rgba(153, 27, 27, 0.95); + --map-flow-away-fill: rgba(37, 99, 235, 0.22); + --map-flow-away-line: rgba(255, 255, 255, 0.95); + --map-flow-away-arrow: rgba(30, 64, 175, 0.95); + --map-flow-unknown-fill: rgba(120, 113, 108, 0.18); /* 표고가 없어 판정 못한 셀 */ + + /* 유역 채움 파스텔 8종 — 유역 번호 순으로 돌려쓴다(사용자 지시: 파스텔톤). + 알파 0.45는 비선택 유역을 0.18로 낮출 때 문자열로 치환하므로 형식을 바꾸지 말 것. */ + --map-basin-1: rgba(167, 216, 199, 0.45); + --map-basin-2: rgba(247, 208, 168, 0.45); + --map-basin-3: rgba(186, 199, 240, 0.45); + --map-basin-4: rgba(241, 183, 199, 0.45); + --map-basin-5: rgba(214, 226, 168, 0.45); + --map-basin-6: rgba(202, 186, 227, 0.45); + --map-basin-7: rgba(168, 214, 232, 0.45); + --map-basin-8: rgba(240, 219, 168, 0.45); } /* =============================================================================