From 5d4737210ab9b9c28f4ee71ce92894f953c4e26f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:12:48 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(D03=20=EA=B8=8B=EA=B8=B0):=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EA=B8=B8=EC=9D=B4=C2=B7=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20=EA=B3=A0=EC=A0=95=20=C2=B7=20=EB=B2=84=ED=8A=BC=20?= =?UTF-8?q?=EC=9E=90=EC=9C=A0=EC=A7=80=EC=A0=95=20=EC=98=A4=EB=A5=B8?= =?UTF-8?q?=EC=AA=BD=20=C2=B7=20Tab=20=EB=B0=A9=EC=8B=9D=20=EB=B0=94?= =?UTF-8?q?=EA=BF=88=20=C2=B7=20=EC=95=88=20=EC=93=B0=EB=8A=94=20=EC=B9=B8?= =?UTF-8?q?=20=EC=88=A8=EA=B9=80=20(PLAN=20625-1=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 방식 차례 = 자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · 「자유」 → 「자유지정」 - 긋기 처음 방식 = 길이·기울기 고정(기억 안 함) - Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정 · 등록부 plan.drawKind · 긋기 아닐 때 = 초점 이동 그대로 - 길이 · 기울기 칸 = 방식에 안 쓰면 숨김 · 막힘 = 권한 없음만 - 지도 안내 글 Tab · Shift 줄 · 겹 hover 에 shift 인자(625-2 몫) Co-Authored-By: Claude Opus 5.5 --- A00_Common/key_registry.ts | 2 ++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 14 ++++++++++++-- D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts | 15 +++++++++------ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 6 ++++-- D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css | 4 ++++ 5 files changed, 31 insertions(+), 10 deletions(-) diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index c460cc0ff..1074f20e8 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -138,6 +138,8 @@ export const KEY_ACTIONS: readonly KeyAction[] = [ A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]), A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]), A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]), + A("plan.drawKind", "plan", ["Tab"], ["긋는 중 방식 자유지정 ↔ 길이·기울기 고정", "Drawing: free ↔ length + slope"]), + A("plan.drawBetween", "plan", ["Shift 누른 채"], ["길이·기울기 고정 긋는 중 부채꼴 사이 기울기 자리(길이 그대로)", "Drawing: pick between-slope on fan (length kept)"], F), A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]), A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F), A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 7d18054d1..64ee4a4a1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -16,7 +16,7 @@ * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. * 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`. - * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). + * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기 · Tab 방식 바꿈(설정 창 단축키 표 D03 줄). * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). * ========================================================================== */ @@ -120,7 +120,8 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 let preview: DrawPreview | null = null; let candidates: Vertex[] = []; - let kind: DrawConstraint = "free"; + let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9) + let hoverAt: { px: number; py: number; map: ScreenMap } | null = null; // Tab 뒤 미리보기 다시 let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 @@ -472,9 +473,11 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; }, hover(px, py, map) { + hoverAt = { px, py, map }; preview = mode === "draw" ? previewAt(px, py, map) : null; }, leave() { + hoverAt = null; preview = null; }, }; @@ -501,6 +504,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { panelState(); }, key(event) { + // Tab = 자유지정 ↔ 길이·기울기 고정(단독 고정이면 길이·기울기로 · 625-1) · 긋기 아닐 때 = 초점 이동 + if (mode === "draw" && keyIs(event, "plan.drawKind")) { + kind = kind === "both" ? "free" : "both"; + kindFields.set({ kind }); + if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map); + return true; + } if (mode === "draw" && keyIs(event, "plan.drawFinish")) { if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info"); setMode("idle"); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts index 18b8214d7..20c2de8cc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -2,8 +2,9 @@ * D03_PlanAnalysis_UI_DrawPanel.ts * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸. * - * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) + - * 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. + * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 · + * 625-1) + 길이 · 기울기 두 열(기울기 아래 ≈ °) — 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) · + * 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. * 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). * 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만. * ========================================================================== */ @@ -29,10 +30,10 @@ export interface DrawKindValue { } export const DRAW_KIND_LABELS: Record = { - free: "자유", + free: "자유지정", + both: "길이·기울기 고정", length: "길이 고정", slope: "기울기 고정", - both: "길이·기울기 고정", }; export interface DrawKindFields { @@ -118,8 +119,10 @@ export function createDrawKindFields(opts: { function fieldsState(): void { for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled; - lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope"; - slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length"; + lengthField.input.disabled = slopeField.input.disabled = disabled; + fields.hidden = value.kind === "free"; + lengthField.root.hidden = value.kind === "slope"; + slopeColumn.hidden = value.kind === "length"; } degText(); fieldsState(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 0681865f2..de6d27bda 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -101,7 +101,7 @@ export interface PlanMapLayer { base?: boolean; draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean; - hover?: (px: number, py: number, map: ScreenMap) => void; + hover?: (px: number, py: number, map: ScreenMap, shift: boolean) => void; release?: (map: ScreenMap) => void; /** 「연도 마커」를 끌고 지나감(참 = 받을 겹) · 놓음(참 = 받음) — 585-3. */ dragOver?: (px: number, py: number, map: ScreenMap) => boolean; @@ -210,6 +210,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Enter = 긋기 끝", "Esc = 긋기 취소", "Backspace = 마지막 점 빼기", + "Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정", + "Shift 누른 채(길이·기울기 고정) = 부채꼴 사이 기울기 자리 · 길이 그대로", "[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝", "누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘", "Shift+누름 = 선 더함 · 뺌", @@ -458,7 +460,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { perfTime("plan:pointermove", () => { const [px, py] = at(event); const map = screenMap(); - for (const layer of layers) layer.hover?.(px, py, map); + for (const layer of layers) layer.hover?.(px, py, map, event.shiftKey); drawTop(); }); }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 2ca1ca5ff..5694f0d85 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -192,6 +192,10 @@ align-items: start; } +.d03-plan__draw-fields[hidden] { + display: none; +} + .d03-plan__draw-fields .ui-field { margin: 0; min-width: 0; From 77a769776847929786e8f1042d45676d920b5a99 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:16:48 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix(B04):=20=EC=83=88=20=EA=B4=80=20?= =?UTF-8?q?=EA=B8=B0=EB=B3=B8=EA=B0=92=20=E2=80=94=20=EC=9C=A0=EC=9E=85=20?= =?UTF-8?q?=EC=A7=91=EC=88=98=EC=A0=95=EC=9D=B4=EB=A9=B4=20=EC=9C=A0?= =?UTF-8?q?=EC=9E=85=20=ED=9D=99=EB=A7=89=EC=9D=B4=20=EC=B9=B8=20=EC=95=88?= =?UTF-8?q?=20=EC=B1=84=EC=9B=80=20=C2=B7=20=EB=8F=99=EB=AC=BC=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=ED=98=95=20=EC=9C=A0=20=C2=B7=20=EA=B4=80=EA=B2=BD=20?= =?UTF-8?q?=EC=88=98=20(PLAN=20623=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20E?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `_apply_new_defaults` — 집수정 갈래면 `inlet_revet_*` 기본값 · 높이 안 채움 · `inlet_basin_animal` "true" · 등록부 관경 글 "1000" → 수 - 새 자동 관이 폼을 처음 거쳐도 파일이 안 바뀜(620 증상) · 옛 프로젝트 이주 없음 - 시험 — 새 `test_623_inlet_defaults`(폼 왕복 차 0 · `helper_623_inlet_defaults.cjs`) · 기대값 고침 `test_b05_new_structure_defaults` · `test_211_1`(기슭막이 관으로) · `test_b05_drainage_recommend`(관경 수) Co-Authored-By: Claude Opus 5.5 --- .../B04_PreProcess_Router_Basins.py | 19 ++- .../tester/helper_623_inlet_defaults.cjs | 134 ++++++++++++++++++ .../tester/test_211_1_inlet_revet_height.py | 7 +- resources/tester/test_623_inlet_defaults.py | 59 ++++++++ .../tester/test_b05_drainage_recommend.py | 3 +- .../tester/test_b05_new_structure_defaults.py | 20 ++- 6 files changed, 226 insertions(+), 16 deletions(-) create mode 100644 resources/tester/helper_623_inlet_defaults.cjs create mode 100644 resources/tester/test_623_inlet_defaults.py diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 24928ccb5..799ccb1bd 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -257,21 +257,26 @@ def _apply_new_defaults( if definition is None: continue options = dict(point.options or {}) + given = set(options) for field in definition.options: if field.key not in options and field.default not in (None, ""): options[field.key] = field.default - # 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`) - if options.get("inlet_type") == "집수정": + if "pipe_diameter_mm" not in given and isinstance(options.get("pipe_diameter_mm"), str): + options["pipe_diameter_mm"] = int(_float(options["pipe_diameter_mm"])) # 폼 = 수(623) + # 집수정 = 모양 L(54-39 · `DEFAULT_BASIN_SHAPE`) · 동물이동형 유 · 유입 흙막이 없음(623) + basin = options.get("inlet_type") == "집수정" + if basin: options.setdefault("inlet_structure", "L") - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`) + options.setdefault("inlet_basin_animal", "true") + for key in [k for k in options if k.startswith("inlet_revet_") and k not in given]: + del options[key] + # 보호공 · 집수정 높이 = 관경 + 0.5(139-1 · `PIPE_HEIGHT_KEYS`) · 유입 흙막이 + 0.7(211-1) keys = {field.key for field in definition.options} diameter_m = _float(options.get("pipe_diameter_mm")) / 1000 if diameter_m > 0: for key in PIPE_HEIGHT_KEYS: - if key in keys: - side = ( - "inlet" if key == "inlet_revet_height_m" else "" - ) # 유입 흙막이 + 0.7(211-1) + if key in keys and not (basin and key.startswith("inlet_revet_")): + side = "inlet" if key == "inlet_revet_height_m" else "" options.setdefault(key, pipe_wall_min_height_m(diameter_m, side)) if point.facility == "ford_bridge" and "ford_height_m" not in options: flow = (flows or {}).get(round(point.chainage_m, 2)) diff --git a/resources/tester/helper_623_inlet_defaults.cjs b/resources/tester/helper_623_inlet_defaults.cjs new file mode 100644 index 000000000..df063c226 --- /dev/null +++ b/resources/tester/helper_623_inlet_defaults.cjs @@ -0,0 +1,134 @@ +/* 623 헬퍼 — 서버가 새로 만든 관 옵션(`_apply_new_defaults`)을 공용 명세 폼 `write → read` 에 돌리고 + * 같은 종류 병합(`mergeFacilityOptions`)까지 — 폼을 처음 거칠 때 파일이 바뀌는 칸(지워짐 · 더함 · 바뀜). + * 입력 = 표준입력 JSON(옵션 목록) · 출력 = 표준출력 JSON 목록 · 짝 = test_623_inlet_defaults.py. + * 가짜 DOM 틀 = helper_173_7_inlet_animal.cjs. */ +const fs = require("fs"); +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); +const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1) + +const ROOT = path.join(__dirname, "..", ".."); + +class FakeNode { + constructor(tag) { + this.tagName = tag.toUpperCase(); + this.children = []; + this.dataset = {}; + this.style = {}; + this._classes = new Set(); + this._on = {}; + this._text = ""; + this.value = ""; + const self = this; + this.classList = { + add: (...n) => n.forEach((c) => self._classes.add(c)), + remove: (...n) => n.forEach((c) => self._classes.delete(c)), + toggle: (c, f) => + (f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c), + contains: (c) => self._classes.has(c), + }; + } + set className(v) { + this._classes = new Set(String(v).split(/\s+/).filter(Boolean)); + } + set textContent(v) { + this._text = String(v); + this.children = []; + } + get textContent() { + return this._text + this.children.map((c) => c.textContent ?? "").join(""); + } + append(...kids) { + kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k)); + } + replaceChildren(...kids) { + this.children = []; + this.append(...kids); + } + add(kid) { + this.children.push(kid); + } + setAttribute(n, v) { + this[n] = v; + } + addEventListener(type, fn) { + (this._on[type] ??= []).push(fn); + } + fire(type) { + (this._on[type] ?? []).forEach((fn) => fn({ target: this })); + } + closest() { + return null; + } + matches(sel) { + return sel.split(",").some((s) => { + s = s.trim(); + return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase(); + }); + } + querySelectorAll(sel) { + const found = []; + const walk = (n) => + (n.children ?? []).forEach((k) => { + if (k.matches?.(sel)) found.push(k); + walk(k); + }); + walk(this); + return found; + } + querySelector(sel) { + return this.querySelectorAll(sel)[0] ?? null; + } +} +globalThis.document = { createElement: (t) => new FakeNode(t) }; +globalThis.queueMicrotask = (fn) => fn(); +globalThis.Option = function (text, value) { + const o = new FakeNode("option"); + o.textContent = text; + o.value = value; + return o; +}; + +const STUBS = { + "@ui/ui_template_elements": { showToast: () => {} }, + "@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 }, + "./ui_template_checkbox.css": {}, + "../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null }, +}; + +const { loadTs } = makeLoader({ + root: ROOT, + requireFor: (full) => (request) => { + if (CATALOG.isCatalogRequest(request)) return CATALOG; + if (STUBS[request]) return STUBS[request]; + if (request.startsWith("@util/")) + return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`)); + return loadTs(path.join(path.dirname(full), `${request}.ts`)); + }, +}); + +const { createSpecForm } = loadTs( + path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"), +); +const { PIPE_SPEC } = loadTs( + path.join(ROOT, "A00_Common", "design", "logic", "design_structure_pipe.ts"), +); +const { mergeFacilityOptions } = loadTs( + path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Drainage_Facility_Merge.ts"), +); + +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); +const inputs = JSON.parse(fs.readFileSync(0, "utf-8")); +const out = inputs.map((options) => { + const form = createSpecForm(null, {}); + form.write(PIPE_SPEC, options); + const merged = + mergeFacilityOptions({ facility: "pipe", options }, { facility: "pipe", options: form.read() }) + .options ?? {}; + return { + removed: Object.keys(options).filter((k) => !(k in merged)), + added: Object.keys(merged).filter((k) => !(k in options)), + changed: Object.keys(options).filter((k) => k in merged && !same(options[k], merged[k])), + }; +}); +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_211_1_inlet_revet_height.py b/resources/tester/test_211_1_inlet_revet_height.py index f7813439f..572b9008e 100644 --- a/resources/tester/test_211_1_inlet_revet_height.py +++ b/resources/tester/test_211_1_inlet_revet_height.py @@ -47,7 +47,12 @@ def test_파이썬_기본_높이_유입만_0_7(): def test_새_관_기본값_유입_흙막이만_0_7(): - fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200}) + # 유입 기슭막이 관만 유입 흙막이 칸을 받음 — 기본 집수정이면 안 채움(623) + fresh = PipePoint( + chainage_m=10.0, + source="stream", + options={"pipe_diameter_mm": 1200, "inlet_type": "기슭막이"}, + ) basins._apply_new_defaults([fresh]) assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9) assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7) diff --git a/resources/tester/test_623_inlet_defaults.py b/resources/tester/test_623_inlet_defaults.py new file mode 100644 index 000000000..14b3d4b7b --- /dev/null +++ b/resources/tester/test_623_inlet_defaults.py @@ -0,0 +1,59 @@ +"""623 새 관 기본값 — 유입 집수정이면 유입 흙막이 칸 안 채움 · 동물이동형 유(요구 530 E1 · 분석 620). + +못박는 것 + - 서버가 새로 만든 관(`_apply_new_defaults`) = 집수정 갈래 · `inlet_revet_*` 없음 · `inlet_basin_animal` "true". + - 그 옵션을 공용 명세 폼 `write → read` + 병합(`mergeFacilityOptions`)에 돌리면 차이 0 + (지워짐 0 · 더함 0 · 바뀜 0) — 폼을 처음 거쳐도 파일이 안 바뀜(`helper_623_inlet_defaults.cjs`). + - 유입 기슭막이로 받은 관 · 이미 적힌 유입 흙막이 칸은 그대로. +""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from B04_PreProcess import B04_PreProcess_Router_Basins as basins +from common_util.common_util_drainage_pipes import PipePoint + +HERE = Path(__file__).parent +ROOT = HERE.parents[1] + + +def _fresh(**options) -> dict: + pipe = PipePoint(chainage_m=10.0, source="stream", options=options or None) + basins._apply_new_defaults([pipe]) + return pipe.options + + +def test_집수정이면_유입_흙막이_없음_동물이동형_유(): + for options in (_fresh(), _fresh(pipe_diameter_mm=800)): + assert options["inlet_type"] == "집수정" + assert not [k for k in options if k.startswith("inlet_revet_")] + assert options["inlet_basin_animal"] == "true" and options["inlet_structure"] == "L" + assert _fresh()["pipe_diameter_mm"] == 1000 # 등록부 글 "1000" → 수(폼과 같음) + + +def test_기슭막이_관과_적힌_칸은_그대로(): + soil = _fresh(inlet_type="기슭막이") + assert soil["inlet_revet_form"] == "돌쌓기(찰)" and soil["inlet_revet_height_m"] == 1.7 + assert "inlet_basin_animal" not in soil + kept = _fresh(inlet_revet_form="돌쌓기(메)", inlet_basin_animal="false") + assert kept["inlet_revet_form"] == "돌쌓기(메)" and kept["inlet_basin_animal"] == "false" + + +def test_서버가_만든_관_폼_왕복_차이_0(): + if not (ROOT / "config" / "node_modules").is_dir(): + pytest.skip("의존성 미설치") + inputs = [_fresh(), _fresh(pipe_diameter_mm=800), _fresh(pipe_diameter_mm=1200)] + done = subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(HERE / "helper_623_inlet_defaults.cjs")], + input=json.dumps(inputs, ensure_ascii=False), + capture_output=True, + text=True, + encoding="utf-8", + ) + assert done.returncode == 0, done.stderr + assert json.loads(done.stdout) == [{"removed": [], "added": [], "changed": []}] * 3 diff --git a/resources/tester/test_b05_drainage_recommend.py b/resources/tester/test_b05_drainage_recommend.py index f145898a0..7ab3a7bac 100644 --- a/resources/tester/test_b05_drainage_recommend.py +++ b/resources/tester/test_b05_drainage_recommend.py @@ -99,7 +99,8 @@ def test_new_pipe_gets_master_default_not_recommendation(monkeypatch, facility, """새로 놓인 관 = 배관 · 마스터 기본 관경 — 추천(관경 · BOX암거 · 세월교)은 안 얹는다.""" carried = _run_build(monkeypatch, _basin(100.0, facility, diameter), None) assert carried[0].facility == PIPE_FACILITY_PIPE - assert carried[0].options["pipe_diameter_mm"] == _master_pipe_diameter() + # 등록부 글 "1000" → 수로 적힘(623 · 폼 왕복과 같음) + assert carried[0].options["pipe_diameter_mm"] == int(_master_pipe_diameter()) def test_existing_auto_pipe_is_untouched(monkeypatch): diff --git a/resources/tester/test_b05_new_structure_defaults.py b/resources/tester/test_b05_new_structure_defaults.py index b6177eddf..d471a9987 100644 --- a/resources/tester/test_b05_new_structure_defaults.py +++ b/resources/tester/test_b05_new_structure_defaults.py @@ -30,13 +30,19 @@ def _defaults(type_id: str) -> dict: } +def _basin_defaults() -> dict: + """새 관(유입 집수정) — 유입 흙막이 칸 없음 · 모양 L · 동물이동형 유 · 관경 수(623).""" + kept = {k: v for k, v in _defaults("pipe").items() if not k.startswith("inlet_revet_")} + return {**kept, "pipe_diameter_mm": 1000, "inlet_structure": "L", "inlet_basin_animal": "true"} + + def test_새_관만_기본값_빈_키에만(): fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800}) basins._apply_new_defaults([fresh]) # 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39) - # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5} - expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights} + # 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 집수정이면 유입 흙막이 없음(623) + heights = {"outlet_revet_height_m": 1.3, "inlet_basin_height_m": 1.3} + expected = {**_basin_defaults(), "pipe_diameter_mm": 800, **heights} assert fresh.options == expected assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정" box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert") @@ -65,10 +71,10 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch): _, points = basins._build("p", Ctx(), [old]) by = {p.chainage_m: p for p in points} assert by[100.0].options is None # 옛 빈칸 그대로 - # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) - # 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1) - heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7} - assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights} + # 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) · 동물이동형 유 · 유입 흙막이 없음(623) + # 기본 관경 1000 → 1.5(139-1) + heights = {"outlet_revet_height_m": 1.5, "inlet_basin_height_m": 1.5} + assert by[300.0].options == {**_basin_defaults(), **heights} def test_월류_폭_기본값은_등록부_엔진과_같음(): From b96479fd7eba3b716f1b3d7a4aef7180b00e74a6 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:19:01 +0900 Subject: [PATCH 3/3] =?UTF-8?q?refactor(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EA=B2=B9=20=EB=AA=A9=EB=A1=9D=20=C2=B7=20GL=20?= =?UTF-8?q?=EA=B2=B9=20=EA=BC=B4=20=C2=B7=20=EA=B2=B9=20=EC=A4=84=20=C2=B7?= =?UTF-8?q?=20=EB=B2=94=EB=A1=80=20=C2=B7=20=EC=82=B0=EB=A6=BC=20=EC=A1=B0?= =?UTF-8?q?=EA=B0=81=20=EA=B2=B9=EC=9D=84=20=EC=83=88=20=EA=B3=B5=EC=9A=A9?= =?UTF-8?q?=20=ED=8F=B4=EB=8D=94=20A00=5FCommon/map=20=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=E2=80=94=20D03=20=EC=9D=80=20=EB=B6=80=EB=A5=B4=EA=B8=B0?= =?UTF-8?q?=EB=A7=8C=20=C2=B7=20=EA=B2=89=EB=AA=A8=EC=8A=B5=20=C2=B7=20?= =?UTF-8?q?=EB=8F=99=EC=9E=91=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=20624-?= =?UTF-8?q?2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=95=20A=E2=91=A7)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - map_layer_list = 겹 스물셋 · 처음 값 · 페이지 표(d03 · b05 보일 겹 · 처음 켬 · 취향 열쇠 — A② · A⑨) - map_layer_specs = GL 겹 꼴 · 차례(D03 UI_MapGl 에서 옮김 · 선 겹 꼴은 LINE_LAYERS 한 벌) - map_layer_bar = 겹 단추 줄 · 켬끔 · 취향 · 그래픽 카드 단추 / map_legend = 범례 칸 · 조각 겹 범례 - map_forest_layers = D03_UI_ForestLayers 옮김(이름만) - D03 UI_Layers 683 → 639 줄 · UI_MapGl 423 → 115 줄 · UI_LayerDefs 는 캔버스 도움 + 옛 이름 다시 내놓음 - 시험 길 고침(553-4 · 581-2 · 584-1 · 584-3 · 606-1 · 615 · 622-2) — 옮긴 파일 자리 · 553-4 겹 목록은 622-2 때 낡은 것(국유림 임도 · 경제림 · 생태자연도 빠짐) 같이 고침 Co-Authored-By: Claude Opus 5.5 --- .../map/map_forest_layers.ts | 14 +- A00_Common/map/map_layer_bar.ts | 96 +++++ A00_Common/map/map_layer_list.ts | 221 ++++++++++++ A00_Common/map/map_layer_specs.ts | 317 ++++++++++++++++ A00_Common/map/map_legend.ts | 36 ++ .../D03_PlanAnalysis_UI_LayerDefs.ts | 199 +--------- .../D03_PlanAnalysis_UI_LayerLegend.ts | 15 +- .../D03_PlanAnalysis_UI_Layers.ts | 110 ++---- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 340 +----------------- resources/tester/test_553_4_de_parts.py | 12 +- resources/tester/test_581_2_plan_soils.py | 2 +- resources/tester/test_584_1_plan_geo_lines.py | 2 +- .../test_584_3_plan_forest_road_zones.py | 2 +- resources/tester/test_606_1_sat_light.py | 2 +- resources/tester/test_615_contour_tiles.py | 3 +- resources/tester/test_622_2_forest_layers.py | 7 +- 16 files changed, 763 insertions(+), 615 deletions(-) rename D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts => A00_Common/map/map_forest_layers.ts (93%) create mode 100644 A00_Common/map/map_layer_bar.ts create mode 100644 A00_Common/map/map_layer_list.ts create mode 100644 A00_Common/map/map_layer_specs.ts create mode 100644 A00_Common/map/map_legend.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts b/A00_Common/map/map_forest_layers.ts similarity index 93% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts rename to A00_Common/map/map_forest_layers.ts index c7aa20c51..271194d9a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_ForestLayers.ts +++ b/A00_Common/map/map_forest_layers.ts @@ -1,24 +1,24 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_ForestLayers.ts - * D03 받은 산림 · 환경 자료 겹(PLAN 622-2 · 요구 529) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). + * map_forest_layers.ts + * 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). * 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 · * 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) / * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로). * 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임. * 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것. - * 부르는 곳 = `D03_PlanAnalysis_UI_MapGl`(꼴 · 소스) · `UI_Layers`(받기 거름) · `UI_LayerLegend`(범례). + * 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례). * ========================================================================== */ import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl"; import type { LegendItem } from "@ui/ui_template_flow_legend"; import { tileSource, type GlSpec } from "@ui/ui_template_gl_map"; import { themeColor } from "@ui/ui_template_palette"; -import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; +import type { MapLayerKey } from "./map_layer_list"; /** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */ const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const; export type TileOnlyKey = (typeof TILE_ONLY)[number]; -export const tileOnly = (key: PlanLayerKey): key is TileOnlyKey => +export const tileOnly = (key: MapLayerKey): key is TileOnlyKey => (TILE_ONLY as readonly string[]).includes(key); /** 받는 동안 서클에 세는 조각 소스(등고선 포함). */ @@ -89,7 +89,7 @@ const ROUND = { "line-join": "round", "line-cap": "round" } as const; const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification => ["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification; -type Spec = GlSpec; +type Spec = GlSpec; const raster = (key: "landslide" | "ecology", opacity: number): Spec => ({ key, @@ -172,7 +172,7 @@ export function forestRoadSpecs(): Spec[] { } /** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */ -export function forestLegendRows(on: Record): LegendItem[][] { +export function forestLegendRows(on: Record): LegendItem[][] { const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({ label, color: `var(${color})`, diff --git a/A00_Common/map/map_layer_bar.ts b/A00_Common/map/map_layer_bar.ts new file mode 100644 index 000000000..b0c2f9db9 --- /dev/null +++ b/A00_Common/map/map_layer_bar.ts @@ -0,0 +1,96 @@ +/* ============================================================================= + * map_layer_bar.ts + * 공용 지도 겹 줄(PLAN 569-2 · 586-2 · 624-2 · 요구 530) — 겹 단추 한 줄(공용 지도 도구줄 `createMapTools` · 묶음 사이 나눔선) + + * 끝에 그래픽 카드 단추(GL ↔ 옛 캔버스). + * 보일 겹 · 처음 켬 · 취향 열쇠 = 페이지 표(`map_layer_list` `MAP_LAYER_PAGES`) · 누르면 켬끔 뒤집고 취향에 적고 `onToggle`. + * 막힘 = 지형을 아직 안 받음(`sync({ ready: false })`) · 조각만 쓰는 겹은 옛 캔버스에서 「안 보임」 글. + * 받기 · 그리기 · 범례는 페이지 몫 — 켬끔 값(`on`)은 같은 객체를 같이 씀. + * 쓰는 법 = `createMapLayerBar({ page: "d03", glOn, onToggle, onGl })` → `{ root, bar, on, toggle, save, sync }`. + * ========================================================================== */ + +import { createIconButton } from "@ui/ui_template_button"; +import { createMapTools } from "@ui/ui_template_map_tools"; +import { writeByKey } from "../b_page_state"; +import { tileOnly } from "./map_forest_layers"; +import { + MAP_LAYER_PAGES, + pageGroups, + readLayerPref, + type MapLayerKey, + type MapPage, +} from "./map_layer_list"; + +export interface MapLayerBar { + /** 도구줄 감싼 것(자리는 페이지 CSS). */ + root: HTMLElement; + /** 도구줄 자체(폭 재기). */ + bar: HTMLElement; + /** 켬끔 — 페이지가 바꾸면 `save` · 받기 실패로 끄는 것은 안 적음. */ + on: Record; + /** 뒤집고 적고 `onToggle`. */ + toggle: (key: MapLayerKey) => void; + save: () => void; + /** 단추 눌림 · 막힘 · 글을 켬끔 값에 맞춤. */ + sync: (state: { ready: boolean; glOn: boolean }) => void; +} + +export function createMapLayerBar(opts: { + page: MapPage; + glOn: boolean; + onToggle: (key: MapLayerKey) => void; + /** 그래픽 카드 단추 누름. */ + onGl: () => void; +}): MapLayerBar { + const { page } = opts; + const on = readLayerPref(page); + const save = (): void => writeByKey(MAP_LAYER_PAGES[page].pref, JSON.stringify(on)); + const toggle = (key: MapLayerKey): void => { + on[key] = !on[key]; + save(); + opts.onToggle(key); + }; + const defs = pageGroups(page); + const buttons = new Map(); + const groups = defs.map((group) => + group.map((def) => { + const button = createIconButton({ + icon: def.icon, + title: def.title, + size: 32, + pressed: on[def.key], + onClick: () => toggle(def.key), + }); + button.dataset.layer = def.key; + buttons.set(def.key, button); + return button; + }), + ); + const glButton = createIconButton({ + icon: "gauge", + title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", + size: 32, + pressed: opts.glOn, + onClick: opts.onGl, + }); + glButton.dataset.layer = "gl"; + groups.push([glButton]); + const tools = createMapTools({ label: "지도 겹", groups }); + + function sync({ ready, glOn }: { ready: boolean; glOn: boolean }): void { + for (const def of defs.flat()) { + const button = buttons.get(def.key)!; + button.setAttribute("aria-pressed", String(on[def.key])); + button.disabled = !ready; + const title = !ready + ? `${def.title} — 지형 받기 뒤 보임` + : tileOnly(def.key) && !glOn + ? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임` + : def.title; + button.title = title; + button.setAttribute("aria-label", title); + } + glButton.setAttribute("aria-pressed", String(glOn)); + } + + return { root: tools.root, bar: tools.bar, on, toggle, save, sync }; +} diff --git a/A00_Common/map/map_layer_list.ts b/A00_Common/map/map_layer_list.ts new file mode 100644 index 000000000..a2e24f9e0 --- /dev/null +++ b/A00_Common/map/map_layer_list.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * map_layer_list.ts + * 공용 지도 겹 목록(PLAN 624-2 · 요구 530 · 결정 A② · A⑧ · A⑨) — 예정노선(D03) · B05 평면이 같이 씀. + * 겹 = 열쇠 · 아이콘 · 이름 · 처음 값(`on` = 예정노선) · 단추 묶음(바탕 · 물 · 길 · 구역 · 사이 나눔선). + * 페이지 표(`MAP_LAYER_PAGES`) = 보일 겹(숨길 겹) · 처음 켬(없으면 겹의 `on`) · 켬끔 취향 열쇠. + * 선 겹 꼴 · 보호구역 색 · 산사태위험 범례 = GL 꼴(`map_layer_specs`) · 범례(`map_legend`) · 옛 캔버스가 같이 읽음. + * 쓰는 법 = 겹 줄 `createMapLayerBar({ page })`(`map_layer_bar`) · 새 겹 = `GROUPS` 한 줄 + 꼴 한 벌. + * ========================================================================== */ + +import type { IconName } from "@ui/ui_template_icons"; +import type { LegendItem } from "@ui/ui_template_flow_legend"; +import { readByKey } from "../b_page_state"; + +export type MapLayerKey = + | "satellite" + | "hillshade" + | "contour" + | "slope" + | "stream" + | "water" + | "road" + | "forestRoad" + | "forestRoadFile" + | "nationalRoad" + | "ecoForest" + | "ecology" + | "cadastral" + | "zones" + | "soils" + | "landslide" + | "landslideZones" + | "faults" + | "lineaments" + | "zeroOrder" + | "bands" + | "building" + | "place"; + +/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */ +export interface RoadPick { + bundle: string; + line?: number; +} + +export interface LayerDef { + key: MapLayerKey; + icon: IconName; + title: string; + /** 예정노선 처음 값 — 다른 페이지는 `MAP_LAYER_PAGES[page].on`. */ + on: boolean; +} + +/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */ +export const GROUPS: readonly (readonly LayerDef[])[] = [ + [ + { key: "satellite", icon: "satellite", title: "위성사진", on: true }, + { key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false }, + { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, + { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, + ], + [ + { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, + { key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false }, + { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, + { + key: "forestRoad", + icon: "route", + title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)", + on: false, + }, + { + key: "forestRoadFile", + icon: "map", + title: + "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", + on: false, + }, + { + key: "nationalRoad", + icon: "flag", + title: + "국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)", + on: false, + }, + ], + [ + { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, + { + key: "zones", + icon: "shield", + title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)", + on: false, + }, + { + key: "soils", + icon: "leaf", + title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)", + on: false, + }, + { + key: "ecoForest", + icon: "trees", + title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)", + on: false, + }, + { + key: "ecology", + icon: "plant", + title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)", + on: false, + }, + { + key: "landslide", + icon: "alert", + title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)", + on: false, + }, + { + key: "landslideZones", + icon: "alertOctagon", + title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)", + on: false, + }, + { key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false }, + { + key: "lineaments", + icon: "lineDashed", + title: "지질구조선(브이월드 지질 · 참고용)", + on: false, + }, + { + key: "zeroOrder", + icon: "arrowsJoin", + title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)", + on: false, + }, + { + key: "bands", + icon: "borderOuter", + title: "계류 100 m · 도로 300 m 띠(타당성평가)", + on: false, + }, + { key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false }, + { key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false }, + ], +]; +export const LAYERS: readonly LayerDef[] = GROUPS.flat(); + +/** 지도가 있는 페이지. */ +export type MapPage = "d03" | "b05"; + +/** 페이지 표 — 숨길 겹 · 처음 켬(없음 = 겹의 `on`) · 켬끔 취향 열쇠. */ +export const MAP_LAYER_PAGES: Record< + MapPage, + { hide: readonly MapLayerKey[]; on?: readonly MapLayerKey[]; pref: string } +> = { + d03: { hide: [], pref: "aislo:pref:d03.layers" }, + // A② 처음 = 지금 그대로(등고선 · 하천) · A⑨ 띠(타당성) · 올린 임도망도 숨김 + b05: { + hide: ["bands", "forestRoadFile"], + on: ["contour", "stream"], + pref: "aislo:pref:b05.layers", + }, +}; + +/** 그 페이지에 보일 겹 묶음(빈 묶음 뺌). */ +export const pageGroups = (page: MapPage): LayerDef[][] => + GROUPS.map((group) => + group.filter((def) => !MAP_LAYER_PAGES[page].hide.includes(def.key)), + ).filter((group) => group.length); + +/** 켬끔 취향 — 없거나 깨지면 페이지 처음 값 · 숨긴 겹 = 끔. */ +export function readLayerPref(page: MapPage): Record { + const { hide, on: first, pref } = MAP_LAYER_PAGES[page]; + const on = Object.fromEntries( + LAYERS.map((def) => [def.key, first ? first.includes(def.key) : def.on]), + ) as Record; + try { + const saved = JSON.parse(readByKey(pref) ?? "{}") as Partial>; + for (const def of LAYERS) + if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean; + } catch { + // 깨진 값 = 처음 값 + } + for (const key of hide) on[key] = false; + return on; +} + +/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */ +export const ZONE_COLORS: Record = { + forest: "--draw-map-zone-forest", + baekdu: "--draw-map-zone-baekdu", + water: "--draw-map-zone-water", + park: "--draw-map-zone-park", + heritage: "--draw-map-zone-heritage", + wetland: "--draw-map-zone-wetland", + wildlife: "--draw-map-zone-wildlife", + greenbelt: "--draw-map-zone-greenbelt", +}; +/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ +export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ + label: `산사태 ${grade}등급`, + color: `var(--draw-map-landslide-grade-${grade})`, +})); +/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ +export type LineLayerKey = "faults" | "lineaments" | "zeroOrder"; +export const LINE_LAYERS: Record< + LineLayerKey, + { label: string; kind?: string; color: string; width: number; dash: number[] } +> = { + faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] }, + lineaments: { + label: "지질구조선", + kind: "lineaments", + color: "--draw-map-lineament", + width: 1.8, + dash: [8, 4], + }, + zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] }, +}; +export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[]; diff --git a/A00_Common/map/map_layer_specs.ts b/A00_Common/map/map_layer_specs.ts new file mode 100644 index 000000000..de0589b5e --- /dev/null +++ b/A00_Common/map/map_layer_specs.ts @@ -0,0 +1,317 @@ +/* ============================================================================= + * map_layer_specs.ts + * 공용 지도 GL 겹 꼴(PLAN 586-2 · 624-2 · 요구 530) — 바탕 겹 스물의 MapLibre 꼴 · 차례(아래 → 위): + * 위성 · 그림 겹(음영 · 급경사 · 산사태위험 · 생태자연도) · 면 겹(산림입지 · 경제림 · 보호구역 · 띠 · 물 면 · 건물) · + * 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 국유림 임도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) · + * 점 · 글(산사태취약 · 지명 · 등고 수치). + * 자료 = 받는 쪽이 지물에 붙인 `_color` · 고정 소스(위성 · 등고 · 산림 조각) 이름 = `map_forest_layers` · `ui_template_gl_map`. + * 카메라 · 지도 세우기는 페이지 몫(D03 `UI_MapGl.createGlBase` · B05 `createTmGl`) — 여기는 꼴만. + * ========================================================================== */ + +import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl"; +import { GL_FONT, type GlSpec as GlMapSpec } from "@ui/ui_template_gl_map"; +import { themeColor } from "@ui/ui_template_palette"; +import { forestAreaSpecs, forestRasterSpecs, forestRoadSpecs } from "./map_forest_layers"; +import { + LINE_KEYS, + LINE_LAYERS, + type LineLayerKey, + type MapLayerKey, + type RoadPick, +} from "./map_layer_list"; + +/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ +export type GlSource = Exclude; + +const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; + +type GlSpec = GlMapSpec; + +const HALO = "rgba(0, 0, 0, 0.55)"; +const ROUND = { "line-join": "round", "line-cap": "round" } as const; +const COLOR: ExpressionSpecification = ["get", "_color"]; +/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */ +const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification; +/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ +const dash = (px: number[], width: number): number[] => px.map((v) => v / width); + +/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ +function haloLine( + key: MapLayerKey, + source: GlSource, + color: string, + width: number, + pattern: number[] = [], + halo = { color: HALO, width: width + 1.6 }, + filter?: FilterSpecification, +): GlSpec[] { + const line = (id: string, paint: Record, w: number): GlSpec => + ({ + key, + id, + type: "line", + source, + ...(filter ? { filter } : {}), + layout: ROUND, + paint: { + ...paint, + "line-width": w, + ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), + }, + }) as GlSpec; + return [ + line(`${key}-halo`, { "line-color": halo.color }, halo.width), + line(key, { "line-color": color }, width), + ]; +} + +/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ +function fillEdge( + key: MapLayerKey, + source: GlSource, + fill: string | ExpressionSpecification, + opacity: number | ExpressionSpecification, + edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, +): GlSpec[] { + return [ + { + key, + id: key, + type: "fill", + source, + paint: { "fill-color": fill, "fill-opacity": opacity }, + } as GlSpec, + { + key, + id: `${key}-edge`, + type: "line", + source, + layout: ROUND, + paint: { + "line-color": edge.color, + "line-width": edge.width, + "line-opacity": 0.9, + ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), + }, + } as GlSpec, + ]; +} + +/** 겹 차례(아래 → 위)와 꼴 — 옛 캔버스(D03 `UI_LayerPaint`)의 색 · 굵기 · 투명도 · 차례 그대로 · `light` = 위성 밝기 상한. */ +export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] { + const contour = themeColor("--draw-map-contour", "#ffd68c"); + const place = themeColor("--draw-map-place", "#f8fafc"); + const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); + const raster = ( + key: "satellite" | "hillshade" | "slope", + opacity: number, + extra: Record = {}, + ): GlSpec => + ({ + key, + id: key, + type: "raster", + source: key, + paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra }, + }) as GlSpec; + const lineKind = (key: LineLayerKey): GlSpec[] => { + const { color, width, dash } = LINE_LAYERS[key]; + return haloLine(key, key, themeColor(color, "#f43f5e"), width, dash); + }; + return [ + // 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절 + raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }), + raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) + raster("slope", 0.55), + ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) + ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 + ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), + ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { + color: COLOR, + width: 1.2, + dash: [6, 4], + }), + { + key: "cadastral", + id: "cadastral", + type: "line", + source: "cadastral", + layout: ROUND, + paint: { + "line-color": themeColor("--draw-map-parcel", "#f0abfc"), + "line-width": 0.8, + "line-opacity": 0.8, + }, + } as GlSpec, + ...((): GlSpec[] => { + const water = themeColor("--draw-map-water", "#0ea5e9"); + return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); + })(), + ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { + color: HALO, + width: 0.6, + }), + { + key: "contour", + id: "contour-minor", + type: "line", + source: "contour", + "source-layer": "contour", + layout: ROUND, + paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, + } as GlSpec, + { + key: "contour", + id: "contour-index", + type: "line", + source: "contour", + "source-layer": "contour", + filter: BOLD, + layout: ROUND, + paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, + } as GlSpec, + { + key: "contour", + id: "contour-label", + type: "symbol", + source: "contour", + "source-layer": "contour", + filter: BOLD, + layout: { + "symbol-placement": "line", + "symbol-spacing": 320, + "text-field": ["to-string", ["get", "h"]], + "text-font": [GL_FONT], + "text-size": 10, + "text-max-angle": 30, + "text-padding": 2, + }, + paint: { + "text-color": contour, + "text-halo-color": "rgba(0, 0, 0, 0.6)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { + color: HALO, + width: 3.2, + }), + ...haloLine( + "forestRoad", + "road", + themeColor("--draw-map-forest-road", "#f59e0b"), + 2, + [7, 4], + { color: HALO, width: 3.6 }, + [ + "in", + ["get", "도로구분"], + ["literal", ["소로", "미분류"]], + ] as unknown as FilterSpecification, + ), + ...haloLine( + "forestRoadFile", + "forestRoadFile", + themeColor("--draw-map-forest-road-file", "#ff2fd0"), + 2.4, + [], + { color: HALO, width: 4 }, + ), + ...forestRoadSpecs(), // 국유림 임도 = 벡터 조각 + // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 + ...["-pick-halo", "-pick"].map( + (tail, i) => + ({ + key: "forestRoadFile", + id: `forestRoadFile${tail}`, + type: "line", + source: "forestRoadFile", + filter: pickFilter(pick), + layout: ROUND, + paint: { + "line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO, + "line-width": i ? 4.2 : 7.4, + }, + }) as GlSpec, + ), + { + key: "stream", + id: "stream", + type: "line", + source: "stream", + layout: ROUND, + paint: { + "line-color": themeColor("--draw-map-stream", "#38bdf8"), + "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], + }, + } as GlSpec, + ...LINE_KEYS.flatMap(lineKind), + // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 + ...haloLine( + "landslideZones", + "landslideZones", + landslideZone, + 1.8, + [6, 3], + { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, + ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, + ), + { + key: "landslideZones", + id: "landslideZones-point", + type: "circle", + source: "landslideZones", + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-radius": 5, + "circle-color": landslideZone, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1.5, + }, + } as GlSpec, + { + key: "place", + id: "place-dot", + type: "circle", + source: "place", + paint: { "circle-radius": 2.5, "circle-color": place }, + } as GlSpec, + { + key: "place", + id: "place", + type: "symbol", + source: "place", + layout: { + "text-field": ["get", "name"], + "text-font": GL_FONT_BOLD, + "text-size": 11, + "text-anchor": "bottom", + "text-offset": [0, -0.36], + "text-padding": 2, + }, + paint: { + "text-color": place, + "text-halo-color": "rgba(0, 0, 0, 0.7)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ]; +} + +/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */ +export function pickFilter(pick: RoadPick | null): FilterSpecification { + const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]]; + if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]); + return all as unknown as FilterSpecification; +} + +/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ +export const MAP_THEME_NAMES = [ + "--draw-map-contour", + "--draw-map-road", + "--draw-map-stream", + "--draw-map-parcel", +] as const; + +/** 위성 채도(−1~1) — 조금 연하게. */ +const SAT_SATURATION = -0.3; diff --git a/A00_Common/map/map_legend.ts b/A00_Common/map/map_legend.ts new file mode 100644 index 000000000..e2dd3cc67 --- /dev/null +++ b/A00_Common/map/map_legend.ts @@ -0,0 +1,36 @@ +/* ============================================================================= + * map_legend.ts + * 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄. + * 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`). + * 받은 자료 범례(수 · 넓이 · 급경사 범위)는 받는 페이지 몫 — 줄을 만들어 `show` 에 넘김. + * 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김. + * ========================================================================== */ + +import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend"; +import { forestLegendRows } from "./map_forest_layers"; +import { LANDSLIDE_LEGEND, type MapLayerKey } from "./map_layer_list"; + +export interface MapLegend { + root: HTMLElement; + show: (rows: LegendItem[][], tail?: HTMLElement[]) => void; +} + +/** 켠 조각 겹 범례 — 산사태위험 다음 산림 셋. */ +export const tileLegendRows = (on: Record): LegendItem[][] => [ + ...(on.landslide ? [LANDSLIDE_LEGEND] : []), + ...forestLegendRows(on), +]; + +/** `className` = 자리 · 꼴(페이지 CSS). */ +export function createMapLegend(className: string): MapLegend { + const root = document.createElement("div"); + root.className = className; + root.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; + return { + root, + show(rows, tail = []) { + root.replaceChildren(...rows.map((row) => createLegend(row)), ...tail); + root.hidden = !rows.length && !tail.length; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index b23ea6a82..c8bc37042 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,14 +1,12 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 · - * 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · - * GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * D03 바탕 겹 받기 · 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2) — 임도 짐작 구분 · 물 면 · + * 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * 겹 목록 · 처음 값 · 켬끔 취향 · 선 겹 꼴 · 보호구역 색 = 공용 `A00_Common/map/map_layer_list`(624-2). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ -import type { IconName } from "@ui/ui_template_icons"; -import type { LegendItem } from "@ui/ui_template_flow_legend"; -import { readByKey } from "../A00_Common/b_page_state"; +import type { MapLayerKey } from "../A00_Common/map/map_layer_list"; import { drawPreparedLayer, normalizedToScreen, @@ -20,172 +18,15 @@ import type { PlanRaster } from "./D03_PlanAnalysis_Api"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -export type PlanLayerKey = - | "satellite" - | "hillshade" - | "contour" - | "slope" - | "stream" - | "water" - | "road" - | "forestRoad" - | "forestRoadFile" - | "nationalRoad" - | "ecoForest" - | "ecology" - | "cadastral" - | "zones" - | "soils" - | "landslide" - | "landslideZones" - | "faults" - | "lineaments" - | "zeroOrder" - | "bands" - | "building" - | "place"; +/** 겹 목록 · 페이지 표 = 공용 `map_layer_list`(624-2) — 옛 이름으로 다시 내놓음(캔버스 · 조각 · 임도망 목록이 읽음). */ +export type PlanLayerKey = MapLayerKey; +export { + LINE_KEYS, + LINE_LAYERS, + ZONE_COLORS, + type RoadPick, +} from "../A00_Common/map/map_layer_list"; -/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */ -export interface RoadPick { - bundle: string; - line?: number; -} - -export interface LayerDef { - key: PlanLayerKey; - icon: IconName; - title: string; - on: boolean; -} - -/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */ -export const GROUPS: readonly (readonly LayerDef[])[] = [ - [ - { key: "satellite", icon: "satellite", title: "위성사진", on: true }, - { key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false }, - { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, - { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, - ], - [ - { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, - { key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false }, - { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, - { - key: "forestRoad", - icon: "route", - title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)", - on: false, - }, - { - key: "forestRoadFile", - icon: "map", - title: - "올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)", - on: false, - }, - { - key: "nationalRoad", - icon: "flag", - title: - "국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)", - on: false, - }, - ], - [ - { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, - { - key: "zones", - icon: "shield", - title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)", - on: false, - }, - { - key: "soils", - icon: "leaf", - title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)", - on: false, - }, - { - key: "ecoForest", - icon: "trees", - title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)", - on: false, - }, - { - key: "ecology", - icon: "plant", - title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)", - on: false, - }, - { - key: "landslide", - icon: "alert", - title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)", - on: false, - }, - { - key: "landslideZones", - icon: "alertOctagon", - title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)", - on: false, - }, - { key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false }, - { - key: "lineaments", - icon: "lineDashed", - title: "지질구조선(브이월드 지질 · 참고용)", - on: false, - }, - { - key: "zeroOrder", - icon: "arrowsJoin", - title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)", - on: false, - }, - { - key: "bands", - icon: "borderOuter", - title: "계류 100 m · 도로 300 m 띠(타당성평가)", - on: false, - }, - { key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false }, - { key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false }, - ], -]; -export const LAYERS: readonly LayerDef[] = GROUPS.flat(); -/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */ -export const ZONE_COLORS: Record = { - forest: "--draw-map-zone-forest", - baekdu: "--draw-map-zone-baekdu", - water: "--draw-map-zone-water", - park: "--draw-map-zone-park", - heritage: "--draw-map-zone-heritage", - wetland: "--draw-map-zone-wetland", - wildlife: "--draw-map-zone-wildlife", - greenbelt: "--draw-map-zone-greenbelt", -}; -/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */ -export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({ - label: `산사태 ${grade}등급`, - color: `var(--draw-map-landslide-grade-${grade})`, -})); -/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */ -export type LineLayerKey = "faults" | "lineaments" | "zeroOrder"; -export const LINE_LAYERS: Record< - LineLayerKey, - { label: string; kind?: string; color: string; width: number; dash: number[] } -> = { - faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] }, - lineaments: { - label: "지질구조선", - kind: "lineaments", - color: "--draw-map-lineament", - width: 1.8, - dash: [8, 4], - }, - zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] }, -}; -export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[]; /** 도로 중 기존 임도로 짐작하는 구분. */ export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]); /** 물 면 = 도엽 수계(E) 면 셋. */ @@ -283,22 +124,6 @@ export function drawRaster( context.drawImage(raster.image, 0, 0); } -/** 켬끔 취향(`key`) — 없거나 깨지면 처음 값. */ -export function readPref(key: string): Record { - const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record< - PlanLayerKey, - boolean - >; - try { - const saved = JSON.parse(readByKey(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; -} - /** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ export function glTagged( collection: GeoJsonCollection, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index e05c6eb77..27658bfda 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -2,6 +2,7 @@ * D03_PlanAnalysis_UI_LayerLegend.ts * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). + * 범례 칸 · 조각 겹 범례 = 공용 `A00_Common/map/map_legend`(624-2) · 여기는 받은 자료 줄만. * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ @@ -16,15 +17,14 @@ import type { PlanZones, } from "./D03_PlanAnalysis_Api"; import { - LANDSLIDE_LEGEND, LINE_KEYS, LINE_LAYERS, ZONE_COLORS, - hectare, type LineLayerKey, - type PlanLayerKey, -} from "./D03_PlanAnalysis_UI_LayerDefs"; -import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers"; + type MapLayerKey, +} from "../A00_Common/map/map_layer_list"; +import { tileLegendRows } from "../A00_Common/map/map_legend"; +import { hectare } from "./D03_PlanAnalysis_UI_LayerDefs"; import { SLOPE_MAX_PCT, bandRatios, @@ -111,13 +111,12 @@ function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] { } export function legendRows( - on: Record, + on: Record, data: Partial, ): LegendItem[][] { const rows: LegendItem[][] = []; if (on.slope && data.slope) rows.push(slopeRow(data.slope)); - if (on.landslide) rows.push(LANDSLIDE_LEGEND); // GL = 조각(받기 없음 · 622-2) - rows.push(...forestLegendRows(on)); + rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { const items = Object.entries(data.zones.summary) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 26718c2fa..476535e1b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts - * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 - * (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). + * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 624-2 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 + * (공용 겹 줄 `A00_Common/map/map_layer_bar` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) · @@ -9,28 +9,29 @@ * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) · * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). - * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. + * 겹 목록 · 처음 켬(위성 · 등고선 · 세류) · 취향(`aislo:pref:d03.layers`) = 공용 `map_layer_list` 페이지 표 「d03」. * 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 · * 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 · * 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`). * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. - * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · 칸 = 공용 `map_legend` · 줄 = `D03_PlanAnalysis_UI_LayerLegend`). * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). * 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 · - * 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = `D03_PlanAnalysis_UI_ForestLayers`. + * 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = 공용 `A00_Common/map/map_forest_layers`. * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; -import { createLegend } from "@ui/ui_template_flow_legend"; -import { createIconButton } from "@ui/ui_template_button"; -import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { soilColor, tileOnly, type TileOnlyKey } from "../A00_Common/map/map_forest_layers"; +import { createMapLayerBar } from "../A00_Common/map/map_layer_bar"; +import { LAYERS, LINE_LAYERS, ZONE_COLORS, type RoadPick } from "../A00_Common/map/map_layer_list"; +import { createMapLegend } from "../A00_Common/map/map_legend"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, @@ -57,23 +58,16 @@ import { } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, - GROUPS, - LAYERS, - LINE_LAYERS, WATER_KEYS, - ZONE_COLORS, glImage, glTagged, loadImage, - readPref, shadeImage, type PlanLayerKey, - type RoadPick, } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -import { soilColor, tileOnly, type TileOnlyKey } from "./D03_PlanAnalysis_UI_ForestLayers"; import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; import { createSheetTiles, @@ -90,7 +84,6 @@ import { } from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; -const PREF_KEY = "aislo:pref:d03.layers"; /** 옛 캔버스로 그림("1") — 처음 = GL. */ const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ @@ -137,7 +130,6 @@ export function createPlanLayers(opts: { track: (job: Promise) => Promise; }): PlanLayers { const { projectId, map, track } = opts; - const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; @@ -152,6 +144,22 @@ export function createPlanLayers(opts: { useGl(false); }); let glOn = readByKey(CANVAS_PREF_KEY) !== "1"; + /** 겹 줄 · 켬끔 · 취향 = 공용 겹 목록(624-2 · 페이지 표 「d03」). */ + const layerBar = createMapLayerBar({ + page: "d03", + glOn, + onToggle: (key) => { + void ensure(key); + sync(); + map.redrawBase(); + }, + onGl: () => { + writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); + useGl(!glOn); + if (!glOn) showToast(NO_SATELLITE_NOTE, "info"); + }, + }); + const { on, toggle } = layerBar; /** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */ const tiles = createSheetTiles({ projectId, @@ -166,78 +174,34 @@ export function createPlanLayers(opts: { }, }); - const buttons = new Map(); - const groups = GROUPS.map((group) => - group.map((def) => { - const button = createIconButton({ - icon: def.icon, - title: def.title, - size: 32, - pressed: on[def.key], - onClick: () => toggle(def.key), - }); - button.dataset.layer = def.key; - buttons.set(def.key, button); - return button; - }), - ); - const glButton = createIconButton({ - icon: "gauge", - title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", - size: 32, - pressed: glOn, - onClick: () => { - writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); - useGl(!glOn); - if (!glOn) showToast(NO_SATELLITE_NOTE, "info"); - }, - }); - glButton.dataset.layer = "gl"; /** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */ function useGl(want: boolean): void { glOn = want && map.setGl(gl); if (!glOn) map.setGl(null); - glButton.setAttribute("aria-pressed", String(glOn)); if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2) sync(); map.redrawBase(); } - groups.push([glButton]); - const tools = createMapTools({ label: "지도 겹", groups }); - const bar = tools.root; + const bar = layerBar.root; bar.classList.add("d03-plan__layers"); // 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`) new ResizeObserver(() => - map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`), - ).observe(tools.bar); - const legend = document.createElement("div"); - legend.className = "d03-plan__legend"; - legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; + map.root.style.setProperty("--d03-layers-w", `${layerBar.bar.offsetWidth}px`), + ).observe(layerBar.bar); + const legendBox = createMapLegend("d03-plan__legend"); + const legend = legendBox.root; /** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */ const loadedOf = (key: PlanLayerKey): boolean => tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)]; 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} — 지형 받기 뒤 보임` - : tileOnly(def.key) && !glOn - ? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임` - : def.title; - button.title = title; - button.setAttribute("aria-label", title); - } + layerBar.sync({ ready: !!meta, glOn }); for (const def of LAYERS) gl.setVisible(def.key, on[def.key]); gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); - const rows = meta ? legendRows(on, data) : []; const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음 - legend.replaceChildren(...rows.map((row) => createLegend(row)), ...(light ? [lightRow] : [])); + legendBox.show(meta ? legendRows(on, data) : [], light ? [lightRow] : []); if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); - legend.hidden = !rows.length && !light; } /** 위성 밝기(요구 475) — 위성 켠 동안 범례 맨 아래 한 줄 · 바로 반영. */ @@ -279,14 +243,6 @@ export function createPlanLayers(opts: { sync(); useGl(glOn); - function toggle(key: PlanLayerKey): void { - on[key] = !on[key]; - writeByKey(PREF_KEY, JSON.stringify(on)); - void ensure(key); - sync(); - map.redrawBase(); - } - /** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */ function ensure(key: PlanLayerKey): Promise { if (!meta || !on[key] || loadedOf(key)) return Promise.resolve(); @@ -366,7 +322,7 @@ export function createPlanLayers(opts: { gl.setRoadPick(null); } on.forestRoadFile = !!info; - writeByKey(PREF_KEY, JSON.stringify(on)); + layerBar.save(); } async function deleteRoad(bundle: string): Promise { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index d460e3e67..1c501c919 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -1,47 +1,37 @@ /* ============================================================================= * D03_PlanAnalysis_UI_MapGl.ts - * D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로: - * 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) · - * 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · - * 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) · - * 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — - * 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음). + * D03 GL 바탕(PLAN 586-1 · 586-2 · 624-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로. + * 겹 꼴 · 차례 = 공용 `A00_Common/map/map_layer_specs`(624-2 · B05 평면과 같은 꼴) · 여기는 D03 지도 세우기만: + * 고정 소스(위성 조각 z6~19 · 등고선 615 줌별 벡터 조각 · 산림 조각 다섯 622-2) · 받는 동안 서클 · 임도망 고름 · 위성 밝기. + * 글 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로. * 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침). * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. - * 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클. - * 받은 산림 · 환경 자료(622-2) = 같은 꼴 고정 소스 다섯(`D03_PlanAnalysis_UI_ForestLayers`) · 받는 동안 서클. * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. - * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만. + * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2). * ========================================================================== */ -import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl"; +import type { Map as MapLibreMap } from "maplibre-gl"; import { contourTileSource, createGlMap, GL_FONT, satelliteTileSource, type GlImage, - type GlSpec as GlMapSpec, } from "@ui/ui_template_gl_map"; -import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { forestSources, TRACKED_SOURCES } from "../A00_Common/map/map_forest_layers"; import { - forestAreaSpecs, - forestRasterSpecs, - forestRoadSpecs, - forestSources, - TRACKED_SOURCES, -} from "./D03_PlanAnalysis_UI_ForestLayers"; + MAP_THEME_NAMES, + mapLayerSpecs, + pickFilter, + type GlSource, +} from "../A00_Common/map/map_layer_specs"; +import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; -export { GL_FONT, type GlImage }; +export type { GlImage }; -/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ -export type GlSource = Exclude; - -const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; /** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ export const glFont = (weight: string, text: string): Promise => document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null); @@ -62,304 +52,6 @@ export interface GlBase { resize: () => void; } -type GlSpec = GlMapSpec; - -const HALO = "rgba(0, 0, 0, 0.55)"; -const ROUND = { "line-join": "round", "line-cap": "round" } as const; -const COLOR: ExpressionSpecification = ["get", "_color"]; -/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */ -const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification; -/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ -const dash = (px: number[], width: number): number[] => px.map((v) => v / width); - -/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ -function haloLine( - key: PlanLayerKey, - source: GlSource, - color: string, - width: number, - pattern: number[] = [], - halo = { color: HALO, width: width + 1.6 }, - filter?: FilterSpecification, -): GlSpec[] { - const line = (id: string, paint: Record, w: number): GlSpec => - ({ - key, - id, - type: "line", - source, - ...(filter ? { filter } : {}), - layout: ROUND, - paint: { - ...paint, - "line-width": w, - ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), - }, - }) as GlSpec; - return [ - line(`${key}-halo`, { "line-color": halo.color }, halo.width), - line(key, { "line-color": color }, width), - ]; -} - -/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ -function fillEdge( - key: PlanLayerKey, - source: GlSource, - fill: string | ExpressionSpecification, - opacity: number | ExpressionSpecification, - edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, -): GlSpec[] { - return [ - { - key, - id: key, - type: "fill", - source, - paint: { "fill-color": fill, "fill-opacity": opacity }, - } as GlSpec, - { - key, - id: `${key}-edge`, - type: "line", - source, - layout: ROUND, - paint: { - "line-color": edge.color, - "line-width": edge.width, - "line-opacity": 0.9, - ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), - }, - } as GlSpec, - ]; -} - -/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ -function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] { - const contour = themeColor("--draw-map-contour", "#ffd68c"); - const place = themeColor("--draw-map-place", "#f8fafc"); - const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); - const raster = ( - key: "satellite" | "hillshade" | "slope", - opacity: number, - extra: Record = {}, - ): GlSpec => - ({ - key, - id: key, - type: "raster", - source: key, - paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra }, - }) as GlSpec; - const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) => - haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern); - return [ - // 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절 - raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }), - raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) - raster("slope", 0.55), - ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) - ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 - ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), - ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { - color: COLOR, - width: 1.2, - dash: [6, 4], - }), - { - key: "cadastral", - id: "cadastral", - type: "line", - source: "cadastral", - layout: ROUND, - paint: { - "line-color": themeColor("--draw-map-parcel", "#f0abfc"), - "line-width": 0.8, - "line-opacity": 0.8, - }, - } as GlSpec, - ...((): GlSpec[] => { - const water = themeColor("--draw-map-water", "#0ea5e9"); - return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); - })(), - ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { - color: HALO, - width: 0.6, - }), - { - key: "contour", - id: "contour-minor", - type: "line", - source: "contour", - "source-layer": "contour", - layout: ROUND, - paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, - } as GlSpec, - { - key: "contour", - id: "contour-index", - type: "line", - source: "contour", - "source-layer": "contour", - filter: BOLD, - layout: ROUND, - paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, - } as GlSpec, - { - key: "contour", - id: "contour-label", - type: "symbol", - source: "contour", - "source-layer": "contour", - filter: BOLD, - layout: { - "symbol-placement": "line", - "symbol-spacing": 320, - "text-field": ["to-string", ["get", "h"]], - "text-font": [GL_FONT], - "text-size": 10, - "text-max-angle": 30, - "text-padding": 2, - }, - paint: { - "text-color": contour, - "text-halo-color": "rgba(0, 0, 0, 0.6)", - "text-halo-width": 1.5, - }, - } as GlSpec, - ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { - color: HALO, - width: 3.2, - }), - ...haloLine( - "forestRoad", - "road", - themeColor("--draw-map-forest-road", "#f59e0b"), - 2, - [7, 4], - { color: HALO, width: 3.6 }, - [ - "in", - ["get", "도로구분"], - ["literal", ["소로", "미분류"]], - ] as unknown as FilterSpecification, - ), - ...haloLine( - "forestRoadFile", - "forestRoadFile", - themeColor("--draw-map-forest-road-file", "#ff2fd0"), - 2.4, - [], - { color: HALO, width: 4 }, - ), - ...forestRoadSpecs(), // 국유림 임도 = 벡터 조각 - // 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게 - ...["-pick-halo", "-pick"].map( - (tail, i) => - ({ - key: "forestRoadFile", - id: `forestRoadFile${tail}`, - type: "line", - source: "forestRoadFile", - filter: pickFilter(pick), - layout: ROUND, - paint: { - "line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO, - "line-width": i ? 4.2 : 7.4, - }, - }) as GlSpec, - ), - { - key: "stream", - id: "stream", - type: "line", - source: "stream", - layout: ROUND, - paint: { - "line-color": themeColor("--draw-map-stream", "#38bdf8"), - "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], - }, - } as GlSpec, - ...lineKind("faults", 2.4, []), - ...lineKind("lineaments", 1.8, [8, 4]), - ...lineKind("zeroOrder", 1.8, [4, 3]), - // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 - ...haloLine( - "landslideZones", - "landslideZones", - landslideZone, - 1.8, - [6, 3], - { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, - ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, - ), - { - key: "landslideZones", - id: "landslideZones-point", - type: "circle", - source: "landslideZones", - filter: ["==", ["geometry-type"], "Point"], - paint: { - "circle-radius": 5, - "circle-color": landslideZone, - "circle-stroke-color": "#ffffff", - "circle-stroke-width": 1.5, - }, - } as GlSpec, - { - key: "place", - id: "place-dot", - type: "circle", - source: "place", - paint: { "circle-radius": 2.5, "circle-color": place }, - } as GlSpec, - { - key: "place", - id: "place", - type: "symbol", - source: "place", - layout: { - "text-field": ["get", "name"], - "text-font": GL_FONT_BOLD, - "text-size": 11, - "text-anchor": "bottom", - "text-offset": [0, -0.36], - "text-padding": 2, - }, - paint: { - "text-color": place, - "text-halo-color": "rgba(0, 0, 0, 0.7)", - "text-halo-width": 1.5, - }, - } as GlSpec, - ]; -} - -/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */ -function pickFilter(pick: RoadPick | null): FilterSpecification { - const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]]; - if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]); - return all as unknown as FilterSpecification; -} - -/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */ -const LINE_COLOR = { - faults: "--draw-map-fault", - lineaments: "--draw-map-lineament", - zeroOrder: "--draw-map-zero-order", -} as const; - -/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ -const THEME_NAMES = [ - "--draw-map-contour", - "--draw-map-road", - "--draw-map-stream", - "--draw-map-parcel", -] as const; - -/** 위성 채도(−1~1) — 조금 연하게. */ -const SAT_SATURATION = -0.3; - /** `track` = 받는 일을 진행 서클에 셈(등고 · 산림 조각 받는 동안). */ export function createGlBase( projectId: string, @@ -369,13 +61,13 @@ export function createGlBase( let light = SAT_LIGHT_DEFAULT; let pick: RoadPick | null = null; const base = createGlMap({ - specs: () => layerSpecs(light, pick), + specs: () => mapLayerSpecs(light, pick), fixed: { satellite: satelliteTileSource(projectId), contour: contourTileSource(projectId), ...forestSources(projectId), }, - themeNames: THEME_NAMES, + themeNames: MAP_THEME_NAMES, className: "d03-plan__gl", onFail, }); diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index a5a393273..e884b07f9 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -58,10 +58,11 @@ def test_d03_parts(): paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔 assert "map.stations" in paint and "map.slopes" in paint # 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억 - defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts") - assert "createMapTools({" in layers and "createIconButton({" in layers - assert "createToolbar(" not in layers - assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers + defs = _src("A00_Common/map/map_layer_list.ts") # 624-2 공용 겹 목록 + bar = _src("A00_Common/map/map_layer_bar.ts") + assert "createMapTools({" in bar and "createIconButton({" in bar and "createMapLayerBar({" in layers + assert "createToolbar(" not in layers and "createToolbar(" not in bar + assert '"aislo:pref:d03.layers"' in defs and "writeByKey(MAP_LAYER_PAGES[page].pref" in bar keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs) assert keys == [ ("satellite", "true"), @@ -73,9 +74,12 @@ def test_d03_parts(): ("road", "false"), ("forestRoad", "false"), ("forestRoadFile", "false"), + ("nationalRoad", "false"), ("cadastral", "false"), ("zones", "false"), ("soils", "false"), + ("ecoForest", "false"), + ("ecology", "false"), ("landslide", "false"), ("landslideZones", "false"), ("faults", "false"), diff --git a/resources/tester/test_581_2_plan_soils.py b/resources/tester/test_581_2_plan_soils.py index b8d798427..1026d760b 100644 --- a/resources/tester/test_581_2_plan_soils.py +++ b/resources/tester/test_581_2_plan_soils.py @@ -85,7 +85,7 @@ def test_soils_fail_raises(tmp_path, monkeypatch) -> None: def test_screen_def_and_colors() -> None: - defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 entry = re.search(r'key: "soils",\s*icon: "(\w+)",[^}]*on: (\w+)', defs) assert entry and entry.group(2) == "false" # 처음 값 끔 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") diff --git a/resources/tester/test_584_1_plan_geo_lines.py b/resources/tester/test_584_1_plan_geo_lines.py index bb9ae2cf1..dd80691f4 100644 --- a/resources/tester/test_584_1_plan_geo_lines.py +++ b/resources/tester/test_584_1_plan_geo_lines.py @@ -191,7 +191,7 @@ def test_zero_order_no_dem(tmp_path) -> None: def test_screen_defs_icons_colors() -> None: - defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") for key, color in ( diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 9526c0e00..7586f991c 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -324,7 +324,7 @@ def test_zone_layer_saved_and_no_cadastral(tmp_path, monkeypatch) -> None: def test_screen_defs() -> None: folder = ROOT / "D03_PlanAnalysis" - defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") + defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2 icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( diff --git a/resources/tester/test_606_1_sat_light.py b/resources/tester/test_606_1_sat_light.py index 6263c3cc9..c4f0de6e5 100644 --- a/resources/tester/test_606_1_sat_light.py +++ b/resources/tester/test_606_1_sat_light.py @@ -9,7 +9,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 -MAPGL = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts") +MAPGL = read("A00_Common/map/map_layer_specs.ts") # 624-2 GL 겹 꼴 = 공용 LIGHT = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts") LAYERS = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") diff --git a/resources/tester/test_615_contour_tiles.py b/resources/tester/test_615_contour_tiles.py index 6ad44dae9..c4af9c1e7 100644 --- a/resources/tester/test_615_contour_tiles.py +++ b/resources/tester/test_615_contour_tiles.py @@ -94,4 +94,5 @@ def test_screen_uses_fixed_vector_source() -> None: assert "/plan/contour/{z}/{x}/{y}.pbf" in gl base = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_MapGl.ts").read_text(encoding="utf-8") assert "contour: contourTileSource(projectId)" in base - assert base.count('"source-layer": "contour"') == 3 + specs = (ROOT / "A00_Common" / "map" / "map_layer_specs.ts").read_text(encoding="utf-8") + assert specs.count('"source-layer": "contour"') == 3 # 624-2 GL 겹 꼴 = 공용 diff --git a/resources/tester/test_622_2_forest_layers.py b/resources/tester/test_622_2_forest_layers.py index 7b89f5d95..d758fa61b 100644 --- a/resources/tester/test_622_2_forest_layers.py +++ b/resources/tester/test_622_2_forest_layers.py @@ -8,6 +8,7 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[2] D03 = ROOT / "D03_PlanAnalysis" +MAP = ROOT / "A00_Common" / "map" # 624-2 공용 지도 부품 def _read(path: Path) -> str: @@ -18,7 +19,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None: gl = _read(ROOT / "ui_template" / "ui_template_gl_map.ts") assert "export function tileSource" in gl assert "/plan/${path}/{z}/{x}/{y}.${ext}" in gl - forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + forest = _read(MAP / "map_forest_layers.ts") for name in ("forest_road", "forest_soil", "eco_forest"): assert f'vt("{name}")' in forest assert f'...vector("{name}")' in forest or f'"source-layer": "{name}"' in forest @@ -33,7 +34,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None: def test_layer_list_five_off_by_default() -> None: - defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts") + defs = _read(MAP / "map_layer_list.ts") icons = _read(ROOT / "ui_template" / "ui_template_icons.ts") for key in ("nationalRoad", "soils", "ecoForest", "ecology", "landslide"): entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs) @@ -43,7 +44,7 @@ def test_layer_list_five_off_by_default() -> None: def test_colors_are_draw_map_tokens() -> None: theme = _read(ROOT / "ui_template" / "ui_template_theme.css") - forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts") + forest = _read(MAP / "map_forest_layers.ts") for token in set(re.findall(r'"(--draw-map-[\w-]+)"', forest)): assert f"{token}:" in theme, token assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7))