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_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index b6a0d0916..ec52b41e3 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -360,18 +360,19 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { else context.moveTo(sx, sy); }); context.stroke(); - drawStationTicks( - context, - line.vertices.map(([x, y]) => ({ x, y })), - { - intervalM: PLAN_STATION_INTERVAL_M, - pxPerMeter: map.pxPerMeter, - toScreen: (x, y) => map.toScreen([x, y]), - uprightRad: map.uprightRad, - chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), - }, - ); - segmentLabels(context, map, line.vertices); + if (map.stations) + drawStationTicks( + context, + line.vertices.map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: map.pxPerMeter, + toScreen: (x, y) => map.toScreen([x, y]), + uprightRad: map.uprightRad, + chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + }, + ); + if (map.slopes) segmentLabels(context, map, line.vertices); }); // 꺾임점 context.fillStyle = "#ffffff"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts new file mode 100644 index 000000000..6d695d82e --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -0,0 +1,346 @@ +/* ============================================================================= + * 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; + icon: IconName; + title: string; + on: boolean; +} + +const LAYERS: readonly LayerDef[] = [ + { key: "satellite", icon: "satellite", title: "위성사진", on: true }, + { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, + { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, + { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, + { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, + { key: "slope", icon: "angle", 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, + 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..a4d0de066 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -1,29 +1,24 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Map.ts - * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 + 도엽 등고선 바탕 · 위에 겹(그리기 · 거리재기) · 도구줄 여섯 - * (되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 거리재기). + * D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉 + * (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기). * * 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤 - * `computeRouteView` · 등고선 `drawPreparedLayer` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · + * `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` · * 회전 `createMapRotation`(B05 그대로 · 단추 `[data-act="rotate-ccw|cw"]` · Ctrl+휠). * 왼쪽 단추 = 겹 몫(점 찍기 · 마크 · 재기) — 왼쪽 끌기 옮기기 없음(설정 창 규칙 = 가운데 단추 옮김 · 휠 방향). * 겹은 돌리기 전 그림 좌표로 그리고 받음 — 누른 자리는 `unrotate` 로 되돌려 넘김. * ========================================================================== */ -import { createIconButton } from "@ui/ui_template_button"; +import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; -import { createToolbar } from "@ui/ui_template_toolbar"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; 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 +44,6 @@ const FAKE_MARGIN_M = 600; export interface PlanMapScene { meta: VWorldMeta | null; image: HTMLImageElement | null; - contours: PreparedLayer | null; /** 처음 맞출 점(시점 · 종점 · 그은 선). */ fit: Vertex[]; } @@ -59,6 +53,11 @@ export interface ScreenMap { toScreen: (point: Vertex) => [number, number]; toMetric: (px: number, py: number) => Vertex; pxPerMeter: number; + /** B04 그리기 틀(`drawPreparedLayer` 등이 받음). */ + view: ViewState; + /** 측점 눈금 · 구간 길이 · 기울기 글 켬(도구줄 측점 · 기울기). */ + stations: boolean; + slopes: boolean; /** 글자를 세울 각(라디안 · 안 돌렸으면 0). */ uprightRad: number; /** (x, y) 둘레로 글자를 눈높이로 세워 그림(돌린 지도). */ @@ -132,40 +131,48 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const root = document.createElement("div"); root.className = "d03-plan__map"; const canvas = document.createElement("canvas"); - const tool = ( - act: string, - icon: Parameters[0]["icon"], - title: string, - onClick?: () => void, - ): HTMLButtonElement => { - const button = createIconButton({ icon, title, onClick }); - button.dataset.act = act; + let marks = { stations: true, slopes: true }; + const markButton = (act: "stations" | "slopes", title: string): HTMLButtonElement => { + const button = mapToolButton(act, { + title, + pressed: true, + onClick: () => { + marks = { ...marks, [act]: !marks[act] }; + button.setAttribute("aria-pressed", String(marks[act])); + draw(); + }, + }); return button; }; - const undoButton = tool("undo", "undo", "되돌리기(Ctrl+Z)", events.onUndo); - const redoButton = tool("redo", "redo", "다시하기(Ctrl+Y)", events.onRedo); - const viewButton = tool("view3d", "cube", "3D 보기", events.on3d); - const measureButton = tool("measure", "ruler", "거리재기", events.onMeasure); - measureButton.setAttribute("aria-pressed", "false"); - const toolbar = createToolbar({ - glass: true, - size: 32, + const undoButton = mapToolButton("undo", { onClick: events.onUndo }); + const redoButton = mapToolButton("redo", { onClick: events.onRedo }); + const viewButton = mapToolButton("view3d", { onClick: events.on3d }); + const measureButton = mapToolButton("measure", { onClick: events.onMeasure }); + const tools = createMapTools({ label: "평면 도구", - items: [ - undoButton, - redoButton, - "divider", - viewButton, - tool("rotate-ccw", "rotateCcw", "왼쪽으로 돌림(15°)"), - tool("rotate-cw", "rotateCw", "오른쪽으로 돌림(15°)"), - "divider", - measureButton, + hint: { + lines: [ + "누름 = 점 찍기", + "Enter = 긋기 끝", + "Esc = 긋기 취소", + "Backspace = 마지막 점 빼기", + "오른쪽 누름 = 마크 지움", + "가운데 끌기 = 옮기기", + "휠 = 확대 · 축소", + "Ctrl+휠 = 돌리기", + ], + }, + groups: [ + [undoButton, redoButton], + [viewButton, "rotate-ccw", "rotate-cw"], + [markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton], ], }); + const toolbar = tools.root; 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 +196,8 @@ 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, + ...marks, uprightRad: rotation.uprightRad(), upright: (ctx, x, y, paint) => { const angle = rotation.uprightRad(); @@ -234,31 +243,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..950185186 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -29,6 +29,23 @@ position: absolute; top: var(--spacing-8); right: var(--spacing-8); + align-items: flex-end; +} + +/* 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 { @@ -95,7 +112,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/resources/tester/test_552_4_frames.py b/resources/tester/test_552_4_frames.py index 96e40e4ad..44502598e 100644 --- a/resources/tester/test_552_4_frames.py +++ b/resources/tester/test_552_4_frames.py @@ -25,6 +25,7 @@ PARTS = { "float-panel": ("ui_template_float_panel", "createFloatPanel", "ui-float-panel", (32,)), "drawer": ("ui_template_side_panels", "createSidePanels", "ui-side-panels", ()), "toolbar": ("ui_template_toolbar", "createToolbar", "ui-toolbar", (24, 28, 32)), + "map-tools": ("ui_template_map_tools", "createMapTools", "ui-map-tools", ()), "steps": ("ui_template_workflow_layout", "createStepBar", "ui-workflow-layout__step", (28, 40)), "panel-head": ("ui_template_panel_head", "createPanelHead", "ui-panel-head", (32,)), "list-row": ("ui_template_list_row", "createListRow", "ui-list-row", (28, 32, 36)), diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 89db18e28..667385454 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -33,13 +33,34 @@ def test_d03_parts(): css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts") tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") + layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar()" in ts # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 - for src in (ts, draw, tools): + for src in (ts, draw, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): assert raw not in src, raw - assert "createToolbar({" in tools and "createIconButton(" in tools - assert '"rotateCcw"' in tools and '"rotateCw"' in tools and "leftPan" not in tools + # 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기) + assert "createMapTools({" in tools and "hint:" in tools and "leftPan" not in tools + assert "createToolbar(" not in tools and "createIconButton(" not in tools + for act in ("undo", "redo", "view3d", "measure", "stations", "slopes"): + assert f'mapToolButton("{act}"' in tools or f'markButton("{act}"' in tools, act + assert '"rotate-ccw", "rotate-cw"' in tools + for b05_only in ("footprint", "station-snap", "contour-minor"): + assert f'"{b05_only}"' not in tools, b05_only + assert "map.stations" in draw and "map.slopes" in draw + # 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억 + assert "createToolbar({" in layers and "createIconButton({" in layers + assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers + keys = re.findall(r'\{ key: "(\w+)", icon: "\w+", title: "[^"]+", on: (true|false) \}', layers) + assert keys == [ + ("satellite", "true"), + ("contour", "true"), + ("stream", "true"), + ("cadastral", "false"), + ("road", "false"), + ("slope", "false"), + ], keys + assert ".d03-plan__layers" in css and ".d03-plan__legend" in css assert "createSegment({" in draw and draw.count("createInputField(") == 2 assert "createSelectField(" in ts # 조각 연도 assert 'createElement("select")' not in three and "createSelectField" in three diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py new file mode 100644 index 000000000..2017121ee --- /dev/null +++ b/resources/tester/test_568_1_map_tools.py @@ -0,0 +1,81 @@ +"""568-1 지도 도구줄 부품 지킴 시험 — 화면 확인은 부품 모음(`#/b01-account?view=ui-kit`) · ORCA. + +· 단추 목록의 아이콘이 모두 아이콘 묶음에 있음 · 새 아이콘 여덟(도구 넷 · 겹 넷) +· 켬끔 단추 = aria-pressed · [?] = data-act="hint-toggle" + aria-expanded · 안내 판 기본 접힘 +./venv/Scripts/python.exe -m pytest resources/tester/test_568_1_map_tools.py -q +""" + +from __future__ import annotations + +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "ui_template" +TS = (UI / "ui_template_map_tools.ts").read_text(encoding="utf-8") +ICONS = (UI / "ui_template_icons.ts").read_text(encoding="utf-8") +CSS = (UI / "ui_template_map_tools.css").read_text(encoding="utf-8") + + +def _icon_names() -> set[str]: + body = ICONS[ICONS.index("const PATHS") : ICONS.index("} as const")] + return set(re.findall(r"^ (\w+):", body, re.M)) + + +def test_새_아이콘_여덟(): + names = _icon_names() + for n in ( + "ripple", + "tallymarks", + "percentage", + "magnet", + "satellite", + "droplet", + "borderAll", + "angle", + ): + assert n in names, n + + +def test_단추_목록_아이콘_차례(): + table = TS[TS.index("export const MAP_TOOLS") : TS.index("satisfies")] + rows = re.findall( + r'^ "?([\w-]+)"?: \{ icon: "(\w+)", title: "[^"]+"(, toggle: true)? \}', table, re.M + ) + acts = [a for a, _, _ in rows] + assert acts == [ + "undo", + "redo", + "view3d", + "rotate-ccw", + "rotate-cw", + "contour-minor", + "stations", + "slopes", + "footprint", + "measure", + "station-snap", + ] + names = _icon_names() + assert all(icon in names for _, icon, _ in rows) + toggles = {a for a, _, t in rows if t} + assert toggles == { + "contour-minor", + "stations", + "slopes", + "footprint", + "measure", + "station-snap", + } + + +def test_켬끔_안내_판(): + assert "pressed: tool.toggle ? (opts.pressed ?? false) : undefined" in TS + assert "button.dataset.act = act;" in TS + assert 'toggle.dataset.act = "hint-toggle";' in TS + assert 'toggle.setAttribute("aria-expanded", String(open));' in TS + assert "setOpen(opts.hint.open ?? false);" in TS # 기본 접힘 + hint = CSS[CSS.index(".ui-map-tools__hint {") :] + assert "display: none;" in hint[: hint.index("}")] + assert "display: grid;" in CSS[CSS.index(".ui-map-tools__hint.is-open {") :] + assert "pointer-events: none;" in CSS # 빈 자리 · 글판은 지도 조작 통과 diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 6385499b0..7ac90773f 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -123,6 +123,21 @@ const PATHS = { mail: "M3 7a2 2 0 0 1 2 -2h14a2 2 0 0 1 2 2v10a2 2 0 0 1 -2 2h-14a2 2 0 0 1 -2 -2v-10|M3 7l9 6l9 -6", inbox: "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12|M4 13h3l3 3h4l3 -3h3", + // 568-1 — 지도 도구(1m 등고선 · 측점 · 기울기 · 측점 스냅) + ripple: "M3 7c3 -2 6 -2 9 0s6 2 9 0|M3 17c3 -2 6 -2 9 0s6 2 9 0|M3 12c3 -2 6 -2 9 0s6 2 9 0", + tallymarks: "M6 5l0 14|M10 5l0 14|M14 5l0 14|M18 5l0 14|M3 17l18 -10", + percentage: "M16 17a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 7a1 1 0 1 0 2 0a1 1 0 0 0 -2 0|M6 18l12 -12", + magnet: + "M4 13v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a2 2 0 0 0 6 0v-8a2 2 0 0 1 2 -2h1a2 2 0 0 1 2 2v8a8 8 0 0 1 -16 0|M4 8l5 0|M15 8l4 0", + // 568-1 — 지도 겹(위성 · 세류선 · 지적도 · 급경사) + satellite: + "M3.707 6.293l2.586 -2.586a1 1 0 0 1 1.414 0l5.586 5.586a1 1 0 0 1 0 1.414l-2.586 2.586a1 1 0 0 1 -1.414 0l-5.586 -5.586a1 1 0 0 1 0 -1.414z|M6 10l-3 3l3 3l3 -3|M10 6l3 -3l3 3l-3 3|M12 12l1.5 1.5|M14.5 17a2.5 2.5 0 0 0 2.5 -2.5|M15 21a6 6 0 0 0 6 -6", + droplet: + "M7.502 19.423c2.602 2.105 6.395 2.105 8.996 0c2.602 -2.105 3.262 -5.708 1.566 -8.546l-4.89 -7.26c-.42 -.625 -1.287 -.803 -1.936 -.397a1.376 1.376 0 0 0 -.41 .397l-4.893 7.26c-1.695 2.838 -1.035 6.441 1.567 8.546z", + borderAll: + "M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M4 12l16 0|M12 4l0 16", + angle: + "M21 19h-18l9 -15|M20.615 15.171h.015|M19.515 11.771h.015|M17.715 8.671h.015|M15.415 5.971h.015", } as const; export type IconName = keyof typeof PATHS; diff --git a/ui_template/ui_template_map_tools.css b/ui_template/ui_template_map_tools.css new file mode 100644 index 000000000..29457cbfe --- /dev/null +++ b/ui_template/ui_template_map_tools.css @@ -0,0 +1,33 @@ +/* 지도 화면 제어 도구줄(568-1) — 도구줄 + 아래 [?] 안내 판 · 자리는 페이지가 줌 · 빈 자리는 지도 조작 통과 */ +.ui-map-tools { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--spacing-8); + pointer-events: none; +} + +.ui-map-tools > .ui-toolbar { + max-width: 100%; + pointer-events: auto; +} + +/* 안내 판 — 기본 접힘 · 2열 · 글판은 클릭 통과 */ +.ui-map-tools__hint { + display: none; + grid-template-columns: auto auto; + gap: 2px var(--spacing-16); + max-width: 100%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent); + border-radius: var(--radius-cards); + background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-map-tools__hint.is-open { + display: grid; +} diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts new file mode 100644 index 000000000..1c2496a6f --- /dev/null +++ b/ui_template/ui_template_map_tools.ts @@ -0,0 +1,119 @@ +/* ============================================================================= + * ui_template_map_tools.ts + * 지도 화면 제어 도구줄(568-1) — 평면 지도 위 아이콘 단추 한 벌 · B05 평면 · D03 평면이 같이 씀. + * 꼴 = 유리 도구줄(`createToolbar` glass · 32) 한 줄 · 묶음 사이 나눔선 · 단추는 모두 아이콘(마우스 올리면 이름) + * [?] 안내 판 = 도구줄 아래 2열 글판(기본 접힘 · 클릭은 지도로 통과) + * 크기 = 단 32 하나(지도 위 도구) + * 상태 = 켬끔 단추는 `aria-pressed`(눌림 꼴) · 막힘 `disabled` · [?] 는 `aria-expanded` + * 쓰는 법 = createMapTools({ label: "평면 도구", groups: [["undo", "redo"], ["rotate-ccw", "rotate-cw", "measure"]], + * hint: { lines: ["휠 = 확대"] } }) → { root, bar, hint, button(act) } + * 페이지 문구가 다르면 `mapToolButton("measure", { title })` 를 묶음에 그대로 끼움 · 고르기 · 칩도 요소로 끼움 + * 누름 동작은 페이지가 붙임(`onClick` 또는 `[data-act]` 로 찾아 붙임) · 자리는 페이지 CSS(root) + * 대신하는 옛 변종 = B05 `b05-route__view-controls` 글자 유리 단추 줄 · `b05-routeedit__hint` 안내 판 · D03 `tool()` 지역 함수 + * ========================================================================== */ + +import { createIconButton } from "./ui_template_button"; +import type { IconName } from "./ui_template_icons"; +import { createToolbar } from "./ui_template_toolbar"; +import "./ui_template_map_tools.css"; + +/** 단추 목록 — 차례 = 도구줄 차례 · toggle = 켬끔 단추(aria-pressed) */ +export const MAP_TOOLS = { + undo: { icon: "undo", title: "되돌리기 (Ctrl+Z)" }, + redo: { icon: "redo", title: "다시하기 (Ctrl+Y)" }, + view3d: { icon: "cube", title: "3D 보기" }, + "rotate-ccw": { icon: "rotateCcw", title: "반시계로 돌리기" }, + "rotate-cw": { icon: "rotateCw", title: "시계로 돌리기" }, + "contour-minor": { icon: "ripple", title: "1m 등고선", toggle: true }, + stations: { icon: "tallymarks", title: "측점", toggle: true }, + slopes: { icon: "percentage", title: "기울기", toggle: true }, + footprint: { icon: "road", title: "절/성토선", toggle: true }, + measure: { icon: "ruler", title: "거리 재기", toggle: true }, + "station-snap": { icon: "magnet", title: "측점 스냅", toggle: true }, +} satisfies Record; + +export type MapToolAct = keyof typeof MAP_TOOLS; + +export interface MapToolButtonOptions { + /** 마우스 글 — 없으면 목록 이름 */ + title?: string; + /** 켬끔 단추 처음 값(기본 false) */ + pressed?: boolean; + disabled?: boolean; + onClick?: (ev: MouseEvent) => void; +} + +/** 아이콘 단추 하나(`data-act` = act) */ +export function mapToolButton(act: MapToolAct, opts: MapToolButtonOptions = {}): HTMLButtonElement { + const tool: { icon: IconName; title: string; toggle?: boolean } = MAP_TOOLS[act]; + const button = createIconButton({ + icon: tool.icon, + title: opts.title ?? tool.title, + size: 32, + pressed: tool.toggle ? (opts.pressed ?? false) : undefined, + disabled: opts.disabled, + onClick: opts.onClick, + }); + button.dataset.act = act; + return button; +} + +export interface MapToolsOptions { + /** 묶음들 — 사이에 나눔선 · act 이름 또는 페이지가 만든 요소 */ + groups: (MapToolAct | HTMLElement)[][]; + /** 보조기기 이름 */ + label: string; + /** [?] 안내 판(맨 앞 묶음) — 줄마다 한 칸 · 2열 */ + hint?: { lines: string[]; open?: boolean; onToggle?: (open: boolean) => void }; +} + +export interface MapTools { + root: HTMLElement; + bar: HTMLElement; + hint: HTMLElement | null; + button(act: MapToolAct): HTMLButtonElement | null; +} + +export function createMapTools(opts: MapToolsOptions): MapTools { + const root = document.createElement("div"); + root.className = "ui-map-tools"; + const groups = opts.groups.map((group) => + group.map((item) => (typeof item === "string" ? mapToolButton(item) : item)), + ); + let hint: HTMLElement | null = null; + if (opts.hint) { + const { lines, onToggle } = opts.hint; + const toggle = createIconButton({ icon: "help", title: "조작 도움말 펼치기 · 접기", size: 32 }); + toggle.dataset.act = "hint-toggle"; + const panel = document.createElement("div"); + panel.className = "ui-map-tools__hint"; + for (const line of lines) + panel.append(Object.assign(document.createElement("span"), { textContent: line })); + const setOpen = (open: boolean): void => { + panel.classList.toggle("is-open", open); + toggle.setAttribute("aria-expanded", String(open)); + }; + setOpen(opts.hint.open ?? false); + toggle.addEventListener("click", () => { + const open = !panel.classList.contains("is-open"); + setOpen(open); + onToggle?.(open); + }); + groups.unshift([toggle]); + hint = panel; + } + const bar = createToolbar({ + glass: true, + size: 32, + label: opts.label, + items: groups.flatMap((group, i) => (i ? ["divider" as const, ...group] : group)), + }); + root.append(bar); + if (hint) root.append(hint); + return { + root, + bar, + hint, + button: (act) => root.querySelector(`[data-act="${act}"]`), + }; +} 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"] { diff --git a/ui_template/ui_template_ui_kit_frames.ts b/ui_template/ui_template_ui_kit_frames.ts index 5b5c4326f..b1e9558a3 100644 --- a/ui_template/ui_template_ui_kit_frames.ts +++ b/ui_template/ui_template_ui_kit_frames.ts @@ -10,6 +10,7 @@ import { createFloatPanel } from "./ui_template_float_panel"; import { createFormGrid, createPageFrame } from "./ui_template_general_layout"; import { createInputField } from "./ui_template_input"; import { createListRow } from "./ui_template_list_row"; +import { createMapTools, mapToolButton } from "./ui_template_map_tools"; import { createWorkflowPanelHandle } from "./ui_template_overlay"; import { createPanelHead } from "./ui_template_panel_head"; import { createPublicFrame } from "./ui_template_public_layout"; @@ -218,6 +219,27 @@ function toolbar(): HTMLElement { return box; } +function mapTools(): HTMLElement { + const tools = createMapTools({ + label: "평면 도구", + hint: { lines: ["휠 = 확대", "가운데 끌기 = 이동", "Ctrl+휠 = 돌리기", "Esc = 그리기 끝"] }, + groups: [ + ["undo", "redo"], + ["view3d", "rotate-ccw", "rotate-cw"], + [ + "contour-minor", + "stations", + mapToolButton("slopes", { pressed: true }), + "footprint", + "measure", + ], + ["station-snap"], + ], + }); + tools.button("contour-minor")!.disabled = true; + return tools.root; +} + /** 열쇠 → 견본(상태별 · null = 그 상태 없음) */ export const FRAME_DEMOS: Record HTMLElement | null> = { "work-frame": (s) => (s === "default" ? workFrame() : null), @@ -226,6 +248,7 @@ export const FRAME_DEMOS: Record HTMLElement | null "float-panel": (s) => (s === "default" ? floatPanel() : null), drawer: (s) => (s === "default" ? drawer() : null), toolbar: (s) => (s === "default" ? toolbar() : null), + "map-tools": (s) => (s === "default" ? mapTools() : null), steps, "panel-head": (s) => (s === "default" ? panelHead() : null), "list-row": listRow, diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index 9d46124da..84d426c78 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -363,6 +363,8 @@ export const KIT_PARTS: KitPart[] = [ demo: FRAME_DEMOS["drawer"] }, { key: "toolbar", name: "도구줄", group: "frame", kinds: "단추 묶음 · 나눔선 · 밀기 · 유리 · 24 · 28 · 32", file: "ui_template_toolbar.ts", demo: FRAME_DEMOS["toolbar"] }, + { key: "map-tools", name: "지도 도구줄", group: "frame", kinds: "아이콘 단추 한 벌 · 묶음 나눔선 · 켬끔(aria-pressed) · [?] 안내 판 · 페이지가 쓸 단추만", file: "ui_template_map_tools.ts", + demo: FRAME_DEMOS["map-tools"] }, { key: "steps", name: "단계 줄", group: "frame", kinds: "가로 막대 · compact(28) · 세로 목록(40 · ☰ 서랍)", file: "ui_template_workflow_layout.ts", demo: FRAME_DEMOS.steps }, { key: "panel-head", name: "패널 머리", group: "frame", kinds: "제목 · 부제 · 오른쪽 단추 · 밑줄", file: "ui_template_panel_head.ts",