From 6cec62c75b16bb80cde9edd69105c7d9f15abf18 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Wed, 28 Oct 2026 07:01:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EB=85=B8=EC=84=A0=EB=B6=84?= =?UTF-8?q?=EC=84=9D=20=EC=A7=80=EB=8F=84=20=EB=B0=94=ED=83=95=20=EA=B2=B9?= =?UTF-8?q?=20=EC=BC=9C=EA=B3=A0=20=EB=81=94=20=E2=80=94=20=EC=9C=84?= =?UTF-8?q?=EC=84=B1=20=C2=B7=20=EB=93=B1=EA=B3=A0=EC=84=A0(=EA=B3=84?= =?UTF-8?q?=EA=B3=A1=EC=84=A0=20=EA=B5=B5=EA=B2=8C)=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=EB=A5=98=20=C2=B7=20=EC=A7=80=EC=A0=81=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EB=A1=9C=20=C2=B7=20=EA=B8=89=EA=B2=BD=EC=82=AC=20(PLAN=20569-?= =?UTF-8?q?2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20395-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_Layers.ts — 왼쪽 위 겹 단추 한 줄 · 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 aislo:pref:d03.layers 기억 · 켤 때만 받음 · 급경사 범례 - UI_Map — 등고선 그리기를 겹으로 옮김 · ScreenMap.view 더함 - Api — 569-1 서버 겹 세 길(지적 · 도엽 겹 · 급경사 그림) 부름 - theme.css 그림 색 일곱(지도 겹 넷 · 급경사 셋) · test_551_2 그림 값 개수 17 → 24(새 값 일곱) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 20 +- .../D03_PlanAnalysis_UI_Layers.ts | 349 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 39 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 54 +-- .../D03_PlanAnalysis_UI_Style.css | 18 +- resources/tester/test_551_2_draw_tokens.py | 2 +- ui_template/ui_template_theme.css | 7 + 7 files changed, 412 insertions(+), 77 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index b8923ff97..12d06ee61 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -1,12 +1,14 @@ /* ============================================================================= * D03_PlanAnalysis_Api.ts * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · - * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점). + * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) · + * 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -132,3 +134,19 @@ export async function fetchPlanSurface(projectId: string): Promise => + request(`${base(projectId)}/layers/cadastral`); + +/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */ +export const fetchPlanSheetLayer = (projectId: string, key: string): Promise => + request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); + +export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise => + request(`${base(projectId)}/layers/raster?kind=${kind}`); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts new file mode 100644 index 000000000..d2eaa253d --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -0,0 +1,349 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Layers.ts + * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로). + * + * 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 · + * 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸). + * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. + * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음. + * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선. + * ========================================================================== */ + +import { createIconButton } from "@ui/ui_template_button"; +import { showToast } from "@ui/ui_template_elements"; +import { createLegend } from "@ui/ui_template_flow_legend"; +import type { IconName } from "@ui/ui_template_icons"; +import { themeColor } from "@ui/ui_template_palette"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { + fetchVWorldMeta, + getVWorldMapUrl, + type VWorldMeta, +} from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + createNormalizer, + drawPreparedLabels, + drawPreparedLayer, + pickLevelStep, + type GeoJsonCollection, + type PreparedLayer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; +import { + fetchPlanCadastral, + fetchPlanRaster, + fetchPlanSheetLayer, + type PlanRaster, +} from "./D03_PlanAnalysis_Api"; +import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; + +export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope"; + +interface LayerDef { + key: PlanLayerKey; + /** 아이콘 — 없으면 글자 단추(`glyph`). */ + icon?: IconName; + glyph?: string; + title: string; + on: boolean; +} + +const LAYERS: readonly LayerDef[] = [ + { key: "satellite", glyph: "위성", title: "위성사진", on: true }, + { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, + { key: "stream", glyph: "세류", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, + { key: "cadastral", glyph: "지적", title: "지적도(참고용 · 법적 효력 없음)", on: false }, + { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, + { key: "slope", glyph: "경사", title: "급경사 색(15° · 35° · 40° 이상)", on: false }, +]; +const PREF_KEY = "aislo:pref:d03.layers"; +/** 등고선을 받아 올 그은 선 둘레(m). */ +const CONTOUR_PAD_M = 600; +const SLOPE_STEPS = [ + { label: "15°", token: "--draw-slope-15" }, + { label: "35°", token: "--draw-slope-35" }, + { label: "40°", token: "--draw-slope-40" }, +]; + +interface Prepared { + contour: PreparedLayer; + /** 세류 · 하천(소하천 · 지방하천 · 연결선). */ + stream: [PreparedLayer, PreparedLayer]; + cadastral: PreparedLayer; + road: PreparedLayer; + slope: { raster: PlanRaster; image: HTMLImageElement }; +} + +export interface PlanLayers { + bar: HTMLElement; + legend: HTMLElement; + /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ + layers: PlanMapLayer[]; + /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */ + load: () => Promise; + /** 검증 창구 — 켬끔 · 받은 겹. */ + state: () => { on: Record; loaded: PlanLayerKey[] }; +} + +function readPref(): Record { + const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record< + PlanLayerKey, + boolean + >; + try { + const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial>; + for (const def of LAYERS) + if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean; + } catch { + // 깨진 값 = 처음 값 + } + return on; +} + +const loadImage = (src: string): Promise => + new Promise((done, fail) => { + const image = new Image(); + image.onload = () => done(image); + image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); + image.src = src; + }); + +export function createPlanLayers(opts: { + projectId: string; + map: PlanMap; + /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */ + points: () => Vertex[]; +}): PlanLayers { + const { projectId, map } = opts; + const on = readPref(); + const data: Partial = {}; + const pending = new Map>(); + let meta: VWorldMeta | null = null; + let satellite: HTMLImageElement | null = null; + + const buttons = new Map(); + for (const def of LAYERS) { + const button = createIconButton({ + icon: def.icon, + glyph: def.glyph, + title: def.title, + pressed: on[def.key], + onClick: () => toggle(def.key), + }); + button.dataset.layer = def.key; + buttons.set(def.key, button); + } + const bar = createToolbar({ + glass: true, + size: 32, + label: "지도 겹", + items: [...buttons.values()], + }); + bar.classList.add("d03-plan__layers"); + const legend = document.createElement("div"); + legend.className = "d03-plan__legend"; + legend.title = "급경사 색 — 그 값 이상"; + legend.append( + createLegend(SLOPE_STEPS.map((step) => ({ label: step.label, color: `var(${step.token})` }))), + ); + + function sync(): void { + for (const def of LAYERS) { + const button = buttons.get(def.key)!; + button.setAttribute("aria-pressed", String(on[def.key])); + button.disabled = !meta; + const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`; + button.title = title; + button.setAttribute("aria-label", title); + } + legend.hidden = !meta || !on.slope || !data.slope; + } + + function toggle(key: PlanLayerKey): void { + on[key] = !on[key]; + writeByKey(PREF_KEY, JSON.stringify(on)); + void ensure(key); + showSatellite(); + sync(); + map.redraw(); + } + + function showSatellite(): void { + map.update({ image: on.satellite ? satellite : null }); + } + + /** 켜진 겹의 자료를 받음(한 번). */ + function ensure(key: PlanLayerKey): Promise { + if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared])) + return Promise.resolve(); + const running = pending.get(key); + if (running) return running; + const at = meta; + const job = fetchLayer(key, at) + .then(() => { + if (meta !== at) return; + showSatellite(); + sync(); + map.redraw(); + }) + .catch((error: unknown) => { + const text = error instanceof Error ? error.message : String(error); + showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); + }) + .finally(() => pending.delete(key)); + pending.set(key, job); + return job; + } + + async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise { + const normalizer = createNormalizer(at); + const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => + prepareLayer(collection, normalizer, labels); + if (key === "satellite") { + // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 + const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`; + satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`); + } else if (key === "contour") { + const points = opts.points(); + const xs = points.map(([x]) => x); + const ys = points.map(([, y]) => y); + const bbox = [ + Math.min(...xs) - CONTOUR_PAD_M, + Math.min(...ys) - CONTOUR_PAD_M, + Math.max(...xs) + CONTOUR_PAD_M, + Math.max(...ys) + CONTOUR_PAD_M, + ].map((v) => v.toFixed(0)); + const collection = await fetchCachedSheetLayer( + projectId, + "도엽_등고선", + "route", + bbox.join(","), + ); + data.contour = prepare(collection, ["등고수치"]); + } else if (key === "stream") { + const collection = await fetchCachedSheetLayer( + projectId, + "도엽_하천중심선", + ); + const pick = (small: boolean): GeoJsonCollection => ({ + ...collection, + features: (collection.features ?? []).filter( + (feature) => (feature.properties?.["구분"] === "세류") === small, + ), + }); + data.stream = [prepare(pick(true)), prepare(pick(false))]; + } else if (key === "cadastral") { + data.cadastral = prepare(await fetchPlanCadastral(projectId)); + } else if (key === "road") { + data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선")); + } else { + const raster = await fetchPlanRaster(projectId, "slope"); + data.slope = { raster, image: await loadImage(raster.image) }; + } + } + + const stroke = ( + context: CanvasRenderingContext2D, + map: ScreenMap, + layer: PreparedLayer, + color: string, + width: number, + everyM?: number, + ): void => { + context.strokeStyle = color; + context.lineWidth = width; + drawPreparedLayer(context, layer, map.view, "dot", everyM); + }; + + const layers: PlanMapLayer[] = [ + { + draw: (context, map) => { + const slope = on.slope && data.slope; + if (!slope) return; + const [x0, y0, x1, y1] = slope.raster.box_m; + const [left, top] = map.toScreen([x0, y1]); + const [right, bottom] = map.toScreen([x1, y0]); + context.globalAlpha = 0.55; + context.drawImage(slope.image, left, top, right - left, bottom - top); + }, + }, + { + draw: (context, map) => { + if (!on.cadastral || !data.cadastral) return; + context.globalAlpha = 0.8; + stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#facc15"), 0.8); + }, + }, + { + draw: (context, map) => { + const contour = on.contour && data.contour; + if (!contour) return; + const color = themeColor("--draw-map-contour", "#ffd68c"); + context.globalAlpha = 0.7; + stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5)); + stroke(context, map, contour, color, 1.6, 25); // 계곡선 + context.globalAlpha = 1; + context.font = "10px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + drawPreparedLabels( + context, + contour, + map.view, + color, + 25, + map.uprightRad, + "rgba(0, 0, 0, 0.6)", + ); + }, + }, + { + draw: (context, map) => { + if (!on.road || !data.road) return; + stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2); + stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6); + }, + }, + { + draw: (context, map) => { + if (!on.stream || !data.stream) return; + const color = themeColor("--draw-map-stream", "#38bdf8"); + stroke(context, map, data.stream[0], color, 1); + stroke(context, map, data.stream[1], color, 2.4); + }, + }, + ]; + + async function load(): Promise { + const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); + const changed = + JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !== + JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]); + meta = next; + if (changed) { + satellite = null; + for (const key of Object.keys(data)) delete data[key as keyof Prepared]; + } + map.update({ meta }); + showSatellite(); + sync(); + await Promise.all(LAYERS.map((def) => ensure(def.key))); + } + + return { + bar, + legend, + layers, + load, + state: () => ({ + on: { ...on }, + loaded: [ + ...(satellite ? (["satellite"] as PlanLayerKey[]) : []), + ...(Object.keys(data) as PlanLayerKey[]), + ], + }), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 0a84e4bcc..3fb0d9adf 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -1,10 +1,10 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Map.ts - * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯 + * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 여섯 * (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기). * * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 - * `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · + * `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. @@ -18,12 +18,8 @@ import { computeMapRect, computeRouteView, createMetricProjector, - drawPreparedLabels, - drawPreparedLayer, normalizedToScreen, - pickLevelStep, routeLineColor, - type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; @@ -49,7 +45,6 @@ const FAKE_MARGIN_M = 600; export interface PlanMapScene { meta: VWorldMeta | null; image: HTMLImageElement | null; - contours: PreparedLayer | null; /** 처음 맞출 점(시점 · 종점 · 그은 선). */ fit: Vertex[]; } @@ -59,6 +54,8 @@ export interface ScreenMap { toScreen: (point: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; pxPerMeter: number; + /** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */ + view: ViewState; /** 글자를 세울 각(라디안 · 안 돌렸으면 0). */ uprightRad: number; /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ @@ -165,7 +162,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { toolbar.classList.add("d03-plan__tools"); root.append(canvas, toolbar); const context = canvas.getContext("2d")!; - let scene: PlanMapScene = { meta: null, image: null, contours: null, fit: [] }; + let scene: PlanMapScene = { meta: null, image: null, fit: [] }; let layers: PlanMapLayer[] = []; let meta: VWorldMeta | null = null; let fitted = false; @@ -189,6 +186,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { toScreen: (point) => projector.toScreen(point[0], point[1]), toMetric: (px, py) => screenToMetric(meta!, view, px, py), pxPerMeter: projector.pxPerMeter, + view, uprightRad: rotation.uprightRad(), upright: (ctx, x, y, paint) => { const angle = rotation.uprightRad(); @@ -234,31 +232,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const [x1, y1] = normalizedToScreen(view, 1, 1); context.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); } - if (scene.contours) { - context.save(); - context.strokeStyle = "rgba(255, 214, 140, 0.7)"; - context.lineWidth = 0.8; - drawPreparedLayer( - context, - scene.contours, - view, - "dot", - pickLevelStep(scene.contours, view, 5), - ); - context.font = "10px system-ui, sans-serif"; - context.textAlign = "center"; - context.textBaseline = "middle"; - drawPreparedLabels( - context, - scene.contours, - view, - "#fde68a", - 25, - rotation.uprightRad(), - "rgba(0, 0, 0, 0.6)", - ); - context.restore(); - } const map = screenMap(); for (const layer of layers) { context.save(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 56b090ffd..bd955b005 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -2,7 +2,7 @@ * D03_PlanAnalysis_UI_Page.ts * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`). * - * 메인 = 평면(위성 + 도엽 등고선 + 그은 선 · 측점 · 구역 마크) + 도구 여섯. + * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯. * 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) · * 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화]. * @@ -26,16 +26,9 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { fetchCachedSheetLayer, DRAINAGE_SHEET_LAYERS } from "../A00_Common/b_asset_cache"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { fetchSessionUser } from "../A06_Login/A06_Login_Api_Fetch"; -import { fetchVWorldMeta, getVWorldMapUrl } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import { - createNormalizer, - type GeoJsonCollection, -} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { heightAt as gridHeightAt } from "../common_util/common_util_surface_sampler"; import { stationLabel } from "../common_util/common_util_station"; import { @@ -52,6 +45,7 @@ import { } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; @@ -67,8 +61,6 @@ import { } from "./logic/D03_PlanAnalysis_Plan"; const POLL_MS = 1000; -/** 등고선을 받아 올 그은 선 둘레(m). */ -const CONTOUR_PAD_M = 600; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; /** 조각 연도 고를 수 — 사업연도 시작부터. */ @@ -122,6 +114,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { on3d: open3d, onMeasure: () => setMeasure(!measuring), }); + const backdrop = createPlanLayers({ + projectId, + map, + points: () => [start, end, ...allVertices()], + }); + map.root.append(backdrop.bar, backdrop.legend); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); @@ -210,7 +208,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { measuring = on; measure.clear(); if (on) draw.setMode("idle"); - map.setLayers(measuring ? [draw.layer, measure.layer] : [draw.layer]); + map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]); refresh(); } @@ -376,7 +374,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const job = await watch(); if (!job || !root.isConnected) return; failed = job.error; - await Promise.all([loadSurface(), loadBackground()]); + await Promise.all([loadSurface(), backdrop.load()]); refresh(); } @@ -417,33 +415,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } } - async function loadBackground(): Promise { - const meta = await fetchVWorldMeta(projectId, "satellite").catch(() => null); - if (!meta) return map.update({ meta: null, image: null, contours: null }); - const image = new Image(); - image.onload = () => map.redraw(); - // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 - image.src = `${getVWorldMapUrl(projectId, "satellite")}&v=${meta.x_min}_${meta.x_max}_${meta.y_min}_${meta.y_max}`; - const points = [start, end, ...allVertices()]; - const xs = points.map(([x]) => x); - const ys = points.map(([, y]) => y); - const bbox = [ - Math.min(...xs) - CONTOUR_PAD_M, - Math.min(...ys) - CONTOUR_PAD_M, - Math.max(...xs) + CONTOUR_PAD_M, - Math.max(...ys) + CONTOUR_PAD_M, - ].map((v) => v.toFixed(0)); - const contours = await fetchCachedSheetLayer( - projectId, - DRAINAGE_SHEET_LAYERS[0], - "route", - bbox.join(","), - ) - .then((collection) => prepareLayer(collection, createNormalizer(meta), ["등고수치"])) - .catch(() => null); - map.update({ meta, image, contours }); - } - /** 서버 응답 → 지금 값 · 저장본(빈 선 = 시점만). */ function takeServer(view: PlanNetworkView): void { if (view.year_start) yearStart = view.year_start; @@ -536,6 +507,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { dirty, saveMs: () => lastSaveMs, heightAt: z, + layers: backdrop.state, }; const onKey = (event: KeyboardEvent): void => { if (!root.isConnected) return window.removeEventListener("keydown", onKey); @@ -557,7 +529,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } }; window.addEventListener("keydown", onKey); - map.setLayers([draw.layer]); + map.setLayers([...backdrop.layers, draw.layer]); try { editable = !!(await fetchSessionUser())?.is_forest_service; @@ -578,7 +550,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); await loadSurface(); refresh(); - await loadBackground(); + await backdrop.load(); if ((await fetchPlanProgress(projectId)).terrain.running) await follow(); } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index c493fe022..1df8f3989 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -31,6 +31,22 @@ right: var(--spacing-8); } +/* 569-2 바탕 겹 단추 — 왼쪽 위 · 급경사 범례 — 왼쪽 아래 */ +.d03-plan__layers { + position: absolute; + top: var(--spacing-8); + left: var(--spacing-8); +} + +.d03-plan__legend { + position: absolute; + bottom: var(--spacing-8); + left: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border-radius: var(--radius-lg); + background: var(--color-surface); +} + .d03-plan__hint { margin: 0 0 6px; } @@ -95,7 +111,7 @@ .d03-plan__3d-body > .b05-route__viewport { flex: 1 1 auto; min-height: 0; - border-radius: var(--radius-md); + border-radius: var(--radius-lg); } .d03-plan__3d-top { diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index ead51bea7..fb631cd70 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -124,7 +124,7 @@ def test_draw_values_are_literal_hex(): assert draws, mode for name, value in draws.items(): assert HEX.fullmatch(value), (mode, name, value) - assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 17 + assert len([k for k in blocks["light"] if k.startswith("--draw-")]) == 24 def test_drawing_css_uses_no_screen_tokens(): diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 0493e0bca..065f1a4d5 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -551,6 +551,13 @@ html.is-bm-route .m01lab-src__body * { --draw-surface: #f6f7fa; --draw-surface-raised: #ffffff; /* 그림 바탕 · 3D 바탕 */ --draw-canvas: #ffffff; /* CAD 바탕 */ + --draw-map-contour: #ffd68c; /* 위성 위 겹(노선분석) — 도엽 등고선 */ + --draw-map-stream: #38bdf8; /* 세류 · 하천 */ + --draw-map-road: #f8fafc; /* 기존 도로 */ + --draw-map-parcel: #facc15; /* 지적 경계 */ + --draw-slope-15: #fde047; /* 급경사 색 15° 이상 */ + --draw-slope-35: #f97316; /* 35° 이상 */ + --draw-slope-40: #dc2626; /* 40° 이상 */ } :root[data-theme="dark"] {