From 7ae441be67d89dda5c2ceb92ca1e0768ad1a0cbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:40:29 +0900 Subject: [PATCH 01/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B1=B0=EB=A6=AC=EC=9E=AC=EA=B8=B0=20=EC=B2=AB?= =?UTF-8?q?=20=EC=A0=90=EC=9D=B4=20=EB=85=B8=EC=84=A0=20=EC=9C=84=EB=A9=B4?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=94=B0=EB=9D=BC=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20(PLAN=20600-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 순수 셈 logic/D03_PlanAnalysis_Measure — 같은 선 누가 차 · 갈래 고리 따라 올라가 만남 · 이어지지 않으면 곧은 거리 - 재기 겹 — 첫 누름 선 8 px · 꺾임점 12 px 안이면 노선 따라 · 굵게 그림 · 글 「노선 따라 L m · 측점 · Δh」 - Page.ts 한 줄(노선 넘김) · 새 시험 test_600_2_plan_measure Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_Measure.ts | 86 ++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 5 +- .../logic/D03_PlanAnalysis_Measure.ts | 97 ++++++++++++ resources/tester/test_600_2_plan_measure.py | 138 ++++++++++++++++++ 4 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts create mode 100644 resources/tester/test_600_2_plan_measure.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts index 0102c9d31..fc656d204 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -2,40 +2,72 @@ * D03_PlanAnalysis_UI_Measure.ts * D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m). * 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`). + * 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라 + * (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로. * ========================================================================== */ import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + nearestOnRoute, + routeSpan, + type RoutePos, + type RouteSpan, +} from "./logic/D03_PlanAnalysis_Measure"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; export interface PlanMeasure { layer: PlanMapLayer; /** 지움(Esc · 끔). */ clear: () => void; - /** 검증 창구 — 잰 값(두 점이 다 있으면). */ - result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null; + /** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */ + result: () => { + lengthM: number; + slopePct: number | null; + riseM: number | null; + alongRoute: boolean; + } | null; } const fmt = (value: number, digits = 1): string => value.toFixed(digits); const signed = (value: number, digits = 1): string => `${value >= 0 ? "+" : ""}${fmt(value, digits)}`; -export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure { +export function createPlanMeasure( + heightAt: () => (x: number, y: number) => number, + network: () => PlanNetwork, +): PlanMeasure { let from: Vertex | null = null; let to: Vertex | null = null; let fixed = false; + /** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */ + let fromPos: RoutePos | null = null; + let span: RouteSpan | null = null; const measure = (a: Vertex, b: Vertex) => { const z = heightAt(); const rise = z(b[0], b[1]) - z(a[0], a[1]); + const lengthM = span ? span.lengthM : segmentLength(a, b); return { - lengthM: segmentLength(a, b), - slopePct: groundSlopePct(a, b, z), + lengthM, + slopePct: span + ? Number.isFinite(rise) && lengthM > 0 + ? (rise / lengthM) * 100 + : null + : groundSlopePct(a, b, z), riseM: Number.isFinite(rise) ? rise : null, + alongRoute: !!span, }; }; + /** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */ + const place = (point: Vertex): void => { + const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null; + span = near && fromPos ? routeSpan(network(), fromPos, near) : null; + to = near && span ? near.point : point; + }; + const layer: PlanMapLayer = { draw(context, map: ScreenMap) { if (!from) return; @@ -50,18 +82,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb context.strokeStyle = colors.casing; context.stroke(); }; + context.lineCap = "round"; + context.lineJoin = "round"; if (to) { const [bx, by] = map.toScreen(to); context.beginPath(); - context.moveTo(ax, ay); - context.lineTo(bx, by); - strokeCased(context, colors.measure, 2.5, [6, 4]); + if (span) { + span.path.forEach((point, i) => { + const [sx, sy] = map.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + strokeCased(context, colors.measure, 4); + } else { + context.moveTo(ax, ay); + context.lineTo(bx, by); + strokeCased(context, colors.measure, 2.5, [6, 4]); + } dot(ax, ay); dot(bx, by); const { lengthM, slopePct, riseM } = measure(from, to); - const text = - `${fmt(lengthM)} m · ` + - (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`); + const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`; + const text = span + ? `노선 따라 ${fmt(lengthM)} m · ` + + (span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") + + rise + : `${fmt(lengthM)} m · ` + + (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`); const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14]; map.upright(context, mx, my, () => { context.font = "12px system-ui, sans-serif"; @@ -81,21 +128,26 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb if (button !== 0) return false; const point = map.toMetric(px, py); if (!from || fixed) { - [from, to, fixed] = [point, null, false]; + // 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리 + const net = network(); + const hit = lineAt(net, px, py, map); + fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null; + [from, to, fixed, span] = [hit ? hit.point : point, null, false, null]; } else { - [to, fixed] = [point, true]; + place(point); + fixed = true; } return true; }, hover(px, py, map) { - if (from && !fixed) to = map.toMetric(px, py); + if (from && !fixed) place(map.toMetric(px, py)); }, }; return { layer, clear: () => { - [from, to, fixed] = [null, null, false]; + [from, to, fixed, fromPos, span] = [null, null, false, null, null]; }, result: () => (from && to && fixed ? measure(from, to) : null), }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a3c8746c4..51d9328c1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -146,7 +146,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { yearStart: () => yearStart, commit, }); - const measure = createPlanMeasure(() => z); + const measure = createPlanMeasure( + () => z, + () => network, + ); const marks = createPlanPoints({ points: () => points, network: () => network, diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts new file mode 100644 index 000000000..1a017648c --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * D03_PlanAnalysis_Measure.ts + * D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지 + * 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지. + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로. + * ========================================================================== */ + +import { + distanceAlong, + lineLength, + pointAtDistance, + segmentLength, + sliceLine, +} from "./D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; + +/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */ +export interface RoutePos { + line: string; + atM: number; +} + +export interface RouteSpan { + lengthM: number; + /** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */ + path: Vertex[]; + /** 같은 선 위일 때만 — 두 누가(m). */ + stations: [number, number] | null; +} + +/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */ +function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] { + const chain: RoutePos[] = [pos]; + const seen = new Set([pos.line]); + let line = network.lines.find((entry) => entry.id === pos.line); + while (line?.branch) { + const parent = network.lines.find((entry) => entry.id === line!.branch!.line); + if (!parent || seen.has(parent.id)) break; + seen.add(parent.id); + chain.push({ + line: parent.id, + atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)), + }); + line = parent; + } + return chain; +} + +/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */ +export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null { + const up = upChain(network, a); + const down = upChain(network, b); + const ia = up.findIndex((step) => down.some((other) => other.line === step.line)); + if (ia < 0) return null; + const ib = down.findIndex((step) => step.line === up[ia].line); + const legs: Array<[string, number, number]> = []; + // a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선 + for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]); + legs.push([up[ia].line, up[ia].atM, down[ib].atM]); + // 만나는 선 → … → b + for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]); + const path: Vertex[] = []; + let lengthM = 0; + for (const [id, from, to] of legs) { + const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? []; + if (vertices.length < 2) continue; + lengthM += Math.abs(to - from); + const piece = sliceLine(vertices, from, to); + if (from > to) piece.reverse(); + for (const point of piece) { + const last = path[path.length - 1]; + if (!last || segmentLength(last, point) > 1e-9) path.push(point); + } + } + const same = ia === 0 && ib === 0; + return { lengthM, path, stations: same ? [a.atM, b.atM] : null }; +} + +/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */ +export function nearestOnRoute( + network: PlanNetwork, + point: Vertex, + from: RoutePos, +): (RoutePos & { point: Vertex }) | null { + let best: (RoutePos & { point: Vertex }) | null = null; + let bestGap = Infinity; + for (const line of network.lines) { + if (line.vertices.length < 2) continue; + const atM = distanceAlong(line.vertices, point); + const gap = segmentLength(pointAtDistance(line.vertices, atM), point); + if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue; + best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) }; + bestGap = gap; + } + return best; +} diff --git a/resources/tester/test_600_2_plan_measure.py b/resources/tester/test_600_2_plan_measure.py new file mode 100644 index 000000000..846b5d58c --- /dev/null +++ b/resources/tester/test_600_2_plan_measure.py @@ -0,0 +1,138 @@ +"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`. + +같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선 +NETWORK = { + "pieces": [], + "lines": [ + {"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None}, + {"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}}, + {"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}}, + {"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None}, + ], +} + +CASES = { + "same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}], + "same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}], + "branch_main_after": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 150}, + ], + "branch_main_before": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 60}, + ], + "twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}], + "twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}], + "apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}], + "near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}], + "near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}], +} + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = {}; +for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("measure") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_같은_선은_누가_차(r): + assert r["same"]["lengthM"] == pytest.approx(110) + assert r["same"]["stations"] == [40, 150] + assert r["same"]["path"][0] == [40, 0] + assert r["same"]["path"][-1] == [100, 50] + assert r["same_back"]["lengthM"] == pytest.approx(110) + assert r["same_back"]["path"][0] == [100, 50] + + +def test_갈래에서_본선으로(r): + """갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70.""" + assert r["branch_main_after"]["lengthM"] == pytest.approx(80) + assert r["branch_main_after"]["stations"] is None + assert r["branch_main_after"]["path"][0] == [130, 0] + assert r["branch_main_before"]["lengthM"] == pytest.approx(70) + + +def test_갈래의_갈래는_고리를_따라_올라감(r): + """b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m.""" + assert r["twig_root"]["lengthM"] == pytest.approx(200) + # b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50 + assert r["twig_twig"]["lengthM"] == pytest.approx(50) + + +def test_이어지지_않은_본선은_길_없음(r): + assert r["apart"] is None + + +def test_가까운_자리는_이어진_선_안에서만(r): + assert r["near_branch"]["line"] == "b1" + assert r["near_branch"]["atM"] == pytest.approx(30) + assert r["near_other_excluded"]["line"] == "main" From 0c463bcc6470fab8bdf3af193d9f626c8c91a36e Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:45:26 +0900 Subject: [PATCH 02/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EC=A7=80=ED=98=95=20=EB=B0=9B=EA=B8=B0=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=E2=80=94=20=EC=A7=80=EC=A0=81=20=EB=BA=8C?= =?UTF-8?q?=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EC=A1=B0=EA=B0=81=EB=A7=8C=20?= =?UTF-8?q?=C2=B7=20=EC=9C=84=EC=B9=98=20=EB=B0=94=EA=BF=88=20=EB=A9=80?= =?UTF-8?q?=EB=8B=A4=20=C2=B7=20=EB=9D=A0=20=C2=B7=20reset=20=C2=B7=20?= =?UTF-8?q?=EC=B9=B8=20=EC=83=81=ED=95=9C=20=C2=B7=20=EB=81=8A=EA=B9=80=20?= =?UTF-8?q?=C2=B7=20=EB=8B=A8=EA=B3=84=20=EA=B8=80=20=C2=B7=20=EC=9E=84?= =?UTF-8?q?=EB=8F=84=EB=A7=9D=20=C2=B15=20km=20(PLAN=20595=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 지적도를 지형 일에서 뺌(겹 GET 이 받음) · 지적 실패 조각만 다시(열쇠 failed_tiles) - 받기 몸 center · reset — 5 km 넘음 far · 다른 TM 띠 band 물음 · reset = 옛 범위 · 병합 도엽 · 위성 버림 - 받을 칸 500 만 넘음 413 · extent max_cells · lon/lat - 진행 기록 파일 — 서버 다시 켜짐 = interrupted(percent < 100 · message 없음) · 받은 적 없음 percent 100 - 진행 단계 글 n/5 · 받은 범위에 epsg · center 기록 - 임도망 자르기 = 기준점 ±5 km ∪ 받은 지형 · 지형 전에도 올림 · bounds · distance_m · 밖뿐 = 거리 글 - 시험 530_1 · 530_2 · 569_1 · 584_3 덧 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 35 +++- .../D03_PlanAnalysis_ForestRoad.py | 90 ++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 28 +-- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 186 +++++++++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py | 102 ++++++---- resources/tester/test_530_1_plan_terrain.py | 39 +++- resources/tester/test_530_2_plan_router.py | 163 ++++++++++++++- resources/tester/test_569_1_plan_layers.py | 26 ++- .../test_584_3_plan_forest_road_zones.py | 27 ++- 9 files changed, 578 insertions(+), 118 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 1e7cb00e4..acaf8b07a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -27,11 +27,26 @@ export interface PlanBaseView { site: { lon: number; lat: number; address: string | null } | null; } +/** 진행(595-3) — 끊김 = running 거짓 · percent < 100 · message 없음 · interrupted(서버가 다시 켜짐) · + * 받은 적 없음 = percent 100 · message = 「n/5 …」 단계 글. */ export interface PlanJob { running: boolean; percent: number; message: string | null; error: string | null; + interrupted?: boolean; +} + +/** 받기 답(595-2) — 시작 · 멀다(받은 범위에서 5 km 넘음) · 다른 TM 띠 — 둘은 확인 뒤 `reset`. */ +export type TerrainStart = + | ({ status: "accepted" } & PlanJob) + | { status: "far"; distance_m: number; message: string } + | { status: "band"; epsg: number; center_epsg: number; message: string }; + +/** 위치 칸 기준점(경위도 · 캐시) · 새로 받기(옛 받은 범위 버림). */ +export interface TerrainAim { + center?: { lon: number; lat: number } | null; + reset?: boolean; } /** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ @@ -45,6 +60,8 @@ export interface TerrainExtent { covered: boolean; sheets: Array<{ no: string; ring: Array<[number, number]>; stored: boolean }>; cells: number; + /** 받기 칸 상한(넘으면 받기 413). */ + max_cells: number; } /** 그은 선 서버 꼴(562-2) — 마크 = 조각 경계 · 조각 없는 구역 = 연도 없음. */ @@ -89,26 +106,33 @@ async function request(path: string, init: RequestInit = {}): Promise { const base = (projectId: string): string => `/projects/${projectId}/plan`; -/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점). */ +/** 받기 전 범위 — `extra` = 더 덮을 점(그은 꺾임점) · `center` = 위치 칸 기준점(둘레 ±1 km). */ export const fetchTerrainExtent = ( projectId: string, marginM: number | null, extra: Vertex[], + center: { lon: number; lat: number } | null = null, ): Promise => { const query = new URLSearchParams(); if (marginM !== null) query.set("margin_m", String(marginM)); if (extra.length) query.set("extra", JSON.stringify(extra)); + if (center) { + query.set("lon", String(center.lon)); + query.set("lat", String(center.lat)); + } return request(`${base(projectId)}/terrain/extent?${query}`); }; +/** 받기 — 칸 상한 넘음 = 오류(413 글). */ export const startTerrain = ( projectId: string, marginM: number | null, extra: Vertex[], -): Promise => + aim: TerrainAim = {}, +): Promise => request(`${base(projectId)}/terrain`, { method: "POST", - body: JSON.stringify({ margin_m: marginM, extra }), + body: JSON.stringify({ margin_m: marginM, extra, ...aim }), }); export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => @@ -206,12 +230,15 @@ export const fetchPlanZeroOrder = (projectId: string): Promise => export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => request(`${base(projectId)}/layers/bands`); -/** 올린 임도망도(584-3) — 받은 범위로 자른 선 + 가닥 · 길이 m · 원본 선 수 · 좌표계 · 올린 파일. */ +/** 올린 임도망도(584-3 · 595-4) — 기준점 ±5 km ∪ 받은 지형으로 자른 선 + 가닥 · 길이 m · 원본 선 수 · + * 좌표계 · 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 올린 파일. */ export interface PlanForestRoads { count: number; length_m: number; total: number; crs: string[]; + bounds: [number, number, number, number] | null; + distance_m: number | null; source: { files: string[]; bytes: number; uploaded_at: string } | null; collection: GeoJsonCollection; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py index 052a24828..d18a2b9bd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_ForestRoad.py @@ -1,12 +1,13 @@ -"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 요구 443). +"""D 노선분석 지도 올린 임도망도 겹(PLAN 584-3 · 595-4 · 요구 443 · 463). - 출처 = 사용자가 받은 산림청 임도망도 SHP(FGIS 무료신청 · 산림빅데이터거래소 · 시군구 단위 · 선) -- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 받은 범위 안 - 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError`) +- 올림 = zip 하나 또는 shp · shx · dbf(· prj · cpg) 묶음 · 합계 `MAX_BYTES` · 읽어 보고 자르는 범위 + 안 선이 있어야 받음(아니면 옛 것 그대로 · 까닭 = `ValueError` + 기준점 ~ 가장 가까운 임도 거리) +- 자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형(`clip_box` · 지형 받기 전에도 기준점만으로) - 원본 = 프로젝트 `D03_PlanAnalysis/임도망도.zip`(낱 파일은 zip 으로 묶음) + `임도망도.json`(파일 이름 · 올린 때) -- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 받은 범위로 잘라 가닥 · 길이 → - 파생 저장본(열쇠 = 원본 지문 · 범위) +- 좌표계 = prj · 없으면 EPSG:5179 · dbf 글자 = cpg · 없으면 cp949 · 자르는 범위로 잘라 가닥 · 길이 · + 잘린 상자(`bounds` 경위도) · 기준점 거리 → 파생 저장본(열쇠 = 원본 지문 · 범위 · 기준점) 계산 자리 = ③ 서버 단독. """ @@ -32,6 +33,7 @@ from common_util.common_util_json import atomic_write_json from common_util.common_util_time import utc_now_text Box = tuple[float, float, float, float] +XY = tuple[float, float] FOLDER = "D03_PlanAnalysis" SOURCE_ZIP = "임도망도.zip" @@ -41,7 +43,17 @@ DBF_ENCODING = "cp949" MAX_BYTES = 100 * 1024 * 1024 SHP_PARTS = (".shp", ".shx", ".dbf", ".prj", ".cpg") ROAD_KIND = "plan_forest_road" -ROAD_VERSION = 1 +ROAD_VERSION = 2 # 잘린 상자 · 기준점 거리 +RADIUS_M = 5000.0 # 기준점 둘레(요구서_463 3장 18) + + +def clip_box(origin: XY, have: Box | None) -> Box: + """자르는 범위 = 기준점 ± `RADIUS_M` ∪ 받은 지형.""" + x, y = origin + box = (x - RADIUS_M, y - RADIUS_M, x + RADIUS_M, y + RADIUS_M) + if have is None: + return box + return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) def source_dir(project_root: Path) -> Path: @@ -100,18 +112,22 @@ def _plain(value: Any) -> Any: return value if isinstance(value, (str, int, float, bool)) or value is None else str(value) -def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: - """SHP 선 → 받은 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · - 좌표계.""" +def clip_roads(zip_path: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """SHP 선 → 범위로 잘라 경위도 선(원 속성 + `length_m`) · 가닥 · 길이 · 원본 선 수 · 좌표계 · + 잘린 상자(`bounds` [서, 남, 동, 북] · 없으면 None) · 기준점 ~ 가장 가까운 임도(`distance_m` · + 자르기 전 모든 선).""" from pyproj import Transformer + from shapely.geometry import Point, mapping from shapely.geometry import box as rect - from shapely.geometry import mapping from shapely.ops import transform frame = rect(*box) + here = Point(origin) if origin else None to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform features: list[dict[str, Any]] = [] total, length, crs = 0, 0.0, [] + near: float | None = None + edges: list[tuple[float, float, float, float]] = [] for shapes in read_shapes(zip_path): crs.append(shapes.crs.to_string()) shapes = shapes[shapes.geometry.notna()] @@ -121,19 +137,18 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: for (_, row), geometry in zip( shapes.drop(columns=shapes.geometry.name).iterrows(), metric.geometry ): + if here is not None: + gap = geometry.distance(here) + near = gap if near is None else min(near, gap) part = geometry.intersection(frame) if part.is_empty or part.length <= 0: continue length += part.length props = {str(k): _plain(v) for k, v in row.items()} props["length_m"] = round(part.length, 1) - features.append( - { - "type": "Feature", - "geometry": mapping(transform(to_ll, part)), - "properties": props, - } - ) + lonlat = transform(to_ll, part) + edges.append(lonlat.bounds) + features.append({"type": "Feature", "geometry": mapping(lonlat), "properties": props}) if not total: raise ValueError("SHP 에 선 지물이 없습니다 — 임도망도(선) 파일인지 보세요.") return { @@ -141,23 +156,34 @@ def clip_roads(zip_path: Path, box: Box, epsg: int) -> dict[str, Any]: "length_m": round(length, 1), "total": total, "crs": sorted(set(crs)), + "bounds": [ + min(e[0] for e in edges), + min(e[1] for e in edges), + max(e[2] for e in edges), + max(e[3] for e in edges), + ] + if edges + else None, + "distance_m": None if near is None else round(near, 1), "collection": {"type": "FeatureCollection", "features": features}, } -def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: - """올린 임도망도 — 원본 지문 · 범위 같으면 저장본 · 올린 적 없으면 `FileNotFoundError`.""" +def road_layer(project_root: Path, box: Box, epsg: int, origin: XY | None = None) -> dict[str, Any]: + """올린 임도망도 — 원본 지문 · 범위 · 기준점 같으면 저장본 · 올린 적 없으면 + `FileNotFoundError`.""" path = source_dir(project_root) / SOURCE_ZIP stamp = file_stamp(path) if stamp is None: raise FileNotFoundError( - "올린 임도망도가 없습니다 — 옆 올리기 단추로 시군구 SHP 를 올리세요." + "올린 임도망도가 없습니다 — [임도망 올리기]로 시군구 SHP 를 올리세요." ) - key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box]) + at = [round(v, 1) for v in origin] if origin else None + key = derived_key(ROAD_KIND, ROAD_VERSION, stamp, int(epsg), [round(v, 1) for v in box], at) saved = read_derived(project_root, ROAD_KIND, key) if saved is not None: return saved["value"] - value = clip_roads(path, box, epsg) + value = clip_roads(path, box, epsg, origin) try: value["source"] = json.loads((source_dir(project_root) / SOURCE_META).read_text("utf-8")) except (OSError, ValueError): @@ -167,10 +193,14 @@ def road_layer(project_root: Path, box: Box, epsg: int) -> dict[str, Any]: def save_roads( - project_root: Path, files: list[tuple[str, bytes]], box: Box, epsg: int + project_root: Path, + files: list[tuple[str, bytes]], + box: Box, + epsg: int, + origin: XY | None = None, ) -> dict[str, Any]: - """올림 — 묶어 읽어 보고 받은 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` - (옛 것 그대로).""" + """올림 — 묶어 읽어 보고 자르는 범위 안 선이 있으면 원본을 바꿈 · 아니면 `ValueError` + (옛 것 그대로 · 기준점 ~ 가장 가까운 임도 거리).""" data = pack(files) folder = source_dir(project_root) folder.mkdir(parents=True, exist_ok=True) @@ -178,11 +208,13 @@ def save_roads( temporary.write(data) trial = Path(temporary.name) try: - got = clip_roads(trial, box, epsg) + got = clip_roads(trial, box, epsg, origin) if not got["count"]: + gap = got["distance_m"] + where = f" — 가장 가까운 임도 {gap / 1000:.1f} km" if gap is not None else "" raise ValueError( - f"받은 범위 안에 임도가 없습니다(파일 선 {got['total']}가닥) — " - "사업지 시군구 파일인지 보세요." + f"임도가 기준점 ±{RADIUS_M / 1000:.0f} km · 받은 지형 밖뿐입니다{where}" + f"(파일 선 {got['total']}가닥) — 사업지 시군구 파일인지 보세요." ) os.replace(trial, folder / SOURCE_ZIP) finally: @@ -195,4 +227,4 @@ def save_roads( "uploaded_at": utc_now_text(), }, ) - return road_layer(project_root, box, epsg) + return road_layer(project_root, box, epsg, origin) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 534b74e5e..1d365152e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -179,31 +179,37 @@ def _covers(have: Any, box: Box) -> bool: def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]: - """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 아니면 상자 통째로 다시 받음.""" + """지적도 — 열쇠 상자가 덮고 실패 조각 없으면 그대로 · 덮는데 실패 조각이 남았으면 그 조각만 + 다시(PLAN 595-1) · 못 덮으면 상자 통째로 다시 받음.""" path, key_path = processed / CADASTRAL_FILE, processed / CADASTRAL_KEY key = _read_json(key_path) or {} - if path.is_file() and not key.get("failed") and _covers(key.get("box"), box): + covered = path.is_file() and _covers(key.get("box"), box) + if covered and not key.get("failed"): return {"path": path, "fetched": False, "count": key.get("count"), "failed": 0} - parts = tiles(box) + retry = key.get("failed_tiles") if covered else None # 옛 열쇠(조각 기록 없음) = 통째로 + old = ((_read_json(path) or {}).get("features") or []) if retry else [] + frame = key["box"] if retry else [float(v) for v in box] + parts = [tuple(t) for t in retry] if retry else tiles(box) with ThreadPoolExecutor(max_workers=WFS_WORKERS) as pool: results = list(pool.map(lambda part: fetch_tile(part, epsg), parts)) - failed = sum(bad for _, bad in results) - if failed == len(parts): - raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {failed}개 실패") - features = dedup_parcels([f for got, _ in results for f in got]) + bad = [list(part) for part, (_, n) in zip(parts, results) if n] + if len(bad) == len(parts) and not old: + raise RuntimeError(f"지적도를 받지 못했습니다 — 브이월드 WFS 조각 {len(bad)}개 실패") + features = dedup_parcels([*old, *(f for got, _ in results for f in got)]) processed.mkdir(parents=True, exist_ok=True) atomic_write_json(path, {"type": "FeatureCollection", "features": features}) atomic_write_json( key_path, { - "box": [float(v) for v in box], + "box": frame, "epsg": int(epsg), "count": len(features), - "failed": failed, + "failed": len(bad), + "failed_tiles": bad, }, ) - logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), failed, len(features)) - return {"path": path, "fetched": True, "count": len(features), "failed": failed} + logger.info("D 지적도 받기: 조각 %d · 실패 %d · 필지 %d", len(parts), len(bad), len(features)) + return {"path": path, "fetched": True, "count": len(features), "failed": len(bad)} # ── 급경사 ────────────────────────────────────────────── diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 6059b375e..3b430b71b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -11,6 +11,10 @@ 범위 = 기준점 둘레(`SITE_RADIUS_M`) + 지점 + 그은 점(`extra` · 지도 보기 귀) 상자 + 여유 ∪ 받은 범위 (PLAN 591-2 · 기준점 = 처음 받는 기준일 뿐). +위치 바꿈(PLAN 595 · 꼴 = PLAN 595 절) = 몸 `center`(위치 칸 캐시 기준점) 둘레를 더함 · +받은 범위에서 `FAR_M` 넘음 = 「far」 물음 · 다른 TM 띠 = 「band」 물음 → `reset` = 옛 범위 버리고 +center 둘레만 · 칸 `MAX_CELLS` 넘음 = 413 · 진행은 `models_plan/plan_job_<일>.json` 에도 적어 +서버가 다시 켜지면 「끊김」(interrupted). 노선 정본(손 그은 선)은 `D03_PlanAnalysis_Network`(PLAN 562-2 · 옛 추천 엔진 · 꺾임점 R 걷음). """ @@ -28,8 +32,9 @@ from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFil from pydantic import BaseModel from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path -from common_util.common_util_crs import lonlat_to_tm +from common_util.common_util_crs import lonlat_to_tm, tm_epsg_for_lon from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_json import atomic_write_json from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road @@ -50,26 +55,53 @@ _TASKS: set[asyncio.Task] = set() KINDS = ("terrain", "analyze") +class Center(BaseModel): + lon: float + lat: float + + class TerrainBody(BaseModel): - """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m).""" + """지형 받기 — 여유(m · 빈 값 = 처음 값) · 더 덮을 점(그은 꺾임점 · 작업 좌표계 m) · + 위치 칸 가운데(경위도 · 595) · 새로(옛 받은 범위 버림).""" margin_m: float | None = None extra: list[tuple[float, float]] = [] + center: Center | None = None + reset: bool = False -async def _plan_ends(project_id: UUID) -> tuple[Path, int, list[tuple[float, float]]]: - """(실경로, 좌표계, [지점들]) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 둘레 두 귀.""" +Ends = tuple[Path, int, list[tuple[float, float]], int | None] + + +async def _plan_ends( + project_id: UUID, center: tuple[float, float] | None = None, reset: bool = False +) -> Ends: + """(실경로, 좌표계, [지점들], 띠 넘음 epsg) — 설계 내용 파일 · 지점 · 선이 없으면 등록 기준점 + 둘레 두 귀 · `center`(위치 칸) = 그 둘레를 더함(`reset` = 둘레만) · 다른 TM 띠 = 확인 전이면 + 넷째 값에 그 띠(셋은 `center` 없이) · 확인 뒤(받은 지형이 그 띠) = 그 띠 · 둘레만.""" root, plan = await store.load_plan(project_id) # 없음 404 · 예정노선 아님 400 site = (await network.project_info(project_id))["site"] epsg = network.work_epsg(plan, site) + meta = terrain.received_meta(root) + if center is None and meta.get("center") and meta.get("epsg") not in (None, epsg): + center = tuple(meta["center"]) # 띠 넘어 받은 뒤 [저장] 전 — 받은 지형 띠 · 가운데 + ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] + if center is not None: + band = tm_epsg_for_lon(center[0]) + if epsg is not None and band != epsg: + if not (reset or meta.get("epsg") == band): + return (*(await _plan_ends(project_id))[:3], band) + epsg, ends, reset = band, [], True # 옛 띠 지점 · 선 = 「노선 지움」 확인으로 빠짐 + epsg = epsg or band + corners = terrain.site_corners(*lonlat_to_tm(center[0], center[1], epsg)) + return root, epsg, corners if reset else [*ends, *corners], None if epsg is None: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - ends = [(float(p["x"]), float(p["y"])) for p in plan["points"]] if site and not ends and not plan["lines"]: # 처음 = 기준점 둘레 · 그 뒤는 받은 범위가 덮음 ends = terrain.site_corners(*lonlat_to_tm(site["lon"], site["lat"], epsg)) if not ends: raise HTTPException(status_code=400, detail="기준점 · 지점이 없습니다.") - return root, epsg, ends + return root, epsg, ends, None def _query_xy(extra: str | None) -> list[tuple[float, float]]: @@ -81,15 +113,49 @@ def _query_xy(extra: str | None) -> list[tuple[float, float]]: raise HTTPException(status_code=400, detail="extra 꼴이 다릅니다.") from error -def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: +IDLE = {"running": False, "percent": 100, "message": None, "error": None, "interrupted": False} + + +def _job_path(root: Path, kind: str) -> Path: + return terrain.models_dir_of(root) / f"plan_job_{kind}.json" + + +def _note_job(path: Path | None, job: dict[str, Any]) -> None: + """진행 기록 — 서버가 다시 켜져도 「돌던 일」을 알게(실패해도 일은 계속).""" + if path is None: + return + try: + path.parent.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, job) + except OSError as error: + logger.warning("D 진행 기록 실패: %s", error) + + +def _saved_job(root: Path, kind: str) -> dict[str, Any]: + """기억에 없는 일 — 기록이 「도는 중」이면 서버가 다시 켜져 끊김(percent < 100 · + message 없음).""" + try: + saved = json.loads(_job_path(root, kind).read_text(encoding="utf-8")) + except (OSError, ValueError): + return dict(IDLE) + if not (isinstance(saved, dict) and saved.get("running")): + return dict(IDLE) + percent = min(99, int(saved.get("percent") or 0)) + return {**IDLE, "percent": percent, "interrupted": True} + + +def _start_job(project_id: UUID, kind: str, work: Any, root: Path | None = None) -> dict[str, Any]: key = (str(project_id), kind) if (_JOBS.get(key) or {}).get("running"): raise HTTPException(status_code=409, detail="이미 돌고 있습니다.") - job = {"running": True, "percent": 0, "message": "시작", "error": None} + job = {"running": True, "percent": 0, "message": "시작", "error": None, "interrupted": False} _JOBS[key] = job + path = _job_path(root, kind) if root is not None else None + _note_job(path, job) def report(percent: int, message: str) -> None: job.update(percent=int(percent), message=message) + _note_job(path, job) async def run() -> None: try: @@ -100,6 +166,7 @@ def _start_job(project_id: UUID, kind: str, work: Any) -> dict[str, Any]: job["error"] = str(getattr(error, "detail", None) or error) finally: job["running"] = False + _note_job(path, job) task = asyncio.create_task(run(), name=f"D {kind} {project_id}") _TASKS.add(task) @@ -133,10 +200,16 @@ async def _save_models(project_id: UUID, models: list[dict[str, Any]], epsg: int @router.get("/{project_id}/plan/terrain/extent", response_model=None) async def plan_terrain_extent( - project_id: UUID, margin_m: float | None = None, extra: str | None = None + project_id: UUID, + margin_m: float | None = None, + extra: str | None = None, + lon: float | None = None, + lat: float | None = None, ) -> dict[str, Any]: - """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]`) 상자 + 여유 ∪ 받은 범위.""" - root, epsg, ends = await _plan_ends(project_id) + """받기 전 범위 — 지점(+ `extra` 그은 점 `[[x, y]]` · `lon` `lat` 위치 칸 둘레) 상자 + 여유 ∪ + 받은 범위.""" + center = (lon, lat) if lon is not None and lat is not None else None + root, epsg, ends, _ = await _plan_ends(project_id, center) view = await asyncio.to_thread( terrain.plan_extent, root, [*ends, *_query_xy(extra)], epsg, margin_m ) @@ -145,34 +218,73 @@ async def plan_terrain_extent( @router.post("/{project_id}/plan/terrain", response_model=None) async def plan_terrain(project_id: UUID, body: TerrainBody | None = None) -> dict[str, Any]: - """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress`.""" + """사업 전체 수치지형도 · 위성 · DEM 받기(뒤에서) — 진행은 `GET …/plan/progress` · + 위치 칸 가운데가 멀거나 다른 띠 = 물음(`far` · `band`) · 칸 상한 넘음 = 413.""" body = body or TerrainBody() - root, epsg, ends = await _plan_ends(project_id) + center = (body.center.lon, body.center.lat) if body.center else None + root, epsg, ends, band = await _plan_ends(project_id, center, body.reset) + if band is not None: + return { + "status": "band", + "epsg": epsg, + "center_epsg": band, + "message": "다른 좌표 띠로 옮깁니다 — 그은 지점 · 선을 지우고 새로 받을까요?", + } + have = None if body.reset else terrain.received_box(root, epsg) + if center is not None and have is not None: + far = terrain.distance_to_box(lonlat_to_tm(center[0], center[1], epsg), have) + if far > terrain.FAR_M: + return { + "status": "far", + "distance_m": round(far), + "message": f"받은 지형에서 {far / 1000:.1f} km 떨어졌습니다 — " + "옛 받은 범위를 버리고 새로 받을까요?", + } points = [*ends, *body.extra] + cells = terrain.cells_of(terrain.wanted_box(points, terrain.clamp_margin(body.margin_m), have)) + if cells > terrain.MAX_CELLS: + raise HTTPException( + status_code=413, + detail=f"받을 범위가 너무 큽니다 — 칸 {cells / 1e4:.0f}만 · " + f"상한 {terrain.MAX_CELLS / 1e4:.0f}만(지점 · 선을 모으거나 위치를 옮겨 새로 받으세요)", + ) async def work(report: Any) -> None: got = await asyncio.to_thread( - terrain.ensure_plan_terrain, root, points, epsg, body.margin_m, report + terrain.ensure_plan_terrain, + root, + points, + epsg, + body.margin_m, + report, + reset=have is None, + center=center, ) await _save_models(project_id, got["models"], epsg) report(100, f"지형 받기 끝 — 새 도엽 {len(got['sheets']['downloaded'])}매") - return _start_job(project_id, "terrain", work) + return _start_job(project_id, "terrain", work, root) @router.get("/{project_id}/plan/progress", response_model=None) async def plan_progress(project_id: UUID) -> dict[str, Any]: - idle = {"running": False, "percent": 0, "message": None, "error": None} - return { - "status": "success", - **{kind: dict(_JOBS.get((str(project_id), kind)) or idle) for kind in KINDS}, - } + """일마다 진행 — 기억에 없으면 기록을 봄(돌던 기록 = 끊김 · 없으면 percent 100).""" + jobs: dict[str, Any] = {} + for kind in KINDS: + job = _JOBS.get((str(project_id), kind)) + if job is None: + try: + job = await asyncio.to_thread(_saved_job, await _tile_root(project_id), kind) + except HTTPException: + job = IDLE + jobs[kind] = dict(job) + return {"status": "success", **jobs} async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]: """(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404.""" - root, epsg, _ = await _plan_ends(project_id) - box = terrain.received_box(root) + root, epsg, _, _ = await _plan_ends(project_id) + box = terrain.received_box(root, epsg) if box is None: raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.") return root, epsg, box @@ -301,12 +413,30 @@ async def plan_layer_zero_order( return {"status": "success", **value} +async def _road_frame(project_id: UUID) -> tuple[Path, int, terrain.Box, tuple[float, float]]: + """(실경로, 좌표계, 자르는 범위, 기준점 m) — 기준점 = 위치 칸으로 받은 가운데 · 등록 기준점 · + 지점 가운데 차례 · 지형 받기 전에도(595-4).""" + root, epsg, ends, _ = await _plan_ends(project_id) + center = terrain.received_meta(root).get("center") + site = (await network.project_info(project_id))["site"] + if center: + origin = lonlat_to_tm(float(center[0]), float(center[1]), epsg) + elif site: + origin = lonlat_to_tm(site["lon"], site["lat"], epsg) + else: + xs, ys = [p[0] for p in ends], [p[1] for p in ends] + origin = ((min(xs) + max(xs)) / 2, (min(ys) + max(ys)) / 2) + box = forest_road.clip_box(origin, terrain.received_box(root, epsg)) + return root, epsg, box, origin + + @router.get("/{project_id}/plan/layers/forest-roads", response_model=None) async def plan_layer_forest_roads(project_id: UUID) -> dict[str, Any]: - """올린 임도망도(받은 범위로 자른 선 · 가닥 · 길이) — 올린 적 없으면 404.""" - root, epsg, box = await _layer_root(project_id) + """올린 임도망도(기준점 ± 5 km ∪ 받은 지형으로 자른 선 · 가닥 · 길이 · 잘린 상자 · 기준점 + 거리) — 올린 적 없으면 404.""" + root, epsg, box, origin = await _road_frame(project_id) try: - value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg) + value = await asyncio.to_thread(forest_road.road_layer, root, box, epsg, origin) except FileNotFoundError as error: raise HTTPException(status_code=404, detail=str(error)) from error return {"status": "success", **value} @@ -317,8 +447,8 @@ async def plan_layer_forest_roads_upload( project_id: UUID, files: list[UploadFile] = File(...) ) -> dict[str, Any]: """임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음 · 합계 100 MB) — 못 읽거나 - 받은 범위 안 선이 없으면 400(옛 것 그대로) · 넘치면 413.""" - root, epsg, box = await _layer_root(project_id) + 자르는 범위 안 선이 없으면 400(옛 것 그대로 · 가장 가까운 임도 거리) · 넘치면 413.""" + root, epsg, box, origin = await _road_frame(project_id) got: list[tuple[str, bytes]] = [] total = 0 for upload in files: @@ -335,7 +465,7 @@ async def plan_layer_forest_roads_upload( if not total: raise HTTPException(status_code=400, detail="빈 파일입니다.") try: - value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg) + value = await asyncio.to_thread(forest_road.save_roads, root, got, box, epsg, origin) except ValueError as error: raise HTTPException(status_code=400, detail=str(error)) from error return {"status": "success", **value} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py index 866937b52..6827181ef 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Terrain.py @@ -1,13 +1,17 @@ -"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · PLAN 530-1 · 561-2). +"""D 예정노선 지형 받기 — 사업 전체 한 벌(도엽 · 위성 · DEM) (요구 345 · 389 · 463 · PLAN 530-1 · +561-2 · 595). 지형은 D 프로젝트 storage 안 `B04_PreProcess/` 배치 그대로(지형 코드 주인 = B04 · 고칠 곳 0): -- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) +- 범위 = 전체 시점 · 종점(+ 그은 점) 상자 + 여유 · 받은 범위가 있으면 그 범위와 합침(줄지 않음) · + 새로(`reset` · 위치를 멀리 옮김) = 옛 받은 범위 · 병합 도엽 · 위성 범위를 버리고 새 상자만 · + 칸 상한 `MAX_CELLS`(넘으면 라우터가 거절) - 도엽 = 범위 상자에 닿는 도엽 모두 → `ensure_sheets`(없는 것만 · prune 없음) - 병합 = 지난번 병합 도엽 ∪ 이번 도엽(옛 도엽 유지 · 열쇠 같으면 건너뜀) - 위성 = 범위 도엽 도곽 ∪ 옛 위성 범위(덮으면 건너뜀) -- 지적도 = 범위 상자 · 브이월드 WFS 1 km 조각(`D03_PlanAnalysis_Layers` · 실패해도 계속) +- 지적도 = 지형 일에서 뺌 — 겹을 켤 때 GET 이 받음(`D03_PlanAnalysis_Layers.ensure_cadastral`) - DEM = 한 벌 `models_plan/`(연도는 D03 나눔 뒤에 정해짐) · 받은 범위 = `plan_terrain.json` + (상자 · 여유 · 좌표계 · 위치 칸 가운데) - 작업 좌표계 = 지점 epsg · `B03_FileInput/input/prj/plan.prj` 한 장(B04 · B05 좌표계 창구가 읽음) - 받기 전 범위 보기 = `plan_extent`(내려받기 없음) · 받기는 사용자가 누를 때만 @@ -43,7 +47,6 @@ from config.config_system_terrain import ( SHEET_SURFACE_DEFAULT_METHOD, SHEET_SURFACE_MARGIN_M, ) -from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers logger = logging.getLogger(__name__) @@ -52,6 +55,9 @@ DEFAULT_MARGIN_M = 350.0 MAX_MARGIN_M = 5000.0 # 처음 받는 범위 = 등록 기준점 둘레 ± 1 km(요구 453 · PLAN 591-2) SITE_RADIUS_M = 1000.0 +# 위치 바꿈이 받은 범위에서 이만큼 넘게 멀면 묻고 새로 · 받을 DEM 칸 상한(요구서_463 3장 4) +FAR_M = 5000.0 +MAX_CELLS = 5_000_000 STAGE = Path("B04_PreProcess") PLAN_MODELS = "models_plan" TERRAIN_META = "plan_terrain.json" @@ -74,9 +80,16 @@ def clamp_margin(margin_m: float | None) -> float: return min(MAX_MARGIN_M, max(SHEET_SURFACE_MARGIN_M, float(margin_m))) -def received_box(project_root: Path) -> Box | None: - """받은 DEM 범위 — 받은 적 없거나 읽지 못하면 None.""" - meta = _read_json(models_dir_of(project_root) / TERRAIN_META) or {} +def received_meta(project_root: Path) -> dict[str, Any]: + """받은 범위 기록(`box` · `margin_m` · `epsg` · `center`) — 없으면 빈 dict.""" + return _read_json(models_dir_of(project_root) / TERRAIN_META) or {} + + +def received_box(project_root: Path, epsg: int | None = None) -> Box | None: + """받은 DEM 범위 — 받은 적 없거나 읽지 못하거나 다른 좌표계(`epsg` 줄 때)면 None.""" + meta = received_meta(project_root) + if epsg is not None and meta.get("epsg") not in (None, int(epsg)): + return None try: x0, y0, x1, y1 = (float(v) for v in meta["box"]) except (KeyError, TypeError, ValueError): @@ -99,6 +112,18 @@ def wanted_box(points: list[XY], margin_m: float, have: Box | None) -> Box: return min(box[0], have[0]), min(box[1], have[1]), max(box[2], have[2]), max(box[3], have[3]) +def cells_of(box: Box) -> int: + """DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 · 상한 판정.""" + return int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2) + + +def distance_to_box(xy: XY, box: Box) -> float: + """점 ~ 상자 거리(m · 안이면 0).""" + dx = max(box[0] - xy[0], 0.0, xy[0] - box[2]) + dy = max(box[1] - xy[1], 0.0, xy[1] - box[3]) + return math.hypot(dx, dy) + + def covers(have: Box | None, box: Box) -> bool: return ( have is not None @@ -135,7 +160,7 @@ def plan_extent( ) -> dict[str, Any]: """받기 전 범위 보기 — 내려받기 없음. `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m).""" margin = clamp_margin(margin_m) - have = received_box(project_root) + have = received_box(project_root, epsg) box = wanted_box(points, margin, have) store = get_project_map_sheets_dir(project_root) sheets = [] @@ -157,8 +182,8 @@ def plan_extent( "have": box_ring(have, epsg) if have else None, "covered": covers(have, box), "sheets": sheets, - # DEM 칸 수 어림 — 받기 시간 · 메모리 가늠 - "cells": int((box[2] - box[0]) * (box[3] - box[1]) / PLAN_TERRAIN_GRID_M**2), + "cells": cells_of(box), + "max_cells": MAX_CELLS, } @@ -181,10 +206,12 @@ def _read_json(path: Path) -> dict[str, Any] | None: return None -def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[str]: - """병합 = 지난번 병합 도엽(zip 남은 것) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" +def _merge_sheets( + processed: Path, store: Path, available: list[str], keep_old: bool = True +) -> list[str]: + """병합 = 지난번 병합 도엽(zip 남은 것 · 새로면 뺌) ∪ 이번 도엽 · 열쇠 같으면 건너뜀.""" key_path = processed / "sheet_merge.key.json" - old = (_read_json(key_path) or {}).get("sheets") or [] + old = ((_read_json(key_path) or {}).get("sheets") or []) if keep_old else [] sheets = sorted({*available, *(s for s in old if get_sheet_path(store, s) is not None)}) key = {"sheets": sheets, "parser": PARSER_VERSION} if sheets and (_read_json(key_path) != key or not any(processed.glob("도엽_*.geojson"))): @@ -193,8 +220,15 @@ def _merge_sheets(processed: Path, store: Path, available: list[str]) -> list[st return sheets -def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], epsg: int) -> str: - """위성 = 기준 도엽 도곽 ∪ 옛 범위 · 이미 덮으면 건너뜀. 돌려줌 = skip · done · failed.""" +def _satellite( + processed: Path, + prj: Path, + points: list[tuple[float, float]], + epsg: int, + keep_old: bool = True, +) -> str: + """위성 = 기준 도엽 도곽 ∪ 옛 범위(새로면 뺌) · 이미 덮으면 건너뜀. 돌려줌 = skip · done · + failed.""" from pyproj import Transformer to_metric = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True) @@ -207,7 +241,7 @@ def _satellite(processed: Path, prj: Path, points: list[tuple[float, float]], ep xs.append(x) ys.append(y) need = {"x": [min(xs), max(xs)], "y": [min(ys), max(ys)]} - meta = _read_json(processed / "vworld_satellite_meta.json") + meta = _read_json(processed / "vworld_satellite_meta.json") if keep_old else None try: old = [float(meta[k]) for k in ("x_min", "x_max", "y_min", "y_max")] if meta else None except (KeyError, TypeError, ValueError): @@ -234,8 +268,11 @@ def ensure_plan_terrain( epsg: int, margin_m: float | None = None, on_progress: Progress | None = None, + reset: bool = False, + center: tuple[float, float] | None = None, ) -> dict[str, Any]: - """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m). + """사업 전체 지형 받기 — `points` = 전체 시점 · 종점(+ 그은 점 · 작업 좌표계 m) · + `reset` = 옛 받은 범위 버리고 새로 · `center` = 위치 칸 가운데(경위도 · 기록만). 돌려줌 = 도엽(받은 · 실패 · 병합) · 위성 상태 · 받은 범위 · 모델 등록용 dict 목록. """ @@ -250,28 +287,24 @@ def ensure_plan_terrain( processed.mkdir(parents=True, exist_ok=True) prj = write_plan_prj(project_root, epsg) margin = clamp_margin(margin_m) - box = wanted_box(points, margin, received_box(project_root)) + have = None if reset else received_box(project_root, epsg) + box = wanted_box(points, margin, have) - report(5, "수치지형도 도엽 고르는 중") + report(5, "1/5 수치지형도 도엽 고르는 중") wanted = sheets_for_box(box, epsg) store = get_project_map_sheets_dir(project_root) before = {s for s in wanted if get_sheet_path(store, s) is not None} - report(10, f"수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") + report(10, f"2/5 수치지형도 도엽 {len(wanted) - len(before)}매 받는 중") got = ensure_sheets(store, wanted) if not got["available"]: raise ValueError(f"수치지형도 도엽을 하나도 받지 못했습니다: {got['failed']}") - report(45, "도엽 레이어 병합 중") - merged = _merge_sheets(processed, store, list(got["available"])) - report(60, "위성 지도 받는 중") - satellite = _satellite(processed, prj, [(p[1], p[0]) for p in box_ring(box, epsg)], epsg) - report(62, "지적도 받는 중") - try: - cadastral = layers.ensure_cadastral(processed, box, epsg)["count"] - except Exception as exc: # noqa: BLE001 — 지적도는 겹 · 지형 받기를 막지 않음(켤 때 다시) - logger.warning("D 지적도 받기 실패: %s", exc) - cadastral = None + report(45, "3/5 도엽 레이어 병합 중") + merged = _merge_sheets(processed, store, list(got["available"]), keep_old=have is not None) + report(60, "4/5 위성 지도 받는 중") + ring = [(p[1], p[0]) for p in box_ring(box, epsg)] + satellite = _satellite(processed, prj, ring, epsg, keep_old=have is not None) - report(65, "사업 지형면 만드는 중") + report(65, f"5/5 사업 지형면 만드는 중 — 칸 {cells_of(box) / 1e4:.0f}만 · 수 분 걸릴 수 있음") models_dir = models_dir_of(project_root) models_dir.mkdir(parents=True, exist_ok=True) # B 절취 = 넘긴 꼭짓점 상자 + `SHEET_SURFACE_MARGIN_M` → 그만큼 안쪽으로 넘김(여유 ≥ 그 값) @@ -293,7 +326,11 @@ def ensure_plan_terrain( "사업 지형면을 만들지 못했습니다 — 범위 안 도엽 등고선 높이 확인" f"(병합 도엽 {len(merged)}매)" ) - atomic_write_json(models_dir / TERRAIN_META, {"box": list(box), "margin_m": margin}) + meta: dict[str, Any] = {"box": list(box), "margin_m": margin, "epsg": int(epsg)} + kept = None if reset else received_meta(project_root).get("center") + if center or kept: + meta["center"] = list(center or kept) + atomic_write_json(models_dir / TERRAIN_META, meta) report(100, "지형 받기 끝") return { "sheets": { @@ -303,7 +340,6 @@ def ensure_plan_terrain( "merged": merged, }, "satellite": satellite, - "cadastral": cadastral, "box_m": list(box), "models": models, } diff --git a/resources/tester/test_530_1_plan_terrain.py b/resources/tester/test_530_1_plan_terrain.py index 082c77c00..74af4f6a1 100644 --- a/resources/tester/test_530_1_plan_terrain.py +++ b/resources/tester/test_530_1_plan_terrain.py @@ -1,5 +1,6 @@ -"""D 예정노선 지형 받기(PLAN 530-1 · 561-2) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · -옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음. +"""D 예정노선 지형 받기(PLAN 530-1 · 561-2 · 595) — 사업 전체 한 벌 · 범위 보기 = 내려받기 없음 · +옛 도엽 유지 · 새 도엽만 · 범위는 줄지 않음 · 빠짐 없음 · 지적도 뺌 · 새로(reset) = 옛 범위 버림 · +받은 범위 좌표계 · 칸 상한 · 거리. ./venv/Scripts/python.exe -m pytest resources/tester/test_530_1_plan_terrain.py -q """ @@ -54,7 +55,6 @@ def fake(tmp_path, monkeypatch): monkeypatch.setattr(terrain, "parse_and_merge_sheets", merge) monkeypatch.setattr(terrain, "download_vworld_satellite_map", satellite) monkeypatch.setattr(terrain, "build_sheet_surface_model", dem) - monkeypatch.setattr(terrain.layers, "ensure_cadastral", lambda *a: {"count": 0}) # 569-1 return tmp_path, seen @@ -104,6 +104,39 @@ def test_one_set_and_extend_keeps_old(fake) -> None: assert len(seen["merge"]) == merges and third["box_m"] == grown +def test_reset_drops_old_box_sheets_satellite(fake) -> None: + """595-2 — 새로 = 옛 받은 범위 · 병합 도엽 · 위성 범위 버림 · 좌표계 · 가운데 기록 · + 다른 좌표계로 물으면 받은 범위 없음 · 지적도는 지형 일에 없음.""" + root, seen = fake + terrain.ensure_plan_terrain(root, [A, B], EPSG, center=(127.5, 36.0)) + old = set(seen["merge"][-1]) + far = [(A[0] + 40000.0, A[1]), (A[0] + 41000.0, A[1] + 1000.0)] + got = terrain.ensure_plan_terrain(root, far, EPSG, reset=True, center=(128.0, 36.0)) + m = terrain.DEFAULT_MARGIN_M + assert got["box_m"] == pytest.approx( + [far[0][0] - m, far[0][1] - m, far[1][0] + m, far[1][1] + m] + ) + assert not old & set(seen["merge"][-1]) # 옛 도엽 병합에서 빠짐(zip 은 남음) + assert seen["satellite"][-1]["x"][0] > A[0] + 30000 # 옛 위성 범위와 안 합침 + assert "cadastral" not in got + meta = terrain.received_meta(root) + assert meta["epsg"] == EPSG and meta["center"] == [128.0, 36.0] + assert terrain.received_box(root, EPSG) == pytest.approx(tuple(got["box_m"])) + assert terrain.received_box(root, 5187) is None # 다른 띠 = 받은 범위 아님 + # 새로 아닌 이어 받기 = 가운데 기록 그대로 + terrain.ensure_plan_terrain(root, far, EPSG) + assert terrain.received_meta(root)["center"] == [128.0, 36.0] + + +def test_cells_distance_and_limit(fake) -> None: + root, _ = fake + assert terrain.cells_of((0.0, 0.0, 2000.0, 2000.0)) == 1_000_000 # 2 m 격자 + assert terrain.distance_to_box((5.0, 5.0), (0.0, 0.0, 10.0, 10.0)) == 0 + assert terrain.distance_to_box((13.0, 14.0), (0.0, 0.0, 10.0, 10.0)) == 5 + assert terrain.MAX_CELLS == 5_000_000 and terrain.FAR_M == 5000 + assert terrain.plan_extent(root, [A, B], EPSG)["max_cells"] == terrain.MAX_CELLS + + def test_extent_no_download_and_covered(fake) -> None: root, seen = fake before = terrain.plan_extent(root, [A, B], EPSG) diff --git a/resources/tester/test_530_2_plan_router.py b/resources/tester/test_530_2_plan_router.py index 483c6932e..10bfe6970 100644 --- a/resources/tester/test_530_2_plan_router.py +++ b/resources/tester/test_530_2_plan_router.py @@ -1,5 +1,6 @@ -"""D 지형 API(PLAN 530-1 · 561-2 · 591-2) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · 선이 -없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌. +"""D 지형 API(PLAN 530-1 · 561-2 · 591-2 · 595) — 범위 = 지점(설계 내용 파일) 상자 + 여유 · 지점 · +선이 없으면 등록 기준점 둘레 ± 1 km · 받기 = 한 벌 · 위치 칸 가운데 = 둘레 덧 · 멀다 · 다른 띠 +물음 · 새로(reset) · 칸 상한 413 · 서버 다시 켜짐 = 끊김. 옛 분석 API 시험(GET · PUT analysis · 꺾임점 고침)은 옛 추천 엔진과 함께 걷음(PLAN 562-2) · 노선 정본 시험 = `test_530_2_plan_analyze.py` · 지형 셈 시험 = `test_530_1_plan_terrain.py`. @@ -20,6 +21,8 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2" P0, P2 = (0.0, 0.0), (900.0, 400.0) URL = f"/api/projects/{PID}/plan/terrain" +OX, OY = 200000.0, 500000.0 # 595 시험 = 중부 띠 안(경도 127°) +Q0, Q2 = (OX + P0[0], OY + P0[1]), (OX + P2[0], OY + P2[1]) class _Pool: @@ -51,6 +54,7 @@ def client(tmp_path, monkeypatch): test = TestClient(app) test.plan = plan test.info = info + test.root = tmp_path return test @@ -90,14 +94,14 @@ def test_post_runs_one_set(client, monkeypatch) -> None: saved: list = [] works: list = [] - def ensure(root, points, epsg, margin_m, report): + def ensure(root, points, epsg, margin_m, report, **kw): calls.append((points, epsg, margin_m)) return {"sheets": {"downloaded": ["1"]}, "models": [{"model_file_path": "m"}]} async def save(project_id, models, epsg): saved.append((models, epsg)) - def start(project_id, kind, work): + def start(project_id, kind, work, root=None): works.append(work) return {"status": "accepted", "kind": kind} @@ -116,3 +120,154 @@ def test_post_runs_one_set(client, monkeypatch) -> None: # 옛 화면 꼴(points 몸) — 여유 처음 값 · 전체 시점 · 종점 post({"points": []}) assert calls[-1] == ([P0, P2], 5186, None) + + +# ── 595 위치 바꿈 · 상한 · 끊김 ── + + +@pytest.fixture +def posting(client, monkeypatch): + """POST 받기 — 일은 바로 돌림 · ensure 부름(점 · 좌표계 · reset · center) 기록.""" + calls: list = [] + + def ensure(root, points, epsg, margin_m, report, reset=False, center=None): + calls.append({"points": points, "epsg": epsg, "reset": reset, "center": center}) + return {"sheets": {"downloaded": []}, "models": []} + + async def save(project_id, models, epsg): + return None + + works: list = [] + + def start(project_id, kind, work, root=None): + works.append(work) + return {"status": "accepted", "kind": kind} + + def post(body): + res = client.post(URL, json=body) + while works: # 일은 응답 뒤 돌림(앱 고리 밖) + asyncio.run(works.pop()(lambda percent, message: None)) + return res + + monkeypatch.setattr(router_mod.terrain, "ensure_plan_terrain", ensure) + monkeypatch.setattr(router_mod, "_save_models", save) + monkeypatch.setattr(router_mod, "_start_job", start) + client.plan["points"] = [{"x": Q0[0], "y": Q0[1]}, {"x": Q2[0], "y": Q2[1]}] + client.calls = calls + client.run_post = post + return client + + +def _have(client, box, epsg=5186, center=None): + meta = {"box": list(box), "margin_m": 350.0, "epsg": epsg} + if center: + meta["center"] = list(center) + folder = router_mod.terrain.models_dir_of(client.root) + folder.mkdir(parents=True, exist_ok=True) + (folder / router_mod.terrain.TERRAIN_META).write_text(json.dumps(meta), "utf-8") + + +def test_center_near_adds_site_ring(posting) -> None: + """가까운 위치 바꿈 = 지점 + 새 가운데 둘레 · 받은 범위와 합침.""" + from common_util.common_util_crs import lonlat_to_tm, tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 3000.0, OY, 5186) # 받은 범위에서 1.5 km + res = posting.run_post({"center": {"lon": lon, "lat": lat}}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + x, y = lonlat_to_tm(lon, lat, 5186) + r = router_mod.terrain.SITE_RADIUS_M - router_mod.terrain.DEFAULT_MARGIN_M + assert call["points"][:2] == [Q0, Q2] and call["points"][2] == pytest.approx((x - r, y - r)) + assert call["reset"] is False and call["center"] == (lon, lat) + # extent 에도 캐시 가운데 = 둘레가 상자에 들어감 + got = posting.get(f"{URL}/extent", params={"lon": lon, "lat": lat}).json() + assert got["box_m"][2] == pytest.approx(x + router_mod.terrain.SITE_RADIUS_M) + + +def test_center_far_asks_then_reset(posting) -> None: + """5 km 넘음 = 「far」(받지 않음) · reset = 옛 범위 버리고 새 ±1 km(지점 안 넣음).""" + from common_util.common_util_crs import tm_to_lonlat + + _have(posting, (OX - 500.0, OY - 500.0, OX + 1500.0, OY + 1000.0)) + lon, lat = tm_to_lonlat(OX + 8000.0, OY, 5186) # 6.5 km + body = {"center": {"lon": lon, "lat": lat}} + far = posting.run_post(body).json() + assert far["status"] == "far" and far["distance_m"] == pytest.approx(6500, abs=2) + assert "km" in far["message"] and posting.calls == [] + res = posting.run_post({**body, "reset": True}).json() + assert res["status"] == "accepted" + call = posting.calls[-1] + assert call["reset"] is True and len(call["points"]) == 2 # 둘레 두 귀만 + assert call["points"][0][0] == pytest.approx(OX + 8000 - 650, abs=1) + + +def test_center_other_band_asks_then_moves(posting) -> None: + """다른 TM 띠 = 「band」 · 확인(reset) 뒤 그 띠 · 그 뒤엔 받은 지형 띠를 따름.""" + posting.plan["lines"] = [{"id": "a"}] + body = {"center": {"lon": 129.1017, "lat": 36.8128}} # 용화(동부 5187) + ask = posting.run_post(body).json() + assert ask == { + "status": "band", + "epsg": 5186, + "center_epsg": 5187, + "message": ask["message"], + } + assert posting.calls == [] + # 확인 전 extent = 가운데 무시(옛 띠 그대로) + assert posting.get(f"{URL}/extent", params={"lon": 129.1, "lat": 36.8}).json()["epsg"] == 5186 + posting.run_post({**body, "reset": True}) + call = posting.calls[-1] + assert call["epsg"] == 5187 and call["reset"] is True and len(call["points"]) == 2 + # 받은 뒤([저장] 전) — 가운데 없이 물어도 받은 지형 띠 · 가운데 둘레 + (x0, y0), (x1, y1) = call["points"] + box = [x0 - 400, y0 - 400, x1 + 400, y1 + 400] + _have(posting, box, epsg=5187, center=(129.1017, 36.8128)) + got = posting.get(f"{URL}/extent").json() + assert got["epsg"] == 5187 and got["covered"] is True + + +def test_cells_limit_413(posting) -> None: + posting.plan["points"].append( + {"x": OX + 6000.0, "y": OY + 6000.0} + ) # 6.7 × 6.7 km = 1,100 만 칸 + res = posting.run_post({}) + assert res.status_code == 413 and "상한 500만" in res.json()["detail"] + assert posting.calls == [] + + +def test_progress_interrupted_after_restart(client, monkeypatch) -> None: + """도는 기록이 남았는데 기억에 없음(서버 다시 켜짐) = 끊김 · 받은 적 없음 = percent 100.""" + root = client.root + + async def tile_root(project_id): + return root + + monkeypatch.setattr(router_mod, "_tile_root", tile_root) + url = f"/api/projects/{PID}/plan/progress" + router_mod._JOBS.clear() + idle = client.get(url).json()["terrain"] + assert idle == {**router_mod.IDLE} + + path = router_mod._job_path(root, "terrain") + seen = [] + + async def work(report): + report(65, "5/5 사업 지형면 만드는 중") + seen.append(json.loads(path.read_text("utf-8"))) + + async def run(): + router_mod._start_job(PID, "terrain", work, root) + await asyncio.sleep(0.05) + + asyncio.run(run()) + assert seen[0]["running"] is True and seen[0]["percent"] == 65 # 도는 동안 기록 + assert json.loads(path.read_text("utf-8"))["running"] is False # 끝 기록 + path.write_text(json.dumps(seen[0]), "utf-8") # 도는 중 꺼짐 + router_mod._JOBS.clear() # 다시 켜짐 = 기억 빔 + got = client.get(url).json()["terrain"] + assert got["running"] is False and got["interrupted"] is True + assert got["percent"] == 65 and got["message"] is None and got["error"] is None + # 끝난 기록 = 끊김 아님 + path.write_text(json.dumps({**seen[0], "running": False}), "utf-8") + assert client.get(url).json()["terrain"] == {**router_mod.IDLE} diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 56fdf0019..60c745a83 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -1,5 +1,5 @@ -"""D 노선분석 지도 겹(PLAN 569-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · -겹친 필지 거름 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. +"""D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · +겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. ./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q """ @@ -127,9 +127,29 @@ def test_cadastral_failures(tmp_path, monkeypatch) -> None: monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=1)) first = layers.ensure_cadastral(tmp_path, BOX, EPSG) assert first["failed"] == 1 and (tmp_path / layers.CADASTRAL_FILE).exists() + assert ( + len(json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8"))["failed_tiles"]) == 1 + ) + # 595-1 — 실패 조각만 다시 · 필지는 옛 것 + 그 조각 = 통째로 받은 것과 같음 again = FakeWfs() monkeypatch.setattr(layers, "request_vworld_wfs", again) - assert layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 15 + retry = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert retry["fetched"] and again.calls == 1 and retry["failed"] == 0 + assert retry["count"] == 15 * 2 + 1 + assert not layers.ensure_cadastral(tmp_path, BOX, EPSG)["fetched"] and again.calls == 1 + # 옛 열쇠(실패 조각 기록 없음) = 통째로 + key = json.loads((tmp_path / layers.CADASTRAL_KEY).read_text("utf-8")) + key.update(failed=1) + key.pop("failed_tiles") + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert again.calls == 1 + 15 + # 다시 받기도 모두 실패 = 옛 필지 그대로 · 실패 조각 남김(오류 아님) + key.update(failed=1, failed_tiles=[list(layers.tiles(BOX)[0])]) + (tmp_path / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8") + monkeypatch.setattr(layers, "request_vworld_wfs", FakeWfs(fail=99)) + kept = layers.ensure_cadastral(tmp_path, BOX, EPSG) + assert kept["failed"] == 1 and kept["count"] == 15 * 2 + 1 # ── 급경사 ── 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 87ad0b83e..f5e43748c 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -1,5 +1,6 @@ -"""D 노선분석 GIS 겹 더함 3(PLAN 584-3) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 5179 · -cp949 · 받은 범위로 자름 · 범위 밖 = 받지 않음 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · +"""D 노선분석 GIS 겹 더함 3(PLAN 584-3 · 595-4) — 올린 임도망도 SHP(zip · 낱 파일 묶음 · prj 없으면 +5179 · cp949 · 기준점 ±5 km ∪ 받은 지형으로 자름 · 잘린 상자 · 기준점 거리 · 범위 밖 = 받지 않음 + +거리 · 저장본) · 산사태취약지역 시도 표(머리 · 열 별칭 · 도분초 · 지번) · 점 · 지적 필지 맞춤 · 표 없는 시도 · 화면 겹 정의. ./venv/Scripts/python.exe -m pytest resources/tester/test_584_3_plan_forest_road_zones.py -q @@ -81,7 +82,7 @@ def test_bad_uploads_keep_old(tmp_path) -> None: ([("a.zip", b"not a zip")], "zip 파일을 열지"), ([(n, d) for n, d in files if not n.endswith(".dbf")], ".dbf"), ([("a.zip", _zip({"readme.txt": b"x"}))], ".shp 파일이 없습니다"), - (_shp_files(tmp_path / "far", [LINES[2]]), "받은 범위 안에 임도가 없습니다"), + (_shp_files(tmp_path / "far", [LINES[2]]), "받은 지형 밖뿐입니다"), ] for upload, text in bad: with pytest.raises(ValueError, match=re.escape(text)): @@ -91,6 +92,26 @@ def test_bad_uploads_keep_old(tmp_path) -> None: assert not list((tmp_path / fr.FOLDER).glob("*.new")) +def test_clip_box_site_5km_and_distance(tmp_path) -> None: + """595-4 — 자르는 범위 = 기준점 ±5 km ∪ 받은 지형 · 잘린 상자 · 가장 가까운 임도 거리 · + 밖뿐 = 거절 + 거리.""" + site = (231000.0, 450600.0) + frame = fr.clip_box(site, BOX) + assert frame == (226000.0, 445600.0, 236000.0, 455600.0) # 5 km 상자가 받은 지형을 덮음 + assert fr.clip_box(site, (220000.0, 450000.0, 232000.0, 451000.0))[0] == 220000.0 # ∪ + assert fr.clip_box(site, None) == frame + got = fr.save_roads(tmp_path, _shp_files(tmp_path / "a", LINES), frame, EPSG, site) + assert (got["count"], got["total"]) == (2, 3) # 범위 밖 한 가닥(25 km)만 빠짐 + assert got["length_m"] == pytest.approx(2000 + 300, abs=1) # 받은 지형 밖 반도 들어옴 + assert got["distance_m"] == pytest.approx(100, abs=0.5) # 450600 ~ 450500 + w, s, e, n = got["bounds"] + assert (w, s) == pytest.approx(tm_to_lonlat(229000, 450100, EPSG), abs=1e-4) + assert (e, n) == pytest.approx(tm_to_lonlat(231500, 450500, EPSG), abs=1e-4) + far = _shp_files(tmp_path / "far", [LINES[2]]) + with pytest.raises(ValueError, match=r"가장 가까운 임도 2[0-9]\.[0-9] km\(파일 선 1가닥\)"): + fr.save_roads(tmp_path, far, frame, EPSG, site) + + def test_no_upload_is_not_found(tmp_path) -> None: with pytest.raises(FileNotFoundError): fr.road_layer(tmp_path, BOX, EPSG) From 0bf234e2b5fa8badbc9bce92442aeeb4000016ae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:46:22 +0900 Subject: [PATCH 03/14] =?UTF-8?q?feat(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0?= =?UTF-8?q?):=20=EC=A7=80=EC=A0=90=20=EC=B9=B8=20[=EC=B6=94=EA=B0=80]=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EB=A6=84=20=C2=B7=20=EA=B2=BD=EC=9C=84?= =?UTF-8?q?=EB=8F=84=20=EC=86=90=20=EA=B3=A0=EC=B9=A8=20=C2=B7=20=EC=B9=B8?= =?UTF-8?q?=20=EC=84=A4=EB=AA=85=20=EA=B8=80=20=EA=B1=B7=EC=9D=8C=20=C2=B7?= =?UTF-8?q?=20=EC=84=A0=EB=A7=88=EB=8B=A4=20=EC=B0=A8=EB=A1=80=20=EC=83=89?= =?UTF-8?q?=20(PLAN=20597-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_DrawPaint.ts | 9 +- .../D03_PlanAnalysis_UI_Points.ts | 173 ++++++++++++++++-- .../D03_PlanAnalysis_UI_Style.css | 18 ++ .../logic/D03_PlanAnalysis_PointEdit.ts | 76 ++++++++ resources/tester/test_597_point_edit.py | 111 +++++++++++ 5 files changed, 363 insertions(+), 24 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts create mode 100644 resources/tester/test_597_point_edit.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 0e81c63f5..4aa572b55 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -6,7 +6,7 @@ * 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`. * ========================================================================== */ -import { pointColor } from "@ui/ui_template_palette"; +import { pointColor, routeColor } from "@ui/ui_template_palette"; import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { distanceAlong, @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit"; import { planColors, PLAN_STATION_INTERVAL_M, @@ -126,7 +127,7 @@ export function paintNetwork( context.lineJoin = "round"; network.lines.forEach((line, index) => { if (line.vertices.length < 2) return; - context.strokeStyle = index === active ? colors.active : colors.line; + context.strokeStyle = index === active ? colors.active : routeColor(index); context.lineWidth = line.branch ? 2.5 : 3; context.beginPath(); line.vertices.forEach((v, i) => { @@ -163,7 +164,7 @@ export function paintNetwork( } /** 지점 핀 겹(585-1) — 꼭지 = 지점 · 둥근 머리 · 「지점 N」 · 화면 px 고정 크기 · 돌린 지도에서도 섬. */ -export function pointLayer(points: () => Vertex[]): PlanMapLayer { +export function pointLayer(points: () => Vertex[], names: () => string[] = () => []): PlanMapLayer { return { draw(context, map) { points().forEach((point, i) => { @@ -188,7 +189,7 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer { context.fillStyle = "#ffffff"; context.fill(); }); - planLabel(context, map, sx, sy - 38, `지점 ${i + 1}`, "bold 12px"); + planLabel(context, map, sx, sy - 38, pointLabel(names(), i), "bold 12px"); }); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 7565f8f35..ba4044b6b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. - * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 이름 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). + * [추가] = 지도 보기 가운데에 바로 놓음(597-2) · 줄 누름 = 그 줄만 펼침(이름 · 경도 · 위도 — 칸 떠남 · Enter 가 한 걸음). * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). * 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 · * 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝. @@ -10,18 +11,27 @@ * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; +import { createButton } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; -import { createText } from "@ui/ui_template_text"; import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + addPoint, + MAX_POINTS, + movePointTo, + parseLonLat, + pointLabel, + pointNameError, + renamePoint, + type PointsState, +} from "./logic/D03_PlanAnalysis_PointEdit"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ -const MAX_POINTS = 40; /** 핀 머리 = 꼭지 위 px(`pointLayer` 그림과 같음). */ const PIN_HEAD_PX = 18; @@ -56,10 +66,22 @@ export interface PlanPoints { export function createPlanPoints(params: PlanPointsParams): PlanPoints { const list = document.createElement("div"); list.className = "d03-plan__points"; - const hint = createText("", "muted"); - hint.classList.add("d03-plan__hint"); + const addButton = createButton({ + label: "추가", + iconStart: "plus", + variant: "ghost", + size: 24, + onClick: addAtCenter, + }); + addButton.dataset.role = "points-add"; + const head = document.createElement("div"); + head.className = "d03-plan__points-head"; + head.append(addButton); const panel = document.createElement("div"); - panel.append(list, hint); + panel.append(head, list); + let openIndex: number | null = null; // 펼친 줄(하나만) + let syncEditor: (() => void) | null = null; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) const menu = createMapContextMenu("d03-plan"); let menuAt: [number, number] = [0, 0]; let drag: { @@ -69,7 +91,14 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { last: [Vertex[], PlanNetwork] | null; } | null = null; - const name = (i: number): string => `지점 ${i + 1}`; + const name = (i: number): string => pointLabel(params.names(), i); + const state = (): PointsState => ({ + points: params.points(), + names: params.names(), + network: params.network(), + }); + const commitState = (next: PointsState): void => + params.commit(next.points, next.names, next.network); /** 핀 집기 — 꼭지 · 머리(보이는 화면 위쪽) 둘레 · 뒤 지점(위에 그려짐) 먼저. */ function pinAt(px: number, py: number, map: ScreenMap): number | null { @@ -88,6 +117,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { } function remove(i: number): void { + openIndex = null; params.commit( params.points().filter((_, k) => k !== i), params.names().filter((_, k) => k !== i), @@ -134,7 +164,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { return true; } - const pins = pointLayer(params.points); + const pins = pointLayer(params.points, params.names); pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false); const input: PlanMapLayer = { @@ -163,12 +193,101 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { }, }; + /** [추가] — 지도 보기 가운데에 바로 놓고 그 줄을 펼침. */ + function addAtCenter(): void { + const at = params.center(); + if (!at || !params.editable() || params.points().length >= MAX_POINTS) return; + openIndex = params.points().length; + commitState(addPoint(state(), at)); + } + + /** 펼친 줄 — 이름 · 경도 · 위도 칸(Enter · 칸 떠남 = 한 걸음 · 잘못 쓴 값은 까닭만 보이고 안 받음). */ + function editorOf(i: number): { root: HTMLElement; sync: () => void } { + const tm = tmParams(params.epsg()); + const editable = params.editable(); + const field = (label: string, role: string, placeholder?: string) => { + const f = createInputField({ label, size: 24, placeholder }); + f.input.dataset.role = role; + f.input.disabled = !editable || (role !== "point-name" && !tm); + return f; + }; + const nameField = field("이름", "point-name", `지점 ${i + 1}`); + const lonField = field("경도", "point-lon"); + const latField = field("위도", "point-lat"); + const lonLatNow = (): [number, number] | null => { + const p = params.points()[i]; + return tm && p ? tmToLonLat(tm, p[0], p[1]) : null; + }; + const sync = (): void => { + const set = (f: ReturnType, text: string): void => { + if (document.activeElement !== f.input) f.input.value = text; + }; + set(nameField, params.names()[i] ?? ""); + const ll = lonLatNow(); + set(lonField, ll ? ll[0].toFixed(6) : ""); + set(latField, ll ? ll[1].toFixed(6) : ""); + }; + nameField.input.addEventListener("input", () => + nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined), + ); + nameField.input.addEventListener("change", () => { + const text = nameField.input.value.trim(); + const error = pointNameError(text); + nameField.setError(error ?? undefined); + if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); + }); + const moved = (): void => { + const parsed = parseLonLat(lonField.input.value, latField.input.value); + const bad = typeof parsed === "string"; + lonField.setError(bad && parsed.startsWith("경도") ? parsed : undefined); + latField.setError(bad && parsed.startsWith("위도") ? parsed : undefined); + if (bad || !tm) return; + const now = lonLatNow(); + if ( + now && + now[0].toFixed(6) === parsed[0].toFixed(6) && + now[1].toFixed(6) === parsed[1].toFixed(6) + ) + return; + commitState(movePointTo(state(), i, tm, parsed[0], parsed[1])); + }; + lonField.input.addEventListener("change", moved); + latField.input.addEventListener("change", moved); + const root = document.createElement("div"); + root.className = "d03-plan__point-edit"; + root.append(nameField.root, lonField.root, latField.root); + sync(); + return { root, sync }; + } + function render(): void { const tm = tmParams(params.epsg()); const editable = params.editable(); + const points = params.points(); + if (openIndex !== null && openIndex >= points.length) openIndex = null; + addButton.hidden = !editable; + addButton.disabled = points.length >= MAX_POINTS || !params.center(); + const key = `${points.length}|${openIndex}|${editable}|${tm ? 1 : 0}`; + const lonLatOf = (i: number): string | undefined => { + const ll = tm ? tmToLonLat(tm, points[i][0], points[i][1]) : null; + return ll ? `${ll[0].toFixed(6)}, ${ll[1].toFixed(6)}` : undefined; + }; + if (key === shown) { + // 틀 그대로 — 이름 · 좌표 글과 펼친 칸 값만 갈아 끼움 + points.forEach((_, i) => { + const row = list.children[i]?.querySelector(".ui-list-row"); + const label = row?.querySelector(".ui-list-row__label"); + const sub = row?.querySelector(".ui-list-row__sub"); + if (label) label.textContent = name(i); + if (sub) sub.textContent = lonLatOf(i) ?? ""; + }); + syncEditor?.(); + return; + } + shown = key; + syncEditor = null; list.replaceChildren( - ...params.points().map((point, i) => { - const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + ...points.map((_, i) => { const drop = createIconButton({ icon: "trash", title: `${name(i)} 지우기`, @@ -176,22 +295,36 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { onClick: () => remove(i), }); drop.hidden = !editable; - return createListRow({ + const row = createListRow({ label: name(i), - sub: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : undefined, + sub: lonLatOf(i), stripe: pointColor(i), size: 28, trailing: drop, + active: i === openIndex, }); + row.dataset.point = String(i); + row.tabIndex = 0; + const toggle = (): void => { + openIndex = openIndex === i ? null : i; + render(); + }; + row.addEventListener("click", (event) => { + if (!(event.target as Element).closest(".ui-list-row__trailing")) toggle(); + }); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) toggle(); + }); + const item = document.createElement("div"); + item.append(row); + if (i === openIndex) { + const editor = editorOf(i); + syncEditor = editor.sync; + item.append(editor.root); + } + return item; }), ); - hint.textContent = !editable - ? params.points().length - ? "보기만 — 지점은 산림청이 놓습니다." - : "지점 없음" - : params.points().length - ? "핀을 끌면 그 자리 노선 점도 같이 옮깁니다 · 지도 우클릭 「지점 놓기」로 더함." - : "지도 우클릭 「지점 놓기」 — 긋기가 붙는 점(없어도 우클릭으로 긋기 시작)."; } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 14f869430..c97b85f4c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -195,3 +195,21 @@ .d03-plan__piece-year { font-weight: 600; } + +/* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ +.d03-plan__points-head { + display: flex; + justify-content: flex-end; + margin-bottom: var(--spacing-8); +} + +.d03-plan__point-edit { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-8) var(--spacing-8) var(--spacing-8) calc(var(--spacing-8) * 2); +} + +.d03-plan__point-edit > .ui-field { + margin: 0; +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts new file mode 100644 index 000000000..06008fd45 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * D03_PlanAnalysis_PointEdit.ts + * D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/). + * 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임. + * 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`). + * ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴. + * ========================================================================== */ + +import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan"; +import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm"; + +/** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ +export const MAX_POINTS = 40; +export const POINT_NAME_MAX = 20; +const BAD_CHARS = /[\\/:*?"<>|]/; + +/** 이름 까닭 글 — 쓸 수 있으면 null. */ +export function pointNameError(name: string): string | null { + if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`; + if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다'; + return null; +} + +/** 보이는 이름 — 빈 값 = 「지점 N」. */ +export function pointLabel(names: string[], index: number): string { + return names[index]?.trim() || `지점 ${index + 1}`; +} + +/** 경위도 글 → 값 · 못 읽거나 범위 밖이면 까닭 글. */ +export function parseLonLat(lonText: string, latText: string): [number, number] | string { + const lon = Number(lonText.trim()); + const lat = Number(latText.trim()); + if (!lonText.trim() || !Number.isFinite(lon) || Math.abs(lon) > 180) return "경도는 −180 ~ 180"; + if (!latText.trim() || !Number.isFinite(lat) || Math.abs(lat) > 90) return "위도는 −90 ~ 90"; + return [lon, lat]; +} + +export interface PointsState { + points: Vertex[]; + names: string[]; + network: PlanNetwork; +} + +/** 지점 더함 — 이름 비움. 상한이면 그대로. */ +export function addPoint(state: PointsState, at: Vertex): PointsState { + if (state.points.length >= MAX_POINTS) return state; + return { + ...state, + points: [...state.points, [at[0], at[1]]], + names: [...state.names, ""], + }; +} + +/** 이름 바꿈 — 빈 값 허용. */ +export function renamePoint(state: PointsState, index: number, name: string): PointsState { + const names = state.points.map((_, k) => state.names[k] ?? ""); + names[index] = name.trim(); + return { ...state, names }; +} + +/** 경위도로 옮김 — 그 자리 노선 노드도 같이. */ +export function movePointTo( + state: PointsState, + index: number, + tm: TmParams, + lon: number, + lat: number, +): PointsState { + const to = lonLatToTm(tm, lon, lat); + const from = state.points[index]; + return { + ...state, + points: state.points.map((p, k): Vertex => (k === index ? to : p)), + network: moveNodesAt(state.network, from, to), + }; +} diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py new file mode 100644 index 000000000..f8f25848f --- /dev/null +++ b/resources/tester/test_597_point_edit.py @@ -0,0 +1,111 @@ +"""597-2 예정노선 지점 칸 손 고침 계산 — 이름 규칙 · 보이는 이름 · 경위도 읽기 · 더함 · 이름 바꿈 · 경위도로 옮김 +(요구 463 7 · 8) — TS `logic/D03_PlanAnalysis_PointEdit.ts`. + +./venv/Scripts/python.exe -m pytest resources/tester/test_597_point_edit.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_PointEdit.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as pe from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.js"; +import * as tmx from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Tm.js"; +const Y = 2026; +const tm = tmx.tmParams(5187); +// 지점 (100,0) 에서 시작하는 선 · 지점 둘 +let n = plan.newLine(plan.emptyNetwork(), [100, 0]); +n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y); +const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n }; +const added = pe.addPoint(state, [7, 8]); +const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n }; +const [lon, lat] = tmx.tmToLonLat(tm, 100, 0); +const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat); +const back = tmx.tmToLonLat(tm, ...moved.points[0]); +writeFileSync(process.argv[2], JSON.stringify({ + errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "ab", "a|b", "x".repeat(20), "x".repeat(21)] + .map((s) => pe.pointNameError(s)), + labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)], + parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")], + added: [added.points, added.names, added.network === n], + full: pe.addPoint(full, [1, 1]) === full, + renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names], + moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names }, +})); +""" + + +@pytest.fixture(scope="module") +def got(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp_path = tmp_path_factory.mktemp("pe") + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), + "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", + "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_name_rule(got: dict) -> None: + ok, bad = got["errors"][:2], got["errors"][2:11] + assert ok == [None, None] # 빈 이름 · 한글 = 됨 + assert all(msg and "못 씁니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 + assert got["errors"][11] is None # 20 자 + assert "20 자" in got["errors"][12] # 21 자 = 막음 + + +def test_label_blank_falls_back(got: dict) -> None: + assert got["labels"] == ["지점 1", "지점 2", "가", "지점 4"] + + +def test_parse_lonlat(got: dict) -> None: + ok, no_lon, bad_lat, big_lon, big_lat = got["parse"] + assert ok == [128.5, 36.9] + assert no_lon.startswith("경도") and bad_lat.startswith("위도") + assert big_lon.startswith("경도") and big_lat.startswith("위도") + + +def test_add_and_limit(got: dict) -> None: + points, names, network_same = got["added"] + assert points == [[100, 0], [500, 500], [7, 8]] and names == ["", "가", ""] + assert network_same # 더해도 노선 그대로 + assert got["full"] # 40 개 = 그대로 + + +def test_rename(got: dict) -> None: + assert got["renamed"][0] == ["", "나"] # 앞뒤 빈칸 뺌 + assert got["renamed"][1] == ["", "다"] # 이름 목록이 짧아도 차례 맞춤 + + +def test_move_by_lonlat_drags_node(got: dict) -> None: + moved = got["moved"] + assert moved["back"] == pytest.approx(moved["expect"], abs=1e-7) # 경위도 왕복 + assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이 + assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로 + assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"] From ff49f2c8a2f4f08e3cdd0a31d9987aad4329015d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:47:39 +0900 Subject: [PATCH 04/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B3=A0=EB=A6=84=20=EC=B8=B5=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=E2=86=92=20=EC=84=A0=20=E2=86=92=20=EA=B5=AC?= =?UTF-8?q?=EC=97=AD=20=E2=86=92=20=EC=A0=84=EC=B2=B4=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=20=ED=95=98=EB=82=98=20=EC=9E=AC=ED=99=9C=EC=9A=A9=20=C2=B7=20?= =?UTF-8?q?=EC=9A=B0=ED=81=B4=EB=A6=AD=20=EB=A9=94=EB=89=B4=20=C2=B7=20?= =?UTF-8?q?=EC=A7=80=EC=A0=90=20=E2=86=94=20=EB=85=B8=EB=93=9C=20=EB=B6=99?= =?UTF-8?q?=EC=9D=8C=20(PLAN=20598-1~3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 logic/D03_PlanAnalysis_Select.ts — 토막 판정 · 끌어 고르기 토막 · 토막 지움(가운데 = 구역 둘 · 뒤쪽 새 구역) · 전체 지움 · 지점 ↔ 노드 같은 자리 - 새 UI_Select.ts — 고름 상태(다시 누름 = 한 단 넓힘 · 같은 해 = 왼쪽 해 줄로만) - UI_Edit.ts — 우클릭 즉시 지움 걷음 · 노드 끌면 지점 같이 · 지점 12 px 안 붙음 · 지움 창구(dropNode · dropRegion · dropAll · splitAt) - UI_Props.ts — 판 = 노드 · 선 · 구역 · 전체 · 구간 · 마커 몸 · [방향 바꾸기] · [갈래 시점 지정] 걷음 - 새 UI_Menu.ts — 우클릭 메뉴(Points 에서 옮김 · 노드 지움 · 지점으로 · 연도 구역 나눔) - UI_Points.ts — 핀 놓을 때 노드 12 px 안 붙음(nodeNear) - key_registry plan 줄 · 시험 585_2 갈래 시점 지정 줄 지움(요구 463-15 걷음) · 553_4 메뉴 자리 · 새 시험 598_1 Co-Authored-By: Claude Opus 5.5 --- A00_Common/key_registry.ts | 11 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts | 526 +++++++++--------- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 107 ++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 24 +- .../D03_PlanAnalysis_UI_Points.ts | 88 ++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 289 +++++++--- .../D03_PlanAnalysis_UI_Select.ts | 236 ++++++++ .../logic/D03_PlanAnalysis_Select.ts | 241 ++++++++ resources/tester/test_553_4_de_parts.py | 7 +- resources/tester/test_585_2_plan_select.py | 21 +- .../tester/test_598_1_plan_select_layers.py | 129 +++++ 11 files changed, 1252 insertions(+), 427 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts create mode 100644 resources/tester/test_598_1_plan_select_layers.py diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index e9e624aa5..ebd58a5f9 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -138,17 +138,18 @@ 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.delete", "plan", ["Delete"], ["고른 것 지움", "Delete selection"]), + 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), A("plan.rotate", "plan", ["Ctrl+휠"], ["지도 돌림", "Rotate map"], F), A("plan.pan", "plan", [PAN], ["화면 옮김", "Pan"], F), - A("plan.vertex", "plan", ["고른 선 누름", "꼭짓점 끌기"], ["꼭짓점 넣고 끌기 · 옮김", "Insert · drag vertex"], F), - A("plan.unvertex", "plan", ["꼭짓점 · 마커 오른쪽 누름"], ["지움", "Delete"], F), + A("plan.select", "plan", ["누름 · 다시 누름"], ["노드 → 선 → 구역 → 전체 고름", "Pick node → segment → region → all"], F), + A("plan.vertex", "plan", ["고른 선 끌기", "노드 끌기"], ["꼭짓점 넣고 끌기 · 노드 옮김(지점 같이)", "Insert · drag vertex (point follows)"], F), + A("plan.unvertex", "plan", ["노드 · 마커 오른쪽 누름"], ["메뉴 [노드 지움] · [마커 지움]", "Menu: delete node · marker"], F), A("plan.measure", "plan", ["재기: 누름 · 누름"], ["시점 · 종점", "Start · end"], F), - A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 시작 · 지점 놓기 · 갈래", "Map menu — draw · place point · branch"], F), - A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이)", "Move point (with its node)"], F), + A("plan.menu", "plan", ["오른쪽 누름"], ["지도 메뉴 — 긋기 · 지점 놓기 · 노드 지움 · 연도 구역 나눔", "Map menu — draw · place point · delete node · split year"], F), + A("plan.point", "plan", ["지점 핀 끌기"], ["지점 옮김(그 자리 노드 같이 · 노드 12 px 안 = 붙음)", "Move point (with its node · snaps within 12 px)"], F), A("b04.zoom", "b04", ["휠"], ["지도 · 3D 확대 · 축소", "Map · 3D zoom"], F), A("b04.pan", "b04", [PAN], ["지도 · 3D 옮김", "Map · 3D pan"], F), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts index abe6298fe..485d07c72 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -1,57 +1,60 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Edit.ts - * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 요구서 440 3 · 6 · 7 · 445 A4 · A5) — 쉴 때(긋기 · 재기 아님)만. + * D03 고르기 · 고치기 겹(PLAN 580 · 585-2 · 598 · 요구 463 9 · 12 · 13·16 · 15) — 쉴 때(긋기 · 재기 아님)만. * - * 고름 = 구간(조각) 묶음 + 넓이(구간 · 선 · 같은 해) — 선 누름 = 그 구간 · 이미 고른 곳 다시 누름 = 넓이 한 단 위 - * (구간 → 선 → 같은 해 → 구간) · Shift+누름 = 구간 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. - * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 보이는 화면 가로 방향) — 오른쪽으로 = 창(실선 · 다 든 구간만) · - * 왼쪽으로 = 걸침(점선 · 걸친 구간까지) · Shift = 지금 고름에 더함. - * 고른 선 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · - * 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · - * Delete = 고른 마크 지움 · 고른 구간이 선 통째면 그 선 지움. - * 연도 구역 마커(585-3) = 도구줄 「연도 마커」를 선 위로 끌어 놓음 = 그 자리 구간을 둘로(해 그대로 · 구역만 나눔 · - * 해는 고른 뒤 속성 판) · 끄는 동안 마름모 미리보기(못 놓음 = 경고 색) · 놓인 마크를 선 밖으로 끌어냄 = 지움(합침). - * 잠금 = 지점에 놓인 점(지점 핀을 끌면 같이 감 · 오른쪽 누름 = 지도 메뉴 591-2) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). - * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 · 넓이 = 화면 기억(저장 안 함). - * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 `select` · `setScope` · 동작 셋을 부름). + * 고름 층 = 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체(`UI_Select`) — 노드 누름 = 노드 · 선 위 누름 = 토막 · + * 고른 것 다시 누름 = 한 단 넓힘(전체 다음 = 처음) · Shift+누름 = 토막 더함 · 뺌 · 빈 곳 누름 · Esc = 풂. + * 「같은 해」 = 왼쪽 해 줄로만(`selectYear`). + * 빈 곳 왼쪽 끌기 = 끌어 고르기(오토캐드 꼴 · 토막) — 오른쪽으로 = 창(실선 · 다 든 토막만) · + * 왼쪽으로 = 걸침(점선 · 걸친 토막까지) · Shift = 지금 고름에 더함. + * 노드 끌기 = 옮김(붙은 갈래 첫 점 같이) · 지점 자리 노드 = 지점도 같이 · 지점 12 px 안에 놓으면 그 자리로 붙음 · + * 고른 토막 위 끌기 = 꺾임점 더해 옮김(누름만은 넓힘) · 마크 누름 = 뒤 구간 고름 + 마크 밝힘 · 끌기 = 옮김 · + * 선 밖으로 끌어냄 = 지움(합침). + * 지움 = Delete · 속성 판 · 왼쪽 목록 · 우클릭 메뉴가 같은 함수 — 노드 · 토막(가운데 = 구역 둘) · 구역 · 전체 · 마크. + * 우클릭 = 메뉴(`UI_Menu` — 이 겹은 오른쪽 누름을 안 받음). + * 연도 구역 마커(585-3) = 「연도 마커」를 선 위로 끌어 놓음 · 메뉴 「여기서 연도 구역 나눔」(`splitAt`) = 그 자리 구간을 둘로. + * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음 · 고름 = 화면 기억(저장 안 함). + * 속성 판 = `D03_PlanAnalysis_UI_Props`(고름을 읽고 동작을 부름). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { themeColor } from "@ui/ui_template_palette"; import { keyIs } from "../A00_Common/key_registry"; -import { - distanceAlong, - lineLength, - pointAtDistance, - sliceLine, -} from "./logic/D03_PlanAnalysis_Draw"; +import { distanceAlong, pointAtDistance } from "./logic/D03_PlanAnalysis_Draw"; import { insertVertex, moveMark, + moveNodesAt, moveVertex, - piecesInBox, removeLine, removeMark, removeVertex, - reverseLine, samePoint, splitPiece, - branchFromEnd, type PlanNetwork, type Vertex, type ViewBox, } from "./logic/D03_PlanAnalysis_Plan"; +import { + movePointsAt, + nodeSegment, + removeAll, + removeSegments, + segmentsInBox, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; import { lineAt, paintMark, planLabel, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { createPlanSelect, type PlanScope, type PlanSelection } from "./D03_PlanAnalysis_UI_Select"; + +export type { PlanScope, PlanSelection } from "./D03_PlanAnalysis_UI_Select"; /** 끌기로 볼 마우스 움직임(px) — 이보다 적으면 누름. */ const DRAG_PX = 4; /** 마크를 선에서 이만큼(px) 넘게 끌어내면 지움. */ const MARK_OFF_PX = 3 * SNAP_PX; -export type PlanScope = "piece" | "line" | "year"; -const NEXT_SCOPE: Record = { piece: "line", line: "year", year: "piece" }; - export interface PlanEditParams { network: () => PlanNetwork; /** 지점(긋기에 붙는 점 · 591-2). */ @@ -59,6 +62,9 @@ export interface PlanEditParams { commit: (next: PlanNetwork) => void; /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ show: (next: PlanNetwork) => void; + /** 지점 자리 노드를 끎 — 지점 · 노선 한 걸음 · 화면만. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + showPoints: (points: Vertex[], next: PlanNetwork) => void; /** 고르기 받는 때(쉴 때 · 재기 아님). */ enabled: () => boolean; /** 고치기 됨(산림청). */ @@ -67,151 +73,123 @@ export interface PlanEditParams { onSelect: () => void; } -/** 고름 — 구간 id 묶음 · 넓이 · 넓힘 씨(넓이를 바꿀 때 기준 구간) · 고른 마크(그 마크가 여는 구간 id). */ -export interface PlanSelection { - pieces: string[]; - scope: PlanScope; - seed: string | null; +/** 누른 자리 — 마크(구간 id) · 노드 · 토막(선 위 · 노드면 넓힐 토막). */ +export interface PlanHit { mark: string | null; + node: PlanNode | null; + segment: PlanSegment | null; } export interface PlanEdit { layer: PlanMapLayer; selected: () => PlanSelection | null; - /** 구간 id 묶음 고름(빈 묶음 = 풂) · 넓이 = 구간. */ + /** 밝힐 꺾은선(고른 토막 · 구역 · 전체 · 구간). */ + paths: () => Vertex[][]; + /** 구간 id 묶음 고름(빈 묶음 = 풂) · 마크 고름. */ select: (pieces: string[], mark?: string | null) => void; - /** 씨 구간 기준 넓이 바꿈(구간 · 선 · 같은 해). */ + /** 구간 고름 넓이 바꿈(구간 · 선 · 같은 해 — 왼쪽 목록). */ setScope: (scope: PlanScope) => void; - /** 고른 구간이 든 선 하나 — 방향 바꾸기 · 지움(선 둘 이상 · 없음 = 까닭 글). */ - flipLine: () => void; - dropLine: () => void; + selectNode: (node: PlanNode) => void; + selectRegion: (line: string) => void; + selectYear: (year: number) => void; + selectAll: () => void; + /** 고른 것 지움(Delete · 판 [지움]). */ + dropSelected: () => void; + dropNode: (node: PlanNode) => void; dropMark: () => void; - /** 갈래 시점 지정 — 다른 선에 닿은 끝을 시점으로 그 선의 갈래(585-3). */ - branchLine: () => void; - /** 고른 구간이 모두 한 선이면 그 선 차례(아니면 −1). */ - lineIndex: () => number; + dropRegion: (line: string) => void; + /** 전체 지움 — 선 · 구역 모두(지점 남김) · 되돌리기 한 걸음. */ + dropAll: () => void; + /** 이 자리 선 위에 연도 구역 마커 — 놓음 = 참. */ + splitAt: (px: number, py: number, map: ScreenMap) => boolean; + /** 이 자리에 무엇이 있나(우클릭 메뉴). */ + hit: (px: number, py: number, map: ScreenMap) => PlanHit; /** 키 — 받았으면 참(Delete · Esc). */ key: (event: KeyboardEvent) => boolean; - /** 되돌리기 · 저장 뒤 — 사라진 구간은 뺌 · 다 사라지면 풂. */ + /** 되돌리기 · 저장 뒤 — 사라진 것은 뺌 · 다 사라지면 풂. */ sync: () => void; /** 검증 창구 — 지금 끄는 상자(보이는 px · 방향). */ box: () => (ViewBox & { crossing: boolean }) | null; } type Drag = - | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } - | { kind: "locked"; note: string; told: boolean } - /** 고른 구간 다시 누름 — 놓으면 넓힘 · 끌면(고칠 수 있을 때) 꺾임점 더해 옮김. */ | { - kind: "grow"; - piece: string; + kind: "vertex"; + line: number; + vertex: number; + /** 끌기 앞 자리 · 같이 가는 지점(같은 자리)이 있나. */ + from: Vertex; + follow: boolean; + before: PlanNetwork; + points: Vertex[]; + last: [Vertex[], PlanNetwork] | null; + } + | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } + /** 노드 · 선 누름 — 놓으면(고른 것이었으면) 넓힘 · 끌면 노드 옮김 · 고른 토막이면 꺾임점 더해 옮김. */ + | { + kind: "press"; x: number; y: number; - hit: { line: number; segment: number; point: Vertex } | null; + node: PlanNode | null; + on: { line: number; segment: number; point: Vertex } | null; + grow: boolean; } /** 빈 곳 끌기 — 보이는 px 시작 · 지금. */ | { kind: "box"; box: ViewBox; shift: boolean }; export function createPlanEdit(params: PlanEditParams): PlanEdit { - let selected: PlanSelection | null = null; + const sel = createPlanSelect(params.network, params.onSelect); let drag: Drag | null = null; /** 「연도 마커」 끄는 중 미리보기 — 놓일 자리 · 놓을 수 있음 · 글. */ let markDrop: { point: Vertex; ok: boolean; text: string } | null = null; - const pieceIds = (network: PlanNetwork): Set => - new Set(network.pieces.map((piece) => piece.id)); + const lineIndexOf = (network: PlanNetwork, id: string): number => + network.lines.findIndex((line) => line.id === id); - /** 씨 구간 → 넓이만큼 구간 묶음. */ - function expand(seed: string, scope: PlanScope): string[] { - const network = params.network(); - const piece = network.pieces.find((p) => p.id === seed); - if (!piece) return []; - if (scope === "piece") return [seed]; - return network.pieces - .filter((p) => (scope === "line" ? p.line === piece.line : p.year === piece.year)) - .map((p) => p.id); - } - - function choose(next: PlanSelection | null): void { - selected = next && next.pieces.length ? next : null; - params.onSelect(); - } - - function select(pieces: string[], mark: string | null = null): void { - choose({ pieces: [...new Set(pieces)], scope: "piece", seed: pieces[0] ?? null, mark }); - } - - function setScope(scope: PlanScope): void { - const seed = selected?.seed ?? selected?.pieces[0]; - if (!seed) return; - choose({ pieces: expand(seed, scope), scope, seed, mark: null }); - } - - function lineIndex(): number { - const network = params.network(); - if (!selected) return -1; - const lines = new Set( - selected.pieces.map((id) => network.pieces.find((p) => p.id === id)?.line), - ); - if (lines.size !== 1) return -1; - const [id] = lines; - return network.lines.findIndex((line) => line.id === id); - } - - /** 고른 구간의 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ - function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { - const network = params.network(); - const index = lineIndex(); + /** 꼴 바꿈 하나 — 까닭 글이면 알림. */ + function apply(next: PlanNetwork | string): void { if (!params.editable()) return; - if (index < 0) return void showToast("선 하나의 구간만 골랐을 때 됩니다.", "info"); - const next = step(network, index); if (typeof next === "string") return void showToast(next, "info"); params.commit(next); - sync(); + sel.sync(); } function dropMark(): void { const network = params.network(); - const piece = network.pieces.find((p) => p.id === selected?.mark); - if (!piece || !params.editable()) return; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); + const piece = network.pieces.find((p) => p.id === sel.get()?.mark); + if (piece) apply(removeMark(network, piece.line, piece.from_m, 1e-3)); } - /** Delete — 마크 · 선 통째 고름 = 그 선. */ - function dropSelected(): void { - if (selected?.mark) return dropMark(); + function dropNode(node: PlanNode): void { const network = params.network(); - const index = lineIndex(); - const whole = - index >= 0 && - network.pieces - .filter((p) => p.line === network.lines[index].id) - .every((p) => selected!.pieces.includes(p.id)); - if (!whole) - return void showToast("선 넓이로 고르면 지웁니다(다시 누름 · 속성 판 [선]).", "info"); - apply((net, i) => removeLine(net, i)); + const index = lineIndexOf(network, node.line); + if (index >= 0) apply(removeVertex(network, index, node.vertex)); } - /** 사라진 구간 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onSelect 를 부르면 돌고 돎). */ - function sync(): void { - if (!selected) return; - const ids = pieceIds(params.network()); - const pieces = selected.pieces.filter((id) => ids.has(id)); - selected = pieces.length - ? { - ...selected, - pieces, - seed: selected.seed && ids.has(selected.seed) ? selected.seed : pieces[0], - mark: selected.mark && ids.has(selected.mark) ? selected.mark : null, - } - : null; + function dropRegion(line: string): void { + const network = params.network(); + const index = lineIndexOf(network, line); + if (index >= 0) apply(removeLine(network, index)); } - const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => - network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6) - ?.id ?? null; + function dropAll(): void { + const network = params.network(); + if (!network.lines.length) return; + apply(removeAll(network)); + showToast("노선을 모두 지웠습니다 — 되돌리기(Ctrl+Z) · [저장] 때 정본.", "info"); + } + + /** Delete · 판 [지움] — 고름 꼴마다. */ + function dropSelected(): void { + const picked = sel.get(); + if (!picked) return; + if (picked.kind === "mark") return dropMark(); + if (picked.kind === "node") return dropNode(picked.node!); + if (picked.kind === "segment") return apply(removeSegments(params.network(), picked.segments)); + if (picked.kind === "line") return dropRegion(picked.line!); + if (picked.kind === "all") return dropAll(); + showToast("연도 구간은 지우지 않습니다 — 노드 · 선 · 구역으로 고르세요.", "info"); + } /** 화면 px 둘레 마크(조각 시작) — 조각 차례. */ function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null { @@ -245,12 +223,33 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { return best; } - /** 잠금 까닭 — 지점에 놓인 점. */ - function lockOf(network: PlanNetwork, line: number, vertex: number): string | null { - const point = network.lines[line].vertices[vertex]; - return params.base().some((p) => samePoint(p, point)) - ? "지점 자리 점은 지점 핀을 끌어 옮깁니다." - : null; + /** 지점 12 px 안이면 그 자리(노드가 붙음) — `skip` 자리 지점(같이 가는 것) 빼고. */ + function pointNear(px: number, py: number, map: ScreenMap, skip: Vertex): Vertex | null { + const near = params + .base() + .filter((p) => !samePoint(p, skip)) + .map((p) => ({ p, gap: screenGap(map, p, px, py) })) + .filter((item) => item.gap <= SNAP_PX) + .sort((a, b) => a.gap - b.gap)[0]; + return near ? near.p : null; + } + + function hit(px: number, py: number, map: ScreenMap): PlanHit { + const network = params.network(); + const mark = markAt(network, px, py, map); + if (mark !== null) return { mark: network.pieces[mark].id, node: null, segment: null }; + const vertex = vertexAt(network, px, py, map); + if (vertex) { + const node = { line: network.lines[vertex.line].id, vertex: vertex.vertex }; + return { mark: null, node, segment: nodeSegment(network, node) }; + } + const on = lineAt(network, px, py, map); + if (!on) return { mark: null, node: null, segment: null }; + return { + mark: null, + node: null, + segment: { line: network.lines[on.line].id, index: on.segment }, + }; } /** 「연도 마커」를 이 자리에 놓으면 — 다음 꼴 · 새 뒤 구간 id(못 놓음 = null). */ @@ -260,46 +259,56 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { map: ScreenMap, ): { point: Vertex; next: PlanNetwork | null; piece: string | null } { const network = params.network(); - const hit = lineAt(network, px, py, map); - if (!hit) return { point: map.toMetric(px, py), next: null, piece: null }; - const next = splitPiece(network, network.lines[hit.line].id, hit.atM); + const on = lineAt(network, px, py, map); + if (!on) return { point: map.toMetric(px, py), next: null, piece: null }; + const next = splitPiece(network, network.lines[on.line].id, on.atM); const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); - return { point: hit.point, next: added ? next : null, piece: added?.id ?? null }; + return { point: on.point, next: added ? next : null, piece: added?.id ?? null }; } - /** 구간 누름 — Shift = 더함 · 뺌 · 이미 고른 곳 = 넓힘(놓을 때) · 아니면 그 구간. */ - function pressPiece( - piece: string, - shift: boolean, - x: number, - y: number, - hit: { line: number; segment: number; point: Vertex } | null, - ): void { - if (shift && selected) { - const has = selected.pieces.includes(piece); - const pieces = has - ? selected.pieces.filter((id) => id !== piece) - : [...selected.pieces, piece]; - return choose({ pieces, scope: "piece", seed: has ? pieces[0] : piece, mark: null }); + function splitAt(px: number, py: number, map: ScreenMap): boolean { + if (!params.enabled() || !params.editable()) return false; + const place = markPlace(px, py, map); + if (!place.next || !place.piece) { + showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"); + return false; } - if (selected?.pieces.includes(piece) && !selected.mark) { - drag = { kind: "grow", piece, x, y, hit }; - return; - } - select([piece]); + params.commit(place.next); + sel.pieces([place.piece], place.piece); + return true; } - /** 고른 선 꼭짓점 손잡이 그릴 선(고른 구간이 든 선). */ - const pickedLines = (network: PlanNetwork): Set => - new Set( - (selected?.pieces ?? []).map((id) => network.pieces.find((p) => p.id === id)?.line ?? ""), - ); + /** 노드 끌기 시작 — 지점 자리면 지점도 같이. */ + function startVertex(line: number, vertex: number): Drag { + const before = params.network(); + const from = before.lines[line].vertices[vertex]; + const points = params.base(); + const follow = points.some((p) => samePoint(p, from)); + return { kind: "vertex", line, vertex, from, follow, before, points, last: null }; + } + + /** 고른 노드 · 토막 · 구역이 든 선(꺾임점 손잡이). */ + function pickedLines(network: PlanNetwork): Set { + const picked = sel.get(); + if (!picked) return new Set(); + if (picked.kind === "all") return new Set(network.lines.map((line) => line.id)); + const ids = picked.segments.map((seg) => seg.line); + for (const id of picked.pieces) ids.push(network.pieces.find((p) => p.id === id)?.line ?? ""); + if (picked.line) ids.push(picked.line); + return new Set(ids); + } const layer: PlanMapLayer = { draw(context, map) { const network = params.network(); const colors = planColors(); - const path = (points: Vertex[]): void => { + const picked = sel.get(); + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = colors.active; + context.globalAlpha = 0.8; + context.lineWidth = 8; + for (const points of sel.paths()) { context.beginPath(); points.forEach((point, i) => { const [sx, sy] = map.toScreen(point); @@ -307,19 +316,9 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { else context.moveTo(sx, sy); }); context.stroke(); - }; - context.lineCap = "round"; - context.lineJoin = "round"; - context.strokeStyle = colors.active; - context.globalAlpha = 0.8; - context.lineWidth = 8; - for (const id of selected?.pieces ?? []) { - const piece = network.pieces.find((p) => p.id === id); - const vertices = network.lines.find((line) => line.id === piece?.line)?.vertices; - if (piece && vertices) path(sliceLine(vertices, piece.from_m, piece.to_m)); } context.globalAlpha = 1; - // 꺾임점 손잡이(고른 구간이 든 선) + // 꺾임점 손잡이(고른 것이 든 선) const lines = pickedLines(network); context.lineWidth = 2; context.fillStyle = "#ffffff"; @@ -331,11 +330,16 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { context.strokeRect(sx - 4, sy - 4, 8, 8); } } - // 고른 마크 테 - const mark = network.pieces.find((p) => p.id === selected?.mark); + // 고른 노드 · 마크 테 + const node = picked?.kind === "node" ? picked.node : null; + const nodePoint = node + ? network.lines.find((line) => line.id === node.line)?.vertices[node.vertex] + : null; + const mark = network.pieces.find((p) => p.id === picked?.mark); const markLine = network.lines.find((line) => line.id === mark?.line)?.vertices; - if (mark && markLine) { - const [sx, sy] = map.toScreen(pointAtDistance(markLine, mark.from_m)); + const ring = nodePoint ?? (mark && markLine ? pointAtDistance(markLine, mark.from_m) : null); + if (ring) { + const [sx, sy] = map.toScreen(ring); context.beginPath(); context.arc(sx, sy, 12, 0, Math.PI * 2); context.lineWidth = 3; @@ -378,48 +382,28 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { } }, press(px, py, map, button, shift) { - if (!params.enabled()) return false; + if (!params.enabled() || button !== 0) return false; // 우클릭 = 메뉴(UI_Menu) const network = params.network(); - const editable = params.editable(); const mark = markAt(network, px, py, map); - const vertex = vertexAt(network, px, py, map); - if (button === 2) { - if (!editable) return false; - if (mark !== null) { - const piece = network.pieces[mark]; - params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); - sync(); - return true; - } - if (!vertex || lockOf(network, vertex.line, vertex.vertex)) return false; // 지점 = 지도 메뉴 - const next = removeVertex(network, vertex.line, vertex.vertex); - if (typeof next === "string") showToast(next, "info"); - else params.commit(next); - sync(); - return true; - } if (mark !== null) { const piece = network.pieces[mark]; - select([piece.id], piece.id); - if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; + sel.pieces([piece.id], piece.id); + if (params.editable()) drag = { kind: "mark", index: mark, before: network, last: null }; return true; } - if (vertex) { - const id = network.lines[vertex.line].id; - const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); - const piece = pieceAt(network, id, atM); - if (piece && !selected?.pieces.includes(piece)) pressPiece(piece, shift, px, py, null); - const lock = lockOf(network, vertex.line, vertex.vertex); - if (editable && !shift) - drag = lock - ? { kind: "locked", note: lock, told: false } - : { kind: "vertex", ...vertex, before: network, last: null }; - return true; - } - const hit = lineAt(network, px, py, map); - if (hit) { - const piece = pieceAt(network, network.lines[hit.line].id, hit.atM); - if (piece) pressPiece(piece, shift, px, py, hit); + const at = hit(px, py, map); + if (at.segment) { + if (shift) return (sel.segments([at.segment], true), true); + const grow = sel.holds({ + node: at.node ?? undefined, + segment: at.node ? undefined : at.segment, + }); + if (!grow) { + if (at.node) sel.node(at.node); + else sel.segments([at.segment]); + } + const on = at.node ? null : lineAt(network, px, py, map); + drag = { kind: "press", x: px, y: py, node: at.node, on, grow }; return true; } const [vx, vy] = map.rerotate(px, py); @@ -432,31 +416,34 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { [drag.box.x1, drag.box.y1] = map.rerotate(px, py); return; } - if (drag.kind === "grow") { + if (drag.kind === "press") { if (Math.hypot(px - drag.x, py - drag.y) <= DRAG_PX) return; - const hit = drag.hit; - if (!params.editable() || !hit) return void (drag = null); - params.commit(insertVertex(params.network(), hit.line, hit.segment, hit.point)); - const before = params.network(); - drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; - } - if (drag.kind === "locked") { - if (!drag.told) showToast(drag.note, "info"); - drag.told = true; - return; + const { node, on, grow } = drag; + const network = params.network(); + if (!params.editable()) return void (drag = null); + if (node) drag = startVertex(lineIndexOf(network, node.line), node.vertex); + else if (grow && on) { + params.commit(insertVertex(network, on.line, on.segment, on.point)); + drag = startVertex(on.line, on.segment + 1); + } else return void (drag = null); } const cursor = map.toMetric(px, py); if (drag.kind === "vertex") { - drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor); - } else { - const piece = drag.before.pieces[drag.index]; - const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; - const atM = distanceAlong(vertices, cursor); - const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; - drag.last = off - ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) - : moveMark(drag.before, drag.index, atM); + const to = pointNear(px, py, map, drag.from) ?? cursor; + drag.last = drag.follow + ? [movePointsAt(drag.points, drag.from, to), moveNodesAt(drag.before, drag.from, to)] + : [drag.points, moveVertex(drag.before, drag.line, drag.vertex, to)]; + params.showPoints(...drag.last); + return; } + if (drag.kind !== "mark") return; + const piece = drag.before.pieces[drag.index]; + const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; + const atM = distanceAlong(vertices, cursor); + const off = screenGap(map, pointAtDistance(vertices, atM), px, py) > MARK_OFF_PX; + drag.last = off + ? removeMark(drag.before, piece.line, piece.from_m, 1e-3) + : moveMark(drag.before, drag.index, atM); params.show(drag.last); }, dragOver(px, py, map) { @@ -476,11 +463,7 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { drop(px, py, map) { markDrop = null; if (!params.enabled() || !params.editable()) return false; - const place = markPlace(px, py, map); - if (!place.next || !place.piece) - return (void showToast("선 위(끝 · 다른 마커 1 m 밖)에 놓으세요.", "info"), true); - params.commit(place.next); - select([place.piece], place.piece); + splitAt(px, py, map); return true; }, leave() { @@ -489,56 +472,83 @@ export function createPlanEdit(params: PlanEditParams): PlanEdit { release(map) { const done = drag; drag = null; - if (!done || done.kind === "locked") return; - if (done.kind === "grow") { - if (selected) { - const scope = NEXT_SCOPE[selected.scope]; - choose({ pieces: expand(done.piece, scope), scope, seed: done.piece, mark: null }); - } + if (!done) return; + if (done.kind === "press") { + if (done.grow) sel.grow(); return; } if (done.kind === "box") { const { box, shift } = done; if (Math.hypot(box.x1 - box.x0, box.y1 - box.y0) <= DRAG_PX) { - if (!shift) choose(null); + if (!shift) sel.clear(); return; } const network = params.network(); const toView = (point: Vertex): Vertex => map.rerotate(...map.toScreen(point)); - const ids = piecesInBox(network, toView, box, box.x1 < box.x0); - select(shift && selected ? [...selected.pieces, ...ids] : ids); + const found = segmentsInBox(network, toView, box, box.x1 < box.x0); + const now = sel.get(); + sel.segments(shift && now?.kind === "segment" ? [...now.segments, ...found] : found); return; } if (!done.last) return; - params.show(done.before); - params.commit(done.last); - sync(); + if (done.kind === "vertex") { + params.showPoints(done.points, done.before); + params.commitPoints(...done.last); + } else { + params.show(done.before); + params.commit(done.last); + } + sel.sync(); }, }; + /** 왼쪽 목록 넓이 — 씨 구간(처음 고른 구간) 기준. */ + function setScope(scope: PlanScope): void { + const network = params.network(); + const seed = network.pieces.find((p) => p.id === sel.get()?.pieces[0]); + if (!seed) return; + if (scope === "line") return sel.region(seed.line); + if (scope === "piece") return sel.pieces([seed.id]); + const same = network.pieces.filter((p) => p.year === seed.year).map((p) => p.id); + sel.pieces([seed.id, ...same], null, "year"); + } + return { layer, - selected: () => selected, - select, + selected: sel.get, + paths: sel.paths, + select: (pieces, mark = null) => sel.pieces(pieces, mark), setScope, - flipLine: () => apply((network, index) => reverseLine(network, index)), - dropLine: () => apply((network, index) => removeLine(network, index)), + selectNode: sel.node, + selectRegion: sel.region, + selectYear(year) { + const ids = params + .network() + .pieces.filter((p) => p.year === year) + .map((p) => p.id); + sel.pieces(ids, null, "year"); + }, + selectAll: sel.all, + dropSelected, + dropNode, dropMark, - branchLine: () => apply((network, index) => branchFromEnd(network, index)), - lineIndex, + dropRegion, + dropAll, + splitAt, + hit, key(event) { - if (!selected || !params.enabled()) return false; + if (!sel.get() || !params.enabled()) return false; if (keyIs(event, "plan.delete") && params.editable()) { dropSelected(); return true; } if (event.key === "Escape") { - choose(null); + sel.clear(); return true; } return false; }, - sync, + sync: sel.sync, box: () => (drag?.kind === "box" ? { ...drag.box, crossing: drag.box.x1 < drag.box.x0 } : null), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts new file mode 100644 index 000000000..a121b8816 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -0,0 +1,107 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Menu.ts + * D03 지도 우클릭 메뉴(PLAN 591-2 · 598-2 · 요구 463 16 · 22) — 부품 상자 `createMapContextMenu` 하나 · 누른 자리마다 항목. + * 긋는 중 = 긋기 끝 · 핀 = 그 지점에서 긋기 · 지우기 · 마커 = 마커 지움 · + * 노드 = 노드 지움 · 지점으로 · 이어 긋기 / 갈래 시작 · 선 위 = 갈래 시작 · 여기서 연도 구역 나눔 · 다시 긋기(599) · + * 빈 땅 = 여기서 긋기 시작 · 핀 · 노드 밖 = 지점 놓기(노드 둘레면 그 노드에 붙음). + * 우클릭 즉시 지움 없음 — 지움은 메뉴 · 고르고 Delete. B05 우클릭(`bindNodeSplice`)과 코드 안 나눔. + * 겹 = 맨 끝(맨 위) · 오른쪽 누름만 받음. + * ========================================================================== */ + +import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; +import { samePoint, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; +import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; +import type { PlanPoints } from "./D03_PlanAnalysis_UI_Points"; + +export interface PlanMenuParams { + network: () => PlanNetwork; + points: () => Vertex[]; + marks: PlanPoints; + edit: PlanEdit; + draw: PlanDraw; + editable: () => boolean; + /** [다시 긋기](599 · 없으면 항목 없음) — 구역 id · 앞 노드 · 뒤 노드. */ + respan?: (line: string, from: number, to: number) => boolean; +} + +export interface PlanMenu { + layer: PlanMapLayer; + /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ + mount: (viewport: HTMLElement) => void; +} + +export function createPlanMenu(params: PlanMenuParams): PlanMenu { + const { marks, edit, draw } = params; + const menu = createMapContextMenu("d03-plan"); + let menuAt: [number, number] = [0, 0]; + + const layer: PlanMapLayer = { + draw: () => undefined, + press(px, py, map, button) { + if (button !== 2 || !params.editable()) return false; + const items: MapContextMenuItem[] = []; + const pin = marks.pinAt(px, py, map); + const at = pin === null ? edit.hit(px, py, map) : null; + const start = (x: number, y: number): void => { + const label = { + tip: "이어 긋기", + point: "지점에서 긋기 시작", + line: "갈래 시작", + empty: "여기서 긋기 시작", + }[draw.aim(x, y, map)]; + items.push([label, () => draw.startAt(x, y, map)]); + }; + if (draw.mode() === "draw" && draw.stroke()) + items.push(["긋기 끝", () => draw.setMode("idle")]); + else if (pin !== null) { + const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 + items.push([`${marks.name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); + items.push([`${marks.name(pin)} 지우기`, () => marks.remove(pin)]); + } else if (at?.mark) { + const mark = at.mark; + items.push([ + "마커 지움", + () => { + edit.select([mark], mark); + edit.dropMark(); + }, + ]); + } else if (at?.node) { + const node = at.node; + const point = params.network().lines.find((l) => l.id === node.line)!.vertices[node.vertex]; + const [sx, sy] = map.toScreen(point); + start(sx, sy); + items.push("-", ["노드 지움", () => edit.dropNode(node)]); + if (!marks.full() && !params.points().some((p) => samePoint(p, point))) + items.push(["지점으로", () => marks.place(sx, sy, map)]); + } else if (at?.segment) { + const seg = at.segment; + start(px, py); + items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); + if (params.respan) + items.push(["다시 긋기", () => params.respan!(seg.line, seg.index, seg.index + 1)]); + } else start(px, py); + if (pin === null && !at?.node && !at?.mark && !marks.full()) + items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); + menu.open(menuAt[0], menuAt[1], items); + return true; + }, + }; + + return { + layer, + mount(viewport) { + viewport.append(menu.element); + viewport.addEventListener( + "contextmenu", + (event) => { + const rect = viewport.getBoundingClientRect(); + menuAt = [event.clientX - rect.left, event.clientY - rect.top]; + }, + true, + ); + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 22a3d7f5d..40422ea60 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -55,6 +55,7 @@ import { type PlanMapLayer, } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; +import { createPlanMenu } from "./D03_PlanAnalysis_UI_Menu"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; @@ -139,6 +140,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network = normalizePieces(next, yearStart); map.redraw(); }, + commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }), + showPoints: (nextPoints, next) => { + [points, network] = [nextPoints, next]; + map.redraw(); + }, enabled: () => draw.mode() === "idle" && !measuring, editable: () => editable, onSelect: () => refresh(), @@ -149,6 +155,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable: () => editable, yearStart: () => yearStart, commit, + heightAt: () => z, + epsg: () => epsg, + points: () => points, + addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }), }); const pieces = createPlanPieces({ network: () => network, edit }); const measure = createPlanMeasure(() => z); @@ -157,7 +167,6 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network: () => network, names: () => pointNames, epsg: () => epsg, - draw, commit: (nextPoints, nextNames, next) => step({ network: next, points: nextPoints, pointNames: nextNames }), center: () => viewMid, @@ -168,6 +177,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { idle: () => draw.mode() === "idle" && !measuring, editable: () => editable, }); + const menu = createPlanMenu({ + network: () => network, + points: () => points, + marks, + edit, + draw, + editable: () => editable, + }); // 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄) const viewWatch: PlanMapLayer = { draw(context, screen) { @@ -307,7 +324,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { refresh(); } - /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림 · 우클릭 메뉴(`marks.pins`)는 맨 끝(맨 위). */ + /** 겹 차례 — 핀 누름(`marks.input`)은 고르기보다 앞 · 핀 그림(`marks.pins`) · 우클릭 메뉴(`menu.layer`)는 맨 끝(맨 위). */ const layerList = (): PlanMapLayer[] => [ ...backdrop.layers, draw.layer, @@ -316,6 +333,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ...(measuring ? [measure.layer] : []), viewWatch, marks.pins, + menu.layer, ]; /** 점들이 받은 범위 안인가. */ @@ -553,7 +571,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }; window.addEventListener("keydown", onKey); map.setLayers(layerList()); - marks.mount(map.root); + menu.mount(map.root); try { editable = !!(await fetchSessionUser())?.is_forest_service; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 7565f8f35..4a0842ec7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -2,21 +2,23 @@ * D03_PlanAnalysis_UI_Points.ts * D03 지점(PLAN 591-2 · 요구 452 · 453) — 옛 기준 지정(D02) 지점 마킹을 노선분석으로. * 지점 = 긋기에 붙는 점 · 목록 하나(줄 왼 띠 = 지점 색 `pointColor` · 「지점 N」 · 🗑) · 핀 = 늘 맨 위(`pointLayer`). - * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). - * 지도 우클릭 = 메뉴(부품 상자 `createMapContextMenu`) — 핀: 여기서 긋기 · 지움 / 그 밖: 긋기 시작(빈 땅 = 새 본선 · - * 선 위 = 갈래 · 선 끝 = 이어 긋기) · 지점 놓기(꺾임점 둘레면 그 점에) · 긋는 중 = 긋기 끝. - * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 우클릭 메뉴 · 겹 맨 끝 = 맨 위 · - * 꺾임점 · 마크 오른쪽 누름 = 고르기 겹이 먼저 지움). + * 핀 끌기 = 지점 + 그 자리 노선 노드 같이(`moveNodesAt`) · 노드 12 px 안에 놓으면 그 노드에 붙음(598-3) · + * 놓을 때 한 걸음(되돌리기 · 캐시 = 페이지). + * 지도 우클릭 메뉴 = `D03_PlanAnalysis_UI_Menu`(핀 집기 · 놓기 · 지우기를 여기서 빌림). + * 겹 둘 — `input`(핀 누름 · 끌기 · 고르기 겹보다 앞) · `pins`(그림 · 겹 맨 끝 = 맨 위). * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; -import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; import { createText } from "@ui/ui_template_text"; -import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + moveNodesAt, + samePoint, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; -import type { PlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { pointLayer, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; @@ -31,7 +33,6 @@ export interface PlanPointsParams { names: () => string[]; network: () => PlanNetwork; epsg: () => number; - draw: PlanDraw; /** 한 걸음 — 지점 · 이름 · 노선 함께. */ commit: (points: Vertex[], names: string[], network: PlanNetwork) => void; /** 지도 보기 가운데(작업 좌표 · 못 잼 = null) — [추가] 자리. */ @@ -49,8 +50,12 @@ export interface PlanPoints { input: PlanMapLayer; pins: PlanMapLayer; render: () => void; - /** 메뉴를 띄울 지도 그릇(우클릭 자리 = 그릇 기준). */ - mount: (viewport: HTMLElement) => void; + /** 우클릭 메뉴가 빌림 — 핀 집기 · 지점 놓기(노드 둘레면 그 노드) · 지우기 · 이름 · 더 놓을 수 있나. */ + pinAt: (px: number, py: number, map: ScreenMap) => number | null; + place: (px: number, py: number, map: ScreenMap) => void; + remove: (i: number) => void; + name: (i: number) => string; + full: () => boolean; } export function createPlanPoints(params: PlanPointsParams): PlanPoints { @@ -60,8 +65,6 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { hint.classList.add("d03-plan__hint"); const panel = document.createElement("div"); panel.append(list, hint); - const menu = createMapContextMenu("d03-plan"); - let menuAt: [number, number] = [0, 0]; let drag: { index: number; points: Vertex[]; @@ -95,47 +98,26 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { ); } - /** 지점 놓기 — 꺾임점 둘레(SNAP_PX)면 그 점 자리(노드에 붙음). */ - function place(px: number, py: number, map: ScreenMap): void { - if (params.points().length >= MAX_POINTS) return; + /** 붙음(598-3) — 화면 px 둘레(SNAP_PX) 가장 가까운 노드 자리 · `skip` 자리 노드(같이 가는 것) 빼고. */ + function nodeNear(px: number, py: number, map: ScreenMap, skip?: Vertex): Vertex | null { const node = params .network() .lines.flatMap((line) => line.vertices) + .filter((point) => !skip || !samePoint(point, skip)) .map((point) => ({ point, gap: screenGap(map, point, px, py) })) .filter((item) => item.gap <= SNAP_PX) .sort((a, b) => a.gap - b.gap)[0]; - const at = node?.point ?? map.toMetric(px, py); + return node ? node.point : null; + } + + /** 지점 놓기 — 노드 둘레면 그 노드 자리(노드에 붙음). */ + function place(px: number, py: number, map: ScreenMap): void { + if (params.points().length >= MAX_POINTS) return; + const at = nodeNear(px, py, map) ?? map.toMetric(px, py); params.commit([...params.points(), [at[0], at[1]]], [...params.names(), ""], params.network()); } - function openMenu(px: number, py: number, map: ScreenMap): boolean { - if (!params.editable()) return false; - const draw = params.draw; - const pin = pinAt(px, py, map); - const items: MapContextMenuItem[] = []; - if (draw.mode() === "draw" && draw.stroke()) - items.push(["긋기 끝", () => draw.setMode("idle")]); - else if (pin !== null) { - const [sx, sy] = map.toScreen(params.points()[pin]); // 핀 머리를 눌러도 지점 자리에서 - items.push([`${name(pin)}에서 긋기 시작`, () => draw.startAt(sx, sy, map)]); - items.push([`${name(pin)} 지우기`, () => remove(pin)]); - } else { - const label = { - tip: "이어 긋기", - point: "지점에서 긋기 시작", - line: "갈래 시작", - empty: "여기서 긋기 시작", - }[draw.aim(px, py, map)]; - items.push([label, () => draw.startAt(px, py, map)]); - } - if (pin === null && params.points().length < MAX_POINTS) - items.push(["지점 놓기", () => place(px, py, map)]); - menu.open(menuAt[0], menuAt[1], items); - return true; - } - const pins = pointLayer(params.points); - pins.press = (px, py, map, button) => (button === 2 ? openMenu(px, py, map) : false); const input: PlanMapLayer = { draw: () => undefined, @@ -148,8 +130,8 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { }, hover(px, py, map) { if (!drag) return; - const to = map.toMetric(px, py); const from = drag.points[drag.index]; + const to = nodeNear(px, py, map, from) ?? map.toMetric(px, py); const points = drag.points.map((p, k): Vertex => (k === drag!.index ? to : p)); drag.last = [points, moveNodesAt(drag.network, from, to)]; params.show(...drag.last); @@ -199,16 +181,10 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { input, pins, render, - mount(viewport) { - viewport.append(menu.element); - viewport.addEventListener( - "contextmenu", - (event) => { - const rect = viewport.getBoundingClientRect(); - menuAt = [event.clientX - rect.left, event.clientY - rect.top]; - }, - true, - ); - }, + pinAt, + place, + remove, + name, + full: () => params.points().length >= MAX_POINTS, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 0efadf9c6..3c567223c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -1,30 +1,43 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Props.ts - * D03 고른 구간 속성 판 · 좌측 「구역 · 연도」 목록(PLAN 585-2 · 요구서 445 A4) — 오토캐드 특성 창 꼴. + * D03 고름 속성 판(PLAN 585-2 · 598 · 요구 463 13·16 · 11) — 오토캐드 특성 창 꼴 · 판 하나가 고름 꼴마다 몸을 바꿈. * - * 속성 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 — - * 머리 「구간 N · 선 M · L m」 · ✕ = 풂 · 넓이 [구간][선][같은 해] · 연도(모두 같으면 그 해 · 다르면 「여러 해」 → - * 고르면 고른 구간 모두 그 해 = 되돌리기 한 걸음) · 고른 구간 줄(누름 = 그 구간만) · - * [방향 바꾸기] [갈래 시점 지정] [선 지움](선 하나일 때) · [마크 지움](마크 고름) · 보기만 계정 = 값만. - * 측점 방향(585-3 · 요구 446 3) = 사용자가 정함 — 본선 = [방향 바꾸기] · 다른 선에 닿은 끝 = [갈래 시점 지정] - * (그 점이 시점 · 꼬리에 닿은 머리 = 한 선으로 이음) · 자동 판정 없음. + * 판 = 지도 위 떠 있는 판(공용 `createFloatPanel` · 머리 끌어 옮김 · 오른쪽 위) · 고름 있을 때만 · ✕ = 풂. + * 노드 — 경위도 · 지반고 · 측점 · 앞 선 길이 · 기울기 · [노드 지움] [지점으로](그 자리에 지점 · 이미 있으면 숨김). + * 선(토막) — 측점 · 길이 · 지반 기울기 · 든 연도 · [다시 긋기](하나 · 599) [선 지움](가운데 = 구역 둘). + * 구역 — 길이 · 노드 수 · 연도(구역 모든 구간) · 구간 줄 · [구역 지움]. + * 전체 — 길이 · 구역 수 · 해마다 길이 · [전체 지움](되돌리기 됨). + * 연도 구간(왼쪽 해 · 구간 줄) — 연도(모두 같으면 그 해 · 다르면 「여러 해」) · 구간 줄 · 마커 — 앞뒤 해 · [마커 지움]. + * 연도 고름 = 고른 구간 모두 그 해 = 되돌리기 한 걸음 · 보기만 계정 = 값만. * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ import { createButton } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createSelectField } from "@ui/ui_template_input"; -import { createSegment } from "@ui/ui_template_segment"; +import { createKv, type KvRow } from "@ui/ui_template_kv"; import { stationLabel } from "../common_util/common_util_station"; -import { lineNames, setPiecesYear, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; -import type { PlanEdit, PlanScope } from "./D03_PlanAnalysis_UI_Edit"; +import { groundSlopePct, lineLength, type HeightAt } from "./logic/D03_PlanAnalysis_Draw"; +import { + lineNames, + samePoint, + setPiecesYear, + type PlanNetwork, + type PlanPiece, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select"; +import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; +import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; +import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; /** 조각 연도 고를 수 — 사업연도 시작부터. */ const YEAR_SPAN = 10; const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, 1); const meters = (m: number): string => `${m.toFixed(1)} m`; +const percent = (pct: number | null): string => (pct === null ? "-" : `${pct.toFixed(1)} %`); export interface PlanPropsParams { network: () => PlanNetwork; @@ -32,6 +45,13 @@ export interface PlanPropsParams { editable: () => boolean; yearStart: () => number; commit: (next: PlanNetwork) => void; + heightAt: () => HeightAt; + epsg: () => number; + /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ + points: () => Vertex[]; + addPoint: (point: Vertex) => void; + /** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */ + respan?: (line: string, from: number, to: number) => boolean; } export interface PlanProps { @@ -46,7 +66,7 @@ export interface PlanProps { export function createPlanProps(params: PlanPropsParams): PlanProps { const { edit } = params; const float = createFloatPanel({ - title: "고른 구간", + title: "고름", draggable: true, onClose: () => edit.select([]), }); @@ -61,104 +81,203 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); } - function renderPanel(): void { - const picked = edit.selected(); - float.root.hidden = !picked; - if (!picked) return float.body.replaceChildren(); - const network = params.network(); - const names = lineNames(network.lines); - const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); - const lineIds = new Set(pieces.map((piece) => piece.line)); - const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); - const title = float.root.querySelector(".ui-float-panel__title"); - if (title) title.textContent = `구간 ${pieces.length} · 선 ${lineIds.size} · ${meters(total)}`; - const editable = params.editable(); - - const scope = createSegment({ - options: [ - { value: "piece", label: "구간" }, - { value: "line", label: "선" }, - { value: "year", label: "같은 해" }, - ], - value: picked.scope, - size: 24, - ariaLabel: "고름 넓이", - onChange: (value) => edit.setScope(value as PlanScope), - }); - scope.root.dataset.role = "props-scope"; + const button = (label: string, role: string, onClick: () => void): HTMLButtonElement => { + const b = createButton({ label, variant: "ghost", size: 24, onClick }); + b.dataset.role = role; + return b; + }; + /** 연도 고름 — 구간 모두 그 해(되돌리기 한 걸음). */ + function yearField(pieces: PlanPiece[]): HTMLElement { const years = new Set(pieces.map((piece) => piece.year)); const options = yearOptions(); + const ids = pieces.map((piece) => piece.id); const year = createSelectField({ label: "연도", options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, value: years.size > 1 ? "" : String(pieces[0]?.year ?? ""), size: 24, ariaLabel: "고른 구간 연도", - disabled: !editable, + disabled: !params.editable(), onChange: (value) => - value && params.commit(setPiecesYear(params.network(), picked.pieces, Number(value))), + value && params.commit(setPiecesYear(params.network(), ids, Number(value))), }); year.select.dataset.role = "props-year"; + return year.root; + } + /** 구간 줄 — 누름 = 그 구간만. */ + function pieceRows(network: PlanNetwork, pieces: PlanPiece[]): HTMLElement { + const names = lineNames(network.lines); const rows = document.createElement("div"); rows.className = "d03-plan__props-rows"; for (const piece of pieces) { const index = network.lines.findIndex((line) => line.id === piece.line); - const row = createButton({ - label: - `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + + const row = button( + `${names[index]} ${station(piece.from_m)}~${station(piece.to_m)} · ` + `${meters(piece.to_m - piece.from_m)} · ${piece.year}년`, - variant: "ghost", - size: 24, - onClick: () => edit.select([piece.id]), - }); + "props-piece", + () => edit.select([piece.id]), + ); row.dataset.piece = piece.id; rows.append(row); } + return rows; + } - const actions = document.createElement("div"); - actions.className = "d03-plan__row"; - const index = edit.lineIndex(); - if (editable && index >= 0) { - const flip = createButton({ - label: "방향 바꾸기", - variant: "ghost", - size: 24, - onClick: edit.flipLine, - }); - flip.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; - flip.disabled = !!network.lines[index].branch; - flip.dataset.role = "props-flip"; - const drop = createButton({ - label: "선 지움", - variant: "ghost", - size: 24, - onClick: edit.dropLine, - }); - drop.dataset.role = "props-drop"; - const branch = createButton({ - label: "갈래 시점 지정", - variant: "ghost", - size: 24, - onClick: edit.branchLine, - }); - branch.title = "다른 선에 닿은 끝을 시점(No.0)으로 · 그 선의 갈래로 붙입니다."; - branch.hidden = !!network.lines[index].branch; - branch.dataset.role = "props-branch"; - actions.append(flip, branch, drop); + /** 고름 꼴마다 — 머리 글 · 몸 · 단추. */ + function body( + picked: PlanSelection, + network: PlanNetwork, + ): { title: string; parts: HTMLElement[]; actions: HTMLButtonElement[] } { + const names = lineNames(network.lines); + const nameOf = (id: string | null): string => + names[network.lines.findIndex((line) => line.id === id)] ?? ""; + const z = params.heightAt(); + const pieces = network.pieces.filter((piece) => picked.pieces.includes(piece.id)); + const length = selectionLength(edit.paths()); + + if (picked.kind === "node") { + const { line, vertex } = picked.node!; + const vertices = network.lines.find((l) => l.id === line)!.vertices; + const point = vertices[vertex]; + const tm = tmParams(params.epsg()); + const lonLat = tm ? tmToLonLat(tm, point[0], point[1]) : null; + const height = z(point[0], point[1]); + const rows: KvRow[] = [ + { + label: "경위도", + value: lonLat ? `${lonLat[0].toFixed(6)}, ${lonLat[1].toFixed(6)}` : null, + }, + { label: "지반고", value: Number.isFinite(height) ? `${height.toFixed(2)} m` : null }, + { label: "측점", value: station(lineLength(vertices.slice(0, vertex + 1))) }, + ]; + if (vertex > 0) { + const [a, b] = [vertices[vertex - 1], vertices[vertex]]; + rows.push( + { label: "앞 선 길이", value: meters(lineLength([a, b])) }, + { label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) }, + ); + } + const toPoint = button("지점으로", "props-to-point", () => + params.addPoint([point[0], point[1]]), + ); + toPoint.hidden = params.points().some((p) => samePoint(p, point)); + return { + title: `${nameOf(line)} · 노드 ${vertex + 1}`, + parts: [createKv(rows)], + actions: [ + button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), + toPoint, + ], + }; } - if (editable && picked.mark) { - const dropMark = createButton({ - label: "마크 지움", - variant: "ghost", - size: 24, - onClick: edit.dropMark, - }); - dropMark.dataset.role = "props-drop-mark"; - actions.append(dropMark); + + if (picked.kind === "segment") { + const single = picked.segments.length === 1 ? picked.segments[0] : null; + const years = [ + ...new Set( + segmentPieces(network, picked.segments).map( + (id) => network.pieces.find((p) => p.id === id)?.year, + ), + ), + ].sort(); + const rows: KvRow[] = [{ label: "길이", value: meters(length) }]; + if (single) { + const [from, to] = segmentRange(network, single)!; + const [a, b] = segmentPath(network, single)!; + rows.unshift({ label: "측점", value: `${station(from)} ~ ${station(to)}` }); + rows.push({ label: "지반 기울기", value: percent(groundSlopePct(a, b, z)) }); + } + rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); + const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; + if (single && params.respan) + actions.unshift( + button("다시 긋기", "props-respan", () => + params.respan!(single.line, single.index, single.index + 1), + ), + ); + return { + title: single + ? `${nameOf(single.line)} · 선 ${single.index + 1}` + : `선 ${picked.segments.length}개`, + parts: [createKv(rows)], + actions, + }; } - float.body.replaceChildren(scope.root, year.root, rows, actions); + + if (picked.kind === "line") { + const vertices = network.lines.find((l) => l.id === picked.line)?.vertices ?? []; + return { + title: nameOf(picked.line), + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "노드", value: `${vertices.length}개` }, + ]), + yearField(pieces), + pieceRows(network, pieces), + ], + actions: [button("구역 지움", "props-drop-line", () => edit.dropRegion(picked.line!))], + }; + } + + if (picked.kind === "all") { + const byYear = new Map(); + for (const p of network.pieces) + byYear.set(p.year, (byYear.get(p.year) ?? 0) + p.to_m - p.from_m); + return { + title: "전체", + parts: [ + createKv([ + { label: "길이", value: meters(length) }, + { label: "구역", value: `${network.lines.length}개` }, + ...[...byYear] + .sort(([a], [b]) => a - b) + .map(([year, m]) => ({ label: `${year}년`, value: meters(m) })), + ]), + ], + actions: [button("전체 지움", "props-drop-all", edit.dropAll)], + }; + } + + // 연도 구간 · 마커 + const lineIds = new Set(pieces.map((piece) => piece.line)); + const total = pieces.reduce((sum, piece) => sum + piece.to_m - piece.from_m, 0); + const parts: HTMLElement[] = [yearField(pieces), pieceRows(network, pieces)]; + const actions: HTMLButtonElement[] = []; + const mark = network.pieces.find((p) => p.id === picked.mark); + if (mark) { + const before = network.pieces.find( + (p) => p.line === mark.line && Math.abs(p.to_m - mark.from_m) < 1e-3, + ); + parts.unshift( + createKv([ + { label: "측점", value: station(mark.from_m) }, + { label: "앞 · 뒤 해", value: `${before?.year ?? "-"}년 · ${mark.year}년` }, + ]), + ); + actions.push(button("마커 지움", "props-drop-mark", edit.dropMark)); + } + return { + title: `구간 ${pieces.length} · 구역 ${lineIds.size} · ${meters(total)}`, + parts, + actions, + }; + } + + function renderPanel(): void { + const picked = edit.selected(); + float.root.hidden = !picked; + if (!picked) return float.body.replaceChildren(); + const { title, parts, actions } = body(picked, params.network()); + const head = float.root.querySelector(".ui-float-panel__title"); + if (head) head.textContent = title; + float.root.dataset.kind = picked.kind; + const row = document.createElement("div"); + row.className = "d03-plan__row"; + if (params.editable()) row.append(...actions); + float.body.replaceChildren(...parts, row); } return { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts new file mode 100644 index 000000000..282f6cc13 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Select.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Select.ts + * D03 고름 상태(PLAN 598-1 · 요구 463 9 · 13) — 노드 → 선(토막) → 구역 → 전체 · 연도 구간(왼쪽 해 줄) · 마커. + * 넓힘 = 같은 것을 다시 누름 — 씨(처음 누른 노드 · 토막)에서 한 단 위 · 전체 다음 = 씨로 돌아옴. + * 「같은 해」 = 왼쪽 목록 해 줄로만(`year`). + * 상태 = 화면 기억(저장 안 함) · 꼴이 바뀌면 `sync` 가 사라진 것을 뺌. + * ========================================================================== */ + +import { lineLength, sliceLine } from "./logic/D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + lineSegments, + nodeSegment, + sameSegment, + segmentPath, + segmentPieces, + type PlanNode, + type PlanSegment, +} from "./logic/D03_PlanAnalysis_Select"; + +export type PlanSelectKind = "node" | "segment" | "line" | "all" | "pieces" | "mark"; +/** 왼쪽 목록 밝힘 꼴(구간 · 선 · 같은 해). */ +export type PlanScope = "piece" | "line" | "year"; + +export interface PlanSelection { + kind: PlanSelectKind; + /** 노드 고름 · 노드 씨. */ + node: PlanNode | null; + /** 토막 고름(Shift · 끌어 고르기 = 여럿). */ + segments: PlanSegment[]; + /** 구역 고름 · 씨가 든 구역. */ + line: string | null; + /** 고름에 든 연도 구간 id(구간 · 마커 고름 = 그대로 · 그 밖 = 걸친 구간). */ + pieces: string[]; + /** 고른 마커(그 마커가 여는 구간 id). */ + mark: string | null; + scope: PlanScope; + /** 넓힘 씨 — 처음 누른 노드 · 토막. */ + seed: { node: PlanNode } | { segment: PlanSegment } | null; +} + +const NEXT: Partial> = { + node: "segment", + segment: "line", + line: "all", +}; + +export interface PlanSelect { + get: () => PlanSelection | null; + clear: () => void; + node: (node: PlanNode) => void; + /** 토막 — `toggle` = 지금 토막 고름에 더함 · 뺌. */ + segments: (segments: PlanSegment[], toggle?: boolean) => void; + region: (line: string) => void; + all: () => void; + /** 연도 구간 — 마커 고름 = 그 마커가 여는 구간. */ + pieces: (ids: string[], mark?: string | null, scope?: PlanScope) => void; + /** 넓힘 한 단(씨 기준). */ + grow: () => void; + /** 이 노드 · 토막이 지금 고름 안인가(다시 누름 = 넓힘). */ + holds: (hit: { node?: PlanNode; segment?: PlanSegment }) => boolean; + /** 밝힐 꺾은선. */ + paths: () => Vertex[][]; + sync: () => void; +} + +export function createPlanSelect(network: () => PlanNetwork, onChange: () => void): PlanSelect { + let selected: PlanSelection | null = null; + + function piecesOf(next: Omit): string[] { + const net = network(); + if (next.kind === "segment") return segmentPieces(net, next.segments); + if (next.kind === "line") + return net.pieces.filter((p) => p.line === next.line).map((p) => p.id); + if (next.kind === "all") return net.pieces.map((p) => p.id); + return []; + } + + function choose( + next: Omit & Partial, + quiet = false, + ): void { + selected = { + ...next, + pieces: next.pieces ?? piecesOf(next), + scope: next.scope ?? (next.kind === "line" ? "line" : "piece"), + }; + if (!quiet) onChange(); + } + + const empty = { node: null, segments: [], line: null, mark: null, seed: null }; + + function node(at: PlanNode): void { + choose({ ...empty, kind: "node", node: at, line: at.line, seed: { node: at } }); + } + + function segments(list: PlanSegment[], toggle = false): void { + let next = list; + if (toggle && selected?.kind === "segment") { + const has = (s: PlanSegment): boolean => selected!.segments.some((t) => sameSegment(s, t)); + next = [ + ...selected.segments.filter((s) => !list.some((t) => sameSegment(s, t))), + ...list.filter((s) => !has(s)), + ]; + } + if (!next.length) return clear(); + choose({ + ...empty, + kind: "segment", + segments: next, + line: next[0].line, + seed: { segment: next[0] }, + }); + } + + function clear(): void { + selected = null; + onChange(); + } + + /** 씨 기준 한 단 — 노드 → 토막 → 구역 → 전체 → 씨. */ + function grow(): void { + if (!selected?.seed) return; + const seed = selected.seed; + const next = NEXT[selected.kind]; + const line = "node" in seed ? seed.node.line : seed.segment.line; + if (next === "segment") { + const seg = "node" in seed ? nodeSegment(network(), seed.node) : seed.segment; + if (seg) choose({ ...empty, kind: "segment", segments: [seg], line, seed }); + } else if (next === "line") choose({ ...empty, kind: "line", line, seed }); + else if (next === "all") choose({ ...empty, kind: "all", line, seed }); + else if ("node" in seed) node(seed.node); + else choose({ ...empty, kind: "segment", segments: [seed.segment], line, seed }); + } + + function holds(hit: { node?: PlanNode; segment?: PlanSegment }): boolean { + const s = selected; + if (!s || s.kind === "pieces" || s.kind === "mark") return false; + if (s.kind === "all") return true; + const line = hit.node?.line ?? hit.segment?.line; + if (s.kind === "line") return line === s.line; + if (s.kind === "node") + return !!hit.node && hit.node.line === s.node!.line && hit.node.vertex === s.node!.vertex; + if (hit.segment) return s.segments.some((t) => sameSegment(t, hit.segment!)); + return s.segments.some( + (t) => + t.line === hit.node!.line && + (t.index === hit.node!.vertex || t.index + 1 === hit.node!.vertex), + ); + } + + function paths(): Vertex[][] { + const s = selected; + const net = network(); + if (!s || s.kind === "node") return []; + if (s.kind === "segment") + return s.segments + .map((seg) => segmentPath(net, seg)) + .filter((p): p is [Vertex, Vertex] => !!p); + if (s.kind === "line") return net.lines.filter((l) => l.id === s.line).map((l) => l.vertices); + if (s.kind === "all") return net.lines.map((l) => l.vertices); + return s.pieces.flatMap((id) => { + const piece = net.pieces.find((p) => p.id === id); + const v = net.lines.find((l) => l.id === piece?.line)?.vertices; + return piece && v ? [sliceLine(v, piece.from_m, piece.to_m)] : []; + }); + } + + /** 사라진 것 뺌 — 부르는 쪽(페이지 `refresh`)이 다시 그림(여기서 onChange 를 부르면 돌고 돎). */ + function sync(): void { + const s = selected; + if (!s) return; + const net = network(); + const vertexCount = (line: string | null): number => + net.lines.find((l) => l.id === line)?.vertices.length ?? 0; + const okNode = (n: PlanNode): boolean => n.vertex < vertexCount(n.line); + const okSeg = (g: PlanSegment): boolean => g.index + 1 < vertexCount(g.line); + if (s.kind === "pieces" || s.kind === "mark") { + const ids = new Set(net.pieces.map((p) => p.id)); + const pieces = s.pieces.filter((id) => ids.has(id)); + selected = pieces.length + ? { ...s, pieces, mark: s.mark && ids.has(s.mark) ? s.mark : null } + : null; + if (selected?.kind === "mark" && !selected.mark) selected = { ...selected, kind: "pieces" }; + return; + } + const seed = + s.seed && ("node" in s.seed ? okNode(s.seed.node) : okSeg(s.seed.segment)) ? s.seed : null; + const segments = s.segments.filter(okSeg); + const alive = { + all: net.lines.length > 0, + line: vertexCount(s.line) > 0, + node: !!s.node && okNode(s.node), + segment: segments.length > 0, + }[s.kind]; + if (alive) choose({ ...s, segments, seed, pieces: undefined }, true); + else selected = null; + } + + return { + get: () => selected, + clear, + node, + segments, + region: (line) => choose({ ...empty, kind: "line", line, seed: lineSeed(line) }), + all: () => choose({ ...empty, kind: "all", seed: null }), + pieces(ids, mark = null, scope = "piece") { + const list = [...new Set(ids)]; + if (!list.length) return clear(); + const net = network(); + choose({ + ...empty, + kind: mark ? "mark" : "pieces", + line: net.pieces.find((p) => p.id === list[0])?.line ?? null, + pieces: list, + mark, + scope, + }); + }, + grow, + holds, + paths, + sync, + }; + + /** 구역 씨 = 첫 토막(구역에서 넓힘 = 전체 → 첫 토막). */ + function lineSeed(line: string): { segment: PlanSegment } | null { + const first = lineSegments(network(), line)[0]; + return first ? { segment: first } : null; + } +} + +/** 고름 길이(m) — 토막 · 구역 · 전체 · 구간. */ +export function selectionLength(paths: Vertex[][]): number { + return paths.reduce((sum, path) => sum + lineLength(path), 0); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts new file mode 100644 index 000000000..bea304984 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -0,0 +1,241 @@ +/* ============================================================================= + * D03_PlanAnalysis_Select.ts + * D 예정노선 고름 층 셈(PLAN 598 · 요구 463 9 · 12 · 13·16 · 15) — 노드 → 선(두 노드 사이 토막) → 구역(그은 꺾은선) → 전체. + * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. + * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). + * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. + * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). + * ========================================================================== */ + +import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw"; +import { + piecesInBox, + removeLine, + samePoint, + orderLines, + type PlanLine, + type PlanNetwork, + type PlanPiece, + type Vertex, + type ViewBox, +} from "./D03_PlanAnalysis_Plan"; + +/** 선(토막) — 구역 id · 토막 차례(꼭짓점 `index` → `index + 1`). */ +export interface PlanSegment { + line: string; + index: number; +} + +/** 노드 — 구역 id · 꼭짓점 차례. */ +export interface PlanNode { + line: string; + vertex: number; +} + +const EPS = 1e-6; +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** 꺾임점마다 선 위 거리. */ +export function chainages(vertices: ReadonlyArray): number[] { + const out = [0]; + for (let i = 1; i < vertices.length; i += 1) + out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i])); + return out; +} + +export const sameSegment = (a: PlanSegment, b: PlanSegment): boolean => + a.line === b.line && a.index === b.index; + +/** 구역 토막 모두. */ +export function lineSegments(network: PlanNetwork, line: string): PlanSegment[] { + const entry = network.lines.find((l) => l.id === line); + const count = Math.max(0, (entry?.vertices.length ?? 0) - 1); + return Array.from({ length: count }, (_, index) => ({ line, index })); +} + +/** 노선망 토막 모두. */ +export const allSegments = (network: PlanNetwork): PlanSegment[] => + network.lines.flatMap((line) => lineSegments(network, line.id)); + +/** 토막 꼭짓점 둘(없으면 null). */ +export function segmentPath(network: PlanNetwork, seg: PlanSegment): [Vertex, Vertex] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + return v && seg.index >= 0 && seg.index + 1 < v.length ? [v[seg.index], v[seg.index + 1]] : null; +} + +/** 토막 선 위 거리 [시작, 끝](m). */ +export function segmentRange(network: PlanNetwork, seg: PlanSegment): [number, number] | null { + const v = network.lines.find((l) => l.id === seg.line)?.vertices; + if (!v || seg.index < 0 || seg.index + 1 >= v.length) return null; + const from = lineLength(v.slice(0, seg.index + 1)); + return [from, from + segmentLength(v[seg.index], v[seg.index + 1])]; +} + +/** 선 위 거리 `atM` 이 든 토막 차례(꺾임점 위 = 뒤 토막 · 끝 = 마지막). */ +export function segmentAt(vertices: ReadonlyArray, atM: number): number { + const c = chainages(vertices); + for (let i = 0; i + 1 < c.length; i += 1) if (atM < c[i + 1] - EPS) return i; + return Math.max(0, c.length - 2); +} + +/** 노드에서 넓힐 토막 — 뒤 토막(끝 노드 = 앞 토막). */ +export function nodeSegment(network: PlanNetwork, node: PlanNode): PlanSegment | null { + const n = network.lines.find((l) => l.id === node.line)?.vertices.length ?? 0; + if (n < 2) return null; + return { line: node.line, index: Math.min(node.vertex, n - 2) }; +} + +/** 토막에 걸친 조각 id(연도 구간). */ +export function segmentPieces(network: PlanNetwork, segs: ReadonlyArray): string[] { + const out = new Set(); + for (const seg of segs) { + const range = segmentRange(network, seg); + if (!range) continue; + for (const piece of network.pieces) + if (piece.line === seg.line && piece.from_m < range[1] - EPS && piece.to_m > range[0] + EPS) + out.add(piece.id); + } + return [...out]; +} + +/** 끌어 고르기 — 토막마다 판정(창 = 두 끝 모두 안 · 걸침 = 상자와 만남 · `piecesInBox` 와 같은 셈). */ +export function segmentsInBox( + network: PlanNetwork, + toView: (point: Vertex) => Vertex, + box: ViewBox, + crossing: boolean, +): PlanSegment[] { + const segs = allSegments(network); + const probe: PlanNetwork = { + lines: segs.map((seg, i) => ({ + id: `s${i}`, + vertices: segmentPath(network, seg)!, + branch: null, + })), + pieces: segs.map((seg, i) => { + const [a, b] = segmentPath(network, seg)!; + return { id: String(i), line: `s${i}`, from_m: 0, to_m: segmentLength(a, b), year: 0 }; + }), + }; + return piecesInBox(probe, toView, box, crossing).map((i) => segs[Number(i)]); +} + +/** 새 구역 id — `line-`(있는 것과 안 겹침). */ +function nextLineId(lines: ReadonlyArray): string { + const used = new Set(lines.map((line) => line.id)); + let n = lines.length; + while (used.has(`line-${n}`)) n += 1; + return `line-${n}`; +} + +/** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ +function nextPieceId(pieces: ReadonlyArray): string { + const used = pieces.map((piece) => Number(/^p(\d+)$/.exec(piece.id)?.[1] ?? 0)); + return `p${Math.max(0, ...used) + 1}`; +} + +/** 꼭짓점 [a, b) 만 남긴 선 — 꼭짓점과 같은 길이 덧 칸(노드 기록 594-1)도 같이 자름 · 첫 기록 = 없음. */ +function cutVertices(entry: PlanLine, a: number, b: number): PlanLine { + const out: PlanLine & { nodes?: unknown[] } = { ...entry, vertices: entry.vertices.slice(a, b) }; + const nodes = (entry as { nodes?: unknown }).nodes; + if (Array.isArray(nodes) && nodes.length === entry.vertices.length) { + out.nodes = nodes.slice(a, b); + if (a > 0 && out.nodes.length) out.nodes[0] = null; + } + return out; +} + +/** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) · + * 사라지는 노드에 갈래가 붙었으면 못 지움(까닭 글) · 조각 id 는 앞쪽이 지님(걸친 조각은 뒤쪽 새 id). */ +export function removeSegment(network: PlanNetwork, seg: PlanSegment): PlanNetwork | string { + const li = network.lines.findIndex((l) => l.id === seg.line); + const entry = network.lines[li]; + const n = (entry?.vertices.length ?? 0) - 1; + if (!entry || seg.index < 0 || seg.index >= n) return "고른 선이 없습니다."; + if (n === 1) return removeLine(network, li); + const i = seg.index; + const keepFront = i > 0; + const keepBack = i < n - 1; + const children = network.lines.filter((l) => l.branch?.line === entry.id); + if ( + children.some( + (l) => (!keepFront && l.branch!.vertex === 0) || (!keepBack && l.branch!.vertex === n), + ) + ) + return "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요."; + const backId = keepFront ? nextLineId(network.lines) : entry.id; + const c = chainages(entry.vertices); + const [cut0, cut1] = [c[i], c[i + 1]]; + + const front: PlanPiece[] = []; + const back: PlanPiece[] = []; + for (const piece of network.pieces.filter((p) => p.line === entry.id)) { + if (keepFront && piece.from_m < cut0 - EPS) + front.push({ ...piece, to_m: round3(Math.min(piece.to_m, cut0)) }); + if (keepBack && piece.to_m > cut1 + EPS) + back.push({ + ...piece, + line: backId, + from_m: round3(Math.max(0, piece.from_m - cut1)), + to_m: round3(piece.to_m - cut1), + }); + } + const others = network.pieces.filter((p) => p.line !== entry.id); + for (const piece of back) + if (front.some((p) => p.id === piece.id)) + piece.id = nextPieceId([...others, ...front, ...back]); + + const lines: PlanLine[] = []; + for (const line of network.lines) { + if (line === entry) { + if (keepFront) lines.push(cutVertices(entry, 0, i + 1)); + if (keepBack) lines.push({ ...cutVertices(entry, i + 1, n + 1), id: backId, branch: null }); + continue; + } + const b = line.branch; + if (b?.line === entry.id && b.vertex > i) + lines.push({ ...line, branch: { line: backId, vertex: b.vertex - (i + 1) } }); + else lines.push(line); + } + return { ...network, lines: orderLines(lines), pieces: [...others, ...front, ...back] }; +} + +/** 토막 여럿 지움 — 구역마다 뒤 토막부터(앞 토막 차례가 안 바뀜) · 하나라도 못 지우면 까닭 글. */ +export function removeSegments( + network: PlanNetwork, + segs: ReadonlyArray, +): PlanNetwork | string { + let next = network; + const order = [...segs].sort((a, b) => + a.line === b.line ? b.index - a.index : a.line < b.line ? -1 : 1, + ); + for (const seg of order) { + const out = removeSegment(next, seg); + if (typeof out === "string") return out; + next = out; + } + return next; +} + +/** 전체 지움 — 선 · 조각 모두(지점은 부르는 쪽이 남김). */ +export const removeAll = (network: PlanNetwork): PlanNetwork => ({ + ...network, + lines: [], + pieces: [], +}); + +/** `point` 자리 노드 모두(갈래 첫 점 = 붙은 선 점이 대신 · 빼고). */ +export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] { + const out: PlanNode[] = []; + for (const line of network.lines) + line.vertices.forEach((v, vertex) => { + if (!(vertex === 0 && line.branch) && samePoint(v, point)) + out.push({ line: line.id, vertex }); + }); + return out; +} + +/** `from` 자리 지점을 모두 `to` 로(지점 ↔ 노드 따라감). */ +export function movePointsAt(points: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { + return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p)); +} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index b6ff5235e..2741be267 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -17,10 +17,11 @@ def _no_hex(path: str) -> None: def test_d03_points_parts(): - """591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품.""" + """591-2 — D02 걷음 · 지점 칸 = 목록 줄 · 아이콘 단추 · 우클릭 메뉴 부품(598-2 = `UI_Menu`).""" assert not (ROOT / "D02_PlanBase" / "D02_PlanBase_UI_Page.ts").exists() ts = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts") - assert "createListRow(" in ts and "createIconButton(" in ts and "createMapContextMenu(" in ts + assert "createListRow(" in ts and "createIconButton(" in ts + assert "createMapContextMenu(" in _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts") for raw in ('createElement("button")', 'createElement("input")', 'el("input"'): assert raw not in ts @@ -78,7 +79,7 @@ def test_d03_parts(): assert ".d03-plan__layers" in css and ".d03-plan__legend" in css assert "createSegment({" in draw and draw.count("createInputField(") == 2 props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판 - assert "createSelectField(" in props and "createFloatPanel(" in props + assert "createSelectField(" in props and "createFloatPanel(" in props and "createKv(" in props assert 'createElement("button")' not in props and 'createElement("select")' not in props assert 'createElement("select")' not in three and "createSelectField" in three assert "createCheckbox" not in three and "buildCorridor" not in three # 예상 코리도 걷음 diff --git a/resources/tester/test_585_2_plan_select.py b/resources/tester/test_585_2_plan_select.py index fc4e4c4f9..4e9ec9e1c 100644 --- a/resources/tester/test_585_2_plan_select.py +++ b/resources/tester/test_585_2_plan_select.py @@ -1,5 +1,5 @@ -"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 · 갈래 시점 지정 -(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. +"""585-2 · 585-3 예정노선 끌어 고르기 판정 · 여러 구간 연도 · 연도 구역 마커 나눔 · 합침 +(요구 445 A4~A6 · 446) — TS `logic/D03_PlanAnalysis_Plan.ts`. 갈래 시점 지정은 걷음(요구 463-15 · PLAN 598-3). 창(오른쪽으로) = 구간 꺾은선 점이 모두 상자 안 · 걸침(왼쪽으로) = 점 하나라도 안 또는 토막이 상자 변과 만남 · 돌린 화면도 보이는 px 로 셈 · 여러 구간 연도 한 번에. @@ -46,7 +46,7 @@ writeFileSync(process.argv[2], JSON.stringify({ ...marks(), })); -/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해 · 갈래 시점 지정. */ +/** 585-3 — 마커 = 구역 나누기만(해 그대로) · 지움 = 앞 구간이 먹음 · 갈래 처음 해. */ function marks() { const span = (net) => net.pieces.filter((p) => p.line === "main").map((p) => [p.id, p.from_m, p.to_m, p.year]); let m = plan.newLine(plan.emptyNetwork(), [0, 0]); @@ -58,17 +58,9 @@ function marks() { // 갈래 처음 해 = 붙은 자리(60 m · p2 = 2028) 구간 해 let b = plan.normalizePieces(plan.insertVertex(yeared, 0, 0, [60, 0]), Y); b = plan.normalizePieces(plan.appendVertex(plan.addBranch(b, 0, 1), 1, [60, 50]), Y); - // 갈래 시점 지정 — (60,90) 에서 그어 (60,0) 꺾임점에 닿은 본선 → 닿은 끝이 시점인 갈래 - let t = plan.normalizePieces(plan.insertVertex(m, 0, 0, [60, 0]), Y); - t = plan.normalizePieces(plan.appendVertex(plan.newLine(t, [60, 90]), 1, [60, 0]), Y); - const branched = plan.branchFromEnd(t, 1); - const lone = plan.normalizePieces(plan.appendVertex(plan.newLine(m, [0, 50]), 1, [30, 80]), Y); return { cut: span(cut), edgeSame: edge === cut, merged: span(merged), branchYear: b.pieces.filter((p) => p.line !== "main").map((p) => p.year), - branched: typeof branched === "string" ? branched : branched.lines.map((l) => [l.id, l.vertices[0], l.branch]), - again: typeof branched === "string" ? null : plan.branchFromEnd(branched, 1), - lone: plan.branchFromEnd(lone, 1), }; } """ @@ -118,14 +110,9 @@ def test_여러_구간_연도(tmp_path: Path) -> None: assert years["p1"] == years["p3"] == 2028 and years["p2"] == 2026 -def test_마커_구역_나눔_합침_갈래(tmp_path: Path) -> None: +def test_마커_구역_나눔_합침_갈래_해(tmp_path: Path) -> None: got = _run_ts(tmp_path) assert got["cut"] == [["p1", 0, 40, 2026], ["p2", 40, 100, 2026]] # 구역만 나눔 · 해 그대로 assert got["edgeSame"] # 다른 마커 1 m 안 = 안 놓음 assert got["merged"] == [["p1", 0, 100, 2026]] # 지움 = 앞 구간 해 · id 가 먹음 assert got["branchYear"] == [2028] # 갈래 처음 해 = 붙은 자리 구간 해 - lines = {line_id: (head, branch) for line_id, head, branch in got["branched"]} - head, branch = lines[next(k for k in lines if k != "main")] - assert head == [60, 0] and branch == {"line": "main", "vertex": 1} # 닿은 끝 = 시점 - assert got["again"] == "이미 갈래입니다 — 갈라지는 점이 시점입니다." - assert got["lone"].startswith("다른 선에 닿은 끝이 없습니다") diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py new file mode 100644 index 000000000..3643d0aca --- /dev/null +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -0,0 +1,129 @@ +"""598-1 예정노선 고름 층 노드 → 선(토막) → 구역 → 전체 · 토막 지움(요구 463 9 · 12 · 13·16 · 15) +— TS `logic/D03_PlanAnalysis_Select.ts`. + +끝 토막 지움 = 끝 점 뺌 · 가운데 토막 지움 = 구역 둘(뒤쪽 = 새 구역 · 갈래 고리 · 조각 따라감) · +사라지는 노드에 갈래 = 까닭 글 · 끌어 고르기 토막 판정 · 지점 ↔ 노드 같은 자리. + +./venv/Scripts/python.exe -m pytest resources/tester/test_598_1_plan_select_layers.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Select.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as sel from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.js"; +const Y = 2026; +// 본선 (0,0)→(100,0)→(200,0)→(300,0) · 마크 150 → p1 [0,150] p2 [150,300] · 갈래 at 노드 2 (200,0)→(200,50) +let n = plan.newLine(plan.emptyNetwork(), [0, 0]); +for (const x of [100, 200, 300]) n = plan.appendVertex(n, 0, [x, 0]); +n = plan.normalizePieces(plan.splitPiece(plan.normalizePieces(n, Y), "main", 150), Y); +n = plan.normalizePieces(plan.setPiecesYear(n, ["p2"], 2028), Y); +n = plan.normalizePieces(plan.appendVertex(plan.addBranch(n, 0, 2), 1, [200, 50]), Y); +const show = (net) => typeof net === "string" ? net : ({ + lines: net.lines.map((l) => [l.id, l.vertices, l.branch]), + pieces: plan.normalizePieces(net, Y).pieces.map((p) => [p.id, p.line, p.from_m, p.to_m, p.year]), +}); +const seg = (line, index) => ({ line, index }); +const view = ([x, y]) => [x, -y]; +writeFileSync(process.argv[2], JSON.stringify({ + segs: sel.allSegments(n), + range: sel.segmentRange(n, seg("main", 1)), + at: [sel.segmentAt(n.lines[0].vertices, 0), sel.segmentAt(n.lines[0].vertices, 100), sel.segmentAt(n.lines[0].vertices, 300)], + nodeSeg: [sel.nodeSegment(n, { line: "main", vertex: 1 }), sel.nodeSegment(n, { line: "main", vertex: 3 })], + pieces: sel.segmentPieces(n, [seg("main", 1)]).sort(), + middle: show(sel.removeSegment(n, seg("main", 0 + 1))), + tail: show(sel.removeSegment(n, seg("main", 2))), + head: show(sel.removeSegment(n, seg("main", 0))), + only: show(sel.removeSegment(n, seg("branch-1", 0))), + parentOnly: sel.removeSegments(n, [seg("main", 0), seg("main", 1), seg("main", 2)]), + many: show(sel.removeSegments(n, [seg("main", 0), seg("main", 2)])), + box: sel.segmentsInBox(n, view, { x0: 90, y0: -10, x1: 210, y1: 10 }, false), + cross: sel.segmentsInBox(n, view, { x0: 150, y0: 10, x1: 120, y1: -10 }, true), + all: show(sel.removeAll(n)), + nodes: sel.nodesAt(n, [200, 0]), + moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]), +})); +""" + + +def _run_ts(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + ["node", str(TSC), str(TS_FILE), "--outDir", str(out), "--rootDir", str(ROOT), + "--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler", + "--ignoreConfig"], + cwd=str(ROOT), check=True, capture_output=True, + ) # fmt: skip + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_토막_자리(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + assert [s["line"] for s in got["segs"]] == ["main"] * 3 + ["branch-1"] + assert got["range"] == [100, 200] + assert got["at"] == [0, 1, 2] # 꺾임점 위 = 뒤 토막 · 끝 = 마지막 + assert got["nodeSeg"] == [{"line": "main", "index": 1}, {"line": "main", "index": 2}] + assert got["pieces"] == ["p1", "p2"] # 100~200 토막에 마크 150 = 두 구간 + + +def test_가운데_토막_지움_구역_둘(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + lines = {i: (v, b) for i, v, b in got["middle"]["lines"]} + assert lines["main"] == ([[0, 0], [100, 0]], None) # 앞쪽 = 같은 id + back = next(k for k in lines if k not in ("main", "branch-1")) + assert lines[back] == ([[200, 0], [300, 0]], None) # 뒤쪽 = 새 구역(본선) + assert lines["branch-1"][1] == {"line": back, "vertex": 0} # 갈래 고리가 따라감 + pieces = {(p[1], p[2], p[3]): (p[0], p[4]) for p in got["middle"]["pieces"]} + assert pieces[("main", 0, 100)] == ("p1", 2026) # 앞 조각 id 지님 + assert pieces[(back, 0, 100)][1] == 2028 # 뒤 조각 해 그대로 + + +def test_끝_토막_지움_끝_점_뺌(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + tail = {i: v for i, v, _ in got["tail"]["lines"]} + assert tail["main"] == [[0, 0], [100, 0], [200, 0]] + head = {i: (v, b) for i, v, b in got["head"]["lines"]} + assert head["main"][0] == [[100, 0], [200, 0], [300, 0]] + assert head["branch-1"][1] == {"line": "main", "vertex": 1} # 갈래 차례 −1 + assert [p[2:4] for p in got["head"]["pieces"] if p[1] == "main"] == [[0, 50], [50, 200]] + + +def test_갈래_붙은_노드_막음_하나뿐_구역_지움(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + only = got["only"]["lines"] + assert [i for i, _, _ in only] == ["main"] # 토막 하나뿐 = 구역 지움 + assert got["parentOnly"] == "갈래가 붙은 점이 사라집니다 — 갈래를 먼저 지우세요." + many = {i: v for i, v, _ in got["many"]["lines"]} + assert many["main"] == [[100, 0], [200, 0]] # 뒤부터 지움 = 차례 안 꼬임 + + +def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + assert got["box"] == [{"line": "main", "index": 1}] # 창 = 두 끝 모두 안 + assert got["cross"] == [{"line": "main", "index": 1}] # 걸침 = 상자와 만남 + assert got["all"] == {"lines": [], "pieces": []} + assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌 + assert got["moved"] == [[210, 5], [5, 5]] From e68744c3eb692d412978dbcb8393d201271393a0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:51:48 +0900 Subject: [PATCH 05/14] =?UTF-8?q?fix(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=85=B8=EB=93=9C=20=EC=9C=84=20=ED=95=80=20?= =?UTF-8?q?=EB=88=84=EB=A6=84=20=3D=20=EB=85=B8=EB=93=9C=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=84=20=C2=B7=20=EB=81=8C=EB=A9=B4=20=EC=A7=80=EC=A0=90=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20(PLAN=20598-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 핀이 노드 자리에 있으면 핀 겹이 누름을 안 먹음 — 노드 판 · 넓힘이 가능 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index 4a0842ec7..bd52f069b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -124,7 +124,7 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { press(px, py, map, button) { if (button !== 0 || !params.editable() || !params.idle()) return false; const index = pinAt(px, py, map); - if (index === null) return false; + if (index === null || nodeNear(px, py, map) !== null) return false; // 노드 위 = 고르기 겹(노드 고름 · 끌면 지점 같이) drag = { index, points: params.points(), network: params.network(), last: null }; return true; }, From 10402b3ee1d63d23072e64a13cf650bd163516c3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:57:51 +0900 Subject: [PATCH 06/14] =?UTF-8?q?feat(=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0?= =?UTF-8?q?):=20=EA=B5=AC=EC=97=AD=20=EB=AA=A9=EB=A1=9D=20=EC=83=89=20?= =?UTF-8?q?=EB=9D=A0=20=C2=B7=20=EC=9D=B4=EB=A6=84=20=EC=B9=B8=20=C2=B7=20?= =?UTF-8?q?=EC=97=B0=EB=8F=84=20=EA=B3=A0=EB=A6=84=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9B=80=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EA=B8=80=20=EC=9E=91?= =?UTF-8?q?=EA=B2=8C=20=C2=B7=20=EC=A7=80=EC=A0=90=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=9D=80=20Plan.nameError=20(PLAN=20597-2=20?= =?UTF-8?q?=C2=B7=20597-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 8 +- .../D03_PlanAnalysis_UI_Pieces.ts | 219 ++++++++++++++---- .../D03_PlanAnalysis_UI_Points.ts | 12 +- .../D03_PlanAnalysis_UI_Style.css | 55 ++--- .../logic/D03_PlanAnalysis_PointEdit.ts | 23 +- resources/tester/test_597_point_edit.py | 13 +- 6 files changed, 231 insertions(+), 99 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a685c96de..b510abfe0 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -146,7 +146,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { yearStart: () => yearStart, commit, }); - const pieces = createPlanPieces({ network: () => network, edit }); + const pieces = createPlanPieces({ + network: () => network, + edit, + editable: () => editable, + yearOptions: props.yearOptions, + commit, + }); const measure = createPlanMeasure(() => z); const marks = createPlanPoints({ points: () => points, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts index 9cca359f9..73ddc73ff 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Pieces.ts @@ -1,13 +1,26 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Pieces.ts - * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597) — 해 줄(같은 해) · 선 머리 줄(선) · 구간 줄(구간). - * 속성 판(`UI_Props.ts`)에서 떼어냄 — 목록 = 보기 + 고르기. + * D03 좌측 「구역 · 연도」 목록 몸(PLAN 597 · 요구 463 14) — 구역 = 그은 꺾은선 하나(본선 · 갈래). + * 해 줄(같은 해) · 구역 줄(색 띠 = 지도 선과 같은 `routeColor(차례)` · 이름 칸 · 연도 고름 · 지움) · + * 구간 줄(측점 글 작게 · 연도 고름). 누름 = 고름 · 해 고침 · 이름 · 지움은 한 걸음(되돌리기 · 캐시 = 페이지). + * 연도 고름은 속성 판과 같은 셈(`setLineYear` · `setPiecesYear`) · 보기만 계정 = 값만. * ========================================================================== */ -import { createText } from "@ui/ui_template_text"; +import { createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createListRow } from "@ui/ui_template_list_row"; +import { askConfirm } from "@ui/ui_template_modal"; +import { routeColor } from "@ui/ui_template_palette"; import { stationLabel } from "../common_util/common_util_station"; import { lineLength } from "./logic/D03_PlanAnalysis_Draw"; -import { lineNames, type PlanNetwork } from "./logic/D03_PlanAnalysis_Plan"; +import { + lineNames, + nameError, + setLineYear, + setPiecesYear, + type PlanNetwork, +} from "./logic/D03_PlanAnalysis_Plan"; +import { setLineName } from "./logic/D03_PlanAnalysis_PointEdit"; import type { PlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; @@ -17,6 +30,10 @@ const meters = (m: number): string => `${m.toFixed(1)} m`; export interface PlanPiecesParams { network: () => PlanNetwork; edit: PlanEdit; + editable: () => boolean; + /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ + yearOptions: () => Array<{ value: string; text: string }>; + commit: (next: PlanNetwork) => void; } export interface PlanPieces { @@ -25,79 +42,191 @@ export interface PlanPieces { render: () => void; } -/** 좌측 줄 — 누름 = 고름(div · 열쇠 Enter 도). */ -const pickRow = (row: HTMLElement, onPick: () => void): HTMLElement => { - row.tabIndex = 0; - row.setAttribute("role", "button"); - row.addEventListener("click", onPick); - row.addEventListener("keydown", (event) => event.key === "Enter" && onPick()); - return row; -}; - export function createPlanPieces(params: PlanPiecesParams): PlanPieces { const { edit } = params; const list = document.createElement("div"); list.className = "d03-plan__pieces"; + let shown = ""; // 지금 그린 틀 열쇠 — 같으면 값만 갈아 끼움(칸 초점 지키기) + let syncs: Array<() => void> = []; + + /** 줄 안 칸 눌림은 줄 고름으로 안 번지게. */ + const isControl = (event: Event): boolean => + !!(event.target as Element).closest("input, select, button, .ui-list-row__trailing"); + + /** 줄 하나 — 누름 = 고름(열쇠 Enter 도) · 줄 안 칸은 제외. */ + const pickable = (row: HTMLElement, onPick: () => void): HTMLElement => { + row.tabIndex = 0; + row.addEventListener("click", (event) => isControl(event) || onPick()); + row.addEventListener("keydown", (event) => { + if (event.key === "Enter" && event.target === row) onPick(); + }); + return row; + }; + + /** 연도 고름 칸 — 값이 섞이면 「여러 해」. */ + function yearSelect( + years: () => number[], + label: string, + pick: (year: number) => void, + ): { root: HTMLElement; sync: () => void } { + const field = createSelectField({ + options: [], + size: 24, + ariaLabel: label, + disabled: !params.editable(), + onChange: (value) => value && pick(Number(value)), + }); + const sync = (): void => { + const set = new Set(years()); + const options = params.yearOptions(); + field.setOptions( + set.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, + set.size > 1 ? "" : String([...set][0]), + ); + }; + sync(); + return { root: field.root, sync }; + } function render(): void { const network = params.network(); const names = lineNames(network.lines); const picked = new Set(edit.selected()?.pieces ?? []); const scope = edit.selected()?.scope; + const editable = params.editable(); + const key = JSON.stringify([ + network.lines.map((line) => line.id), + network.pieces.map((piece) => [piece.id, piece.line]), + [...picked], + scope, + editable, + params.yearOptions().length, + ]); + if (key === shown) { + syncs.forEach((sync) => sync()); + return; + } + shown = key; + syncs = []; const rows: HTMLElement[] = []; + const byYear = new Map(); for (const piece of network.pieces) byYear.set(piece.year, [...(byYear.get(piece.year) ?? []), piece]); for (const [year, pieces] of [...byYear].sort(([a], [b]) => a - b)) { - const row = document.createElement("div"); - row.className = "d03-plan__piece d03-plan__piece-year"; - row.dataset.year = String(year); - row.classList.toggle( - "is-selected", - scope === "year" && pieces.every((p) => picked.has(p.id)), - ); const length = pieces.reduce((sum, p) => sum + p.to_m - p.from_m, 0); - row.append(createText(`${year}년 · 구간 ${pieces.length} · ${meters(length)}`, "muted")); - rows.push( - pickRow(row, () => { + const row = createListRow({ + label: `${year}년`, + sub: `구간 ${pieces.length} · ${meters(length)}`, + size: 28, + active: scope === "year" && pieces.every((p) => picked.has(p.id)), + onClick: () => { edit.select([pieces[0].id]); edit.setScope("year"); - }), - ); + }, + }); + row.dataset.year = String(year); + rows.push(row); } + network.lines.forEach((line, index) => { const pieces = network.pieces.filter((piece) => piece.line === line.id); if (!pieces.length) return; - const head = document.createElement("div"); - head.className = "d03-plan__piece d03-plan__piece-head"; - head.dataset.line = line.id; - head.classList.toggle( - "is-selected", - scope === "line" && pieces.every((p) => picked.has(p.id)), + const lineNow = (): (typeof network.lines)[number] => + params.network().lines.find((entry) => entry.id === line.id) ?? line; + const piecesNow = (): typeof pieces => + params.network().pieces.filter((piece) => piece.line === line.id); + + const nameField = createInputField({ size: 24, placeholder: names[index] }); + nameField.input.value = line.name ?? ""; + nameField.input.setAttribute("aria-label", "구역 이름"); + nameField.input.dataset.role = "line-name"; + nameField.input.disabled = !editable; + nameField.input.addEventListener("input", () => + nameField.setError(nameError(nameField.input.value) ?? undefined), ); - head.append(createText(`${names[index]} · ${meters(lineLength(line.vertices))}`, "muted")); + nameField.input.addEventListener("change", () => { + const error = nameError(nameField.input.value); + nameField.setError(error ?? undefined); + const text = nameField.input.value.trim(); + if (!error && text !== (lineNow().name ?? "")) + params.commit(setLineName(params.network(), line.id, text)); + }); + + const year = yearSelect( + () => piecesNow().map((piece) => piece.year), + "구역 연도", + (value) => params.commit(setLineYear(params.network(), line.id, value)), + ); + year.root.dataset.role = "line-year"; + const drop = createIconButton({ + icon: "trash", + title: "구역 지움", + size: 24, + onClick: async () => { + const ok = await askConfirm({ + message: `「${lineNames(params.network().lines)[index]}」 구역을 지울까요?`, + okLabel: "지움", + danger: true, + }); + if (!ok) return; + edit.select([piecesNow()[0].id]); + edit.dropLine(); + }, + }); + drop.dataset.role = "line-drop"; + drop.hidden = !editable; + const tail = document.createElement("span"); + tail.className = "d03-plan__line-tail"; + tail.append(year.root, drop); + + const head = createListRow({ + label: "", + sub: `구간 ${pieces.length} · ${meters(lineLength(line.vertices))}`, + stripe: routeColor(index), + size: 28, + trailing: tail, + active: scope === "line" && pieces.every((p) => picked.has(p.id)), + }); + head.querySelector(".ui-list-row__label")?.replaceChildren(nameField.root); + head.dataset.line = line.id; rows.push( - pickRow(head, () => { + pickable(head, () => { edit.select([pieces[0].id]); edit.setScope("line"); }), ); + syncs.push(() => { + year.sync(); + nameField.input.placeholder = lineNames(params.network().lines)[index] ?? ""; + if (document.activeElement !== nameField.input) + nameField.input.value = lineNow().name ?? ""; + const sub = head.querySelector(".ui-list-row__sub"); + if (sub) + sub.textContent = `구간 ${piecesNow().length} · ${meters(lineLength(lineNow().vertices))}`; + }); + for (const piece of pieces) { - const row = document.createElement("div"); - row.className = "d03-plan__piece"; - row.classList.toggle("is-selected", picked.has(piece.id)); - row.dataset.piece = piece.id; - row.append( - createText( - `${station(piece.from_m)} ~ ${station(piece.to_m)} (${meters(piece.to_m - piece.from_m)})`, - "muted", - ), - createText(`${piece.year}년`, "muted"), + const pieceNow = (): (typeof pieces)[number] => + params.network().pieces.find((entry) => entry.id === piece.id) ?? piece; + const pieceYear = yearSelect( + () => [pieceNow().year], + "구간 연도", + (value) => params.commit(setPiecesYear(params.network(), [piece.id], value)), ); - rows.push(pickRow(row, () => edit.select([piece.id]))); + const row = createListRow({ + label: `${station(piece.from_m)} ~ ${station(piece.to_m)}`, + sub: meters(piece.to_m - piece.from_m), + size: 28, + trailing: pieceYear.root, + active: picked.has(piece.id), + }); + row.classList.add("d03-plan__piece-row"); + row.dataset.piece = piece.id; + rows.push(pickable(row, () => edit.select([piece.id]))); + syncs.push(pieceYear.sync); } }); - if (!rows.length) rows.push(createText("선을 그으면 구역이 생깁니다.", "muted")); list.replaceChildren(...rows); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts index ba4044b6b..933685d29 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Points.ts @@ -16,14 +16,18 @@ import { createInputField } from "@ui/ui_template_input"; import { createMapContextMenu, type MapContextMenuItem } from "@ui/ui_template_context_menu"; import { createListRow } from "@ui/ui_template_list_row"; import { pointColor } from "@ui/ui_template_palette"; -import { moveNodesAt, type PlanNetwork, type Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + moveNodesAt, + nameError, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; import { addPoint, MAX_POINTS, movePointTo, parseLonLat, pointLabel, - pointNameError, renamePoint, type PointsState, } from "./logic/D03_PlanAnalysis_PointEdit"; @@ -228,11 +232,11 @@ export function createPlanPoints(params: PlanPointsParams): PlanPoints { set(latField, ll ? ll[1].toFixed(6) : ""); }; nameField.input.addEventListener("input", () => - nameField.setError(pointNameError(nameField.input.value.trim()) ?? undefined), + nameField.setError(nameError(nameField.input.value.trim()) ?? undefined), ); nameField.input.addEventListener("change", () => { const text = nameField.input.value.trim(); - const error = pointNameError(text); + const error = nameError(text); nameField.setError(error ?? undefined); if (!error && text !== (params.names()[i] ?? "")) commitState(renamePoint(state(), i, text)); }); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index e48777379..617aaca50 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -106,28 +106,6 @@ gap: 4px; } -.d03-plan__piece-head { - margin: 6px 0 0; - font-weight: 600; - color: var(--color-text); -} - -.d03-plan__piece { - display: flex; - gap: var(--spacing-8); - align-items: center; - justify-content: space-between; -} - -.d03-plan__piece > .ui-text { - margin: 0; -} - -.d03-plan__piece > .ui-field { - flex: 0 0 6.5rem; - margin: 0; -} - /* 지형 받기 범위 미리보기 — 카카오 지도 */ .d03-plan__extent > .ui-text { margin: 0 0 6px; @@ -152,12 +130,6 @@ margin-bottom: 6px; } -/* 580-3 고른 선 · 조각 줄 밝힘 */ -.d03-plan__piece.is-selected { - background: var(--color-accent-soft); - border-radius: var(--radius-md); -} - /* 585-2 고른 구간 속성 판 — 도구줄 아래 오른쪽 · 몸 스크롤 · 구간 줄 = 고스트 단추 */ .d03-plan__props { top: calc(var(--spacing-8) * 6); @@ -188,14 +160,6 @@ justify-content: flex-start; } -.d03-plan__piece[role="button"] { - cursor: pointer; -} - -.d03-plan__piece-year { - font-weight: 600; -} - /* 597 지점 칸 — [추가] 머리 · 펼친 줄(이름 · 경도 · 위도) */ .d03-plan__points-head { display: flex; @@ -231,3 +195,22 @@ .d03-plan__draw-fields .ui-field { margin: 0; } + +/* 597 구역 · 연도 목록 — 구역 줄(이름 칸 · 연도 고름 · 지움) · 구간 줄(측점 글 작게) */ +.d03-plan__line-tail { + display: flex; + gap: var(--spacing-4); + align-items: center; +} + +.d03-plan__pieces .ui-list-row__label > .ui-field { + margin: 0; +} + +.d03-plan__piece-row { + padding-left: calc(var(--spacing-8) * 2); +} + +.d03-plan__piece-row .ui-list-row__label { + font-size: var(--text-caption); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts index 06008fd45..6cb3c8aad 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_PointEdit.ts @@ -1,9 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_PointEdit.ts * D03 지점 칸 손 고침 계산(PLAN 597-2 · 요구 463 7 · 8) — 화면 · fetch 없음(logic/). - * 이름 = 20 자 안 · 파일 이름에 못 쓰는 글자(`/ \ : * ? " < > |`) 막음 · 빈 값 = 「지점 N」 보임. + * 이름 규칙 = `Plan.ts` 의 `nameError`(20 자 · 파일 이름 못 쓰는 글자 · 서버 400 짝) · 빈 값 = 「지점 N」 보임. * 좌표 = 경위도 손 고침 → 작업 좌표(TM) · 그 자리 노선 노드도 같이(`moveNodesAt`). - * ⚠ 이름 한도 · 글자 = `Plan.ts` 의 `NAME_MAX` · `nameError`(594-1)와 같은 규칙 — 그쪽이 들어오면 거기서 가져옴. * ========================================================================== */ import { moveNodesAt, type PlanNetwork, type Vertex } from "./D03_PlanAnalysis_Plan"; @@ -11,15 +10,6 @@ import { lonLatToTm, type TmParams } from "./D03_PlanAnalysis_Tm"; /** 지점 상한 — 서버 `MAX_POINTS` 와 같음. */ export const MAX_POINTS = 40; -export const POINT_NAME_MAX = 20; -const BAD_CHARS = /[\\/:*?"<>|]/; - -/** 이름 까닭 글 — 쓸 수 있으면 null. */ -export function pointNameError(name: string): string | null { - if (name.length > POINT_NAME_MAX) return `이름은 ${POINT_NAME_MAX} 자까지`; - if (BAD_CHARS.test(name)) return '이름에 / \\ : * ? " < > | 는 못 씁니다'; - return null; -} /** 보이는 이름 — 빈 값 = 「지점 N」. */ export function pointLabel(names: string[], index: number): string { @@ -74,3 +64,14 @@ export function movePointTo( network: moveNodesAt(state.network, from, to), }; } + +/** 선 이름 바꿈(597-3) — 빈 값 = 차례 이름으로 돌아감(`name` 비움). */ +export function setLineName(network: PlanNetwork, lineId: string, name: string): PlanNetwork { + const text = name.trim(); + return { + ...network, + lines: network.lines.map((line) => + line.id === lineId ? { ...line, name: text || null } : line, + ), + }; +} diff --git a/resources/tester/test_597_point_edit.py b/resources/tester/test_597_point_edit.py index f8f25848f..b4f90f33a 100644 --- a/resources/tester/test_597_point_edit.py +++ b/resources/tester/test_597_point_edit.py @@ -28,6 +28,8 @@ const tm = tmx.tmParams(5187); let n = plan.newLine(plan.emptyNetwork(), [100, 0]); n = plan.normalizePieces(plan.appendVertex(n, 0, [200, 0]), Y); const state = { points: [[100, 0], [500, 500]], names: ["", "가"], network: n }; +const named = pe.setLineName(pe.setLineName(n, n.lines[0].id, ' 본선A '), 'none', 'x'); +const cleared = pe.setLineName(named, n.lines[0].id, ' '); const added = pe.addPoint(state, [7, 8]); const full = { points: Array.from({ length: 40 }, (_, i) => [i, 0]), names: [], network: n }; const [lon, lat] = tmx.tmToLonLat(tm, 100, 0); @@ -35,10 +37,11 @@ const moved = pe.movePointTo(state, 0, tm, lon + 0.001, lat); const back = tmx.tmToLonLat(tm, ...moved.points[0]); writeFileSync(process.argv[2], JSON.stringify({ errors: ["", "가나다", "a/b", "a" + String.fromCharCode(92) + "b", "a:b", "a*b", "a?b", 'a"b', "ab", "a|b", "x".repeat(20), "x".repeat(21)] - .map((s) => pe.pointNameError(s)), + .map((s) => plan.nameError(s)), labels: [pe.pointLabel(["", " ", "가"], 0), pe.pointLabel(["", " ", "가"], 1), pe.pointLabel(["", " ", "가"], 2), pe.pointLabel([], 3)], parse: [pe.parseLonLat("128.5", "36.9"), pe.parseLonLat("", "36.9"), pe.parseLonLat("128.5", "abc"), pe.parseLonLat("181", "0"), pe.parseLonLat("0", "-91")], added: [added.points, added.names, added.network === n], + lineName: [named.lines[0].name, plan.lineNames(named.lines), cleared.lines[0].name, plan.lineNames(cleared.lines)], full: pe.addPoint(full, [1, 1]) === full, renamed: [pe.renamePoint(state, 1, " 나 ").names, pe.renamePoint({ ...state, names: [] }, 1, "다").names], moved: { points: moved.points, lines: moved.network.lines.map((l) => l.vertices), back, expect: [lon + 0.001, lat], untouched: moved.points[1], names: moved.names }, @@ -75,7 +78,7 @@ def got(tmp_path_factory: pytest.TempPathFactory) -> dict: def test_name_rule(got: dict) -> None: ok, bad = got["errors"][:2], got["errors"][2:11] assert ok == [None, None] # 빈 이름 · 한글 = 됨 - assert all(msg and "못 씁니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 + assert all(msg and "쓸 수 없습니다" in msg for msg in bad) # 파일 이름 못 쓰는 글자 아홉 assert got["errors"][11] is None # 20 자 assert "20 자" in got["errors"][12] # 21 자 = 막음 @@ -109,3 +112,9 @@ def test_move_by_lonlat_drags_node(got: dict) -> None: assert moved["lines"][0][0] == moved["points"][0] # 그 자리 노선 노드도 같이 assert moved["lines"][0][1] == [200, 0] # 다른 노드는 그대로 assert moved["untouched"] == [500, 500] and moved["names"] == ["", "가"] + + +def test_line_name(got: dict) -> None: + named, names, cleared, back = got["lineName"] + assert named == "본선A" and names == ["본선A"] # 앞뒤 빈칸 뺌 · 이름 우선 + assert cleared is None and back == ["본선"] # 빈 값 = 차례 이름으로 From b44066da61bca2f0190bae4269409083a3322f63 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:59:19 +0900 Subject: [PATCH 07/14] =?UTF-8?q?fix(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=ED=86=A0=EB=A7=89=20=EC=A7=80=EC=9B=80=EC=9D=B4?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EB=A1=9D(594-1=20nodes)?= =?UTF-8?q?=EB=8F=84=20=EA=B0=99=EC=9D=B4=20=EC=9E=90=EB=A6=84=20=E2=80=94?= =?UTF-8?q?=20=EB=92=A4=EC=AA=BD=20=EC=B2=AB=20=EA=B8=B0=EB=A1=9D=20=3D=20?= =?UTF-8?q?=EC=97=86=EC=9D=8C=20(PLAN=20598-1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts | 14 ++++++-------- resources/tester/test_598_1_plan_select_layers.py | 11 +++++++++++ 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts index bea304984..ba25df693 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -134,15 +134,13 @@ function nextPieceId(pieces: ReadonlyArray): string { return `p${Math.max(0, ...used) + 1}`; } -/** 꼭짓점 [a, b) 만 남긴 선 — 꼭짓점과 같은 길이 덧 칸(노드 기록 594-1)도 같이 자름 · 첫 기록 = 없음. */ +/** 꼭짓점 [a, b) 만 남긴 선 — 노드 기록(594-1)도 같이 자름 · 뒤쪽 첫 기록 = 없음(앞 노드가 사라짐). */ function cutVertices(entry: PlanLine, a: number, b: number): PlanLine { - const out: PlanLine & { nodes?: unknown[] } = { ...entry, vertices: entry.vertices.slice(a, b) }; - const nodes = (entry as { nodes?: unknown }).nodes; - if (Array.isArray(nodes) && nodes.length === entry.vertices.length) { - out.nodes = nodes.slice(a, b); - if (a > 0 && out.nodes.length) out.nodes[0] = null; - } - return out; + const vertices = entry.vertices.slice(a, b); + if (!entry.nodes) return { ...entry, vertices }; + const nodes = entry.nodes.slice(a, b); + if (a > 0 && nodes.length) nodes[0] = null; + return { ...entry, vertices, nodes }; } /** 토막 하나 지움 — 하나뿐 = 구역 지움 · 끝 토막 = 끝 점 뺌 · 가운데 = 구역 둘(뒤쪽 = 새 구역 · 갈래 아님) · diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py index 3643d0aca..0ae23beea 100644 --- a/resources/tester/test_598_1_plan_select_layers.py +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -52,6 +52,12 @@ writeFileSync(process.argv[2], JSON.stringify({ all: show(sel.removeAll(n)), nodes: sel.nodesAt(n, [200, 0]), moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]), + nodes594: (() => { + const r = { kind: "length", length_m: 100, slope_pct: null }; + const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) }; + const out = sel.removeSegment(withNodes, seg("main", 1)); + return out.lines.filter((l) => l.id !== "branch-1").map((l) => [l.id, l.nodes]); + })(), })); """ @@ -127,3 +133,8 @@ def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None: assert got["all"] == {"lines": [], "pieces": []} assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌 assert got["moved"] == [[210, 5], [5, 5]] + rec = {"kind": "length", "length_m": 100, "slope_pct": None} + assert got["nodes594"] == [ + ["main", [None, rec]], + ["line-2", [None, rec]], + ] # 노드 기록 같이 · 뒤쪽 첫 = 없음 From bcf397ab3d40bbf0bd16e0b4bab9d2cb98505be3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:00:32 +0900 Subject: [PATCH 08/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EC=A7=80=ED=98=95=20=EC=B9=B8=20=E2=86=92=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=B9=B8=20=C2=B7=20=EC=A3=BC=EC=86=8C=20?= =?UTF-8?q?=C2=B7=20=EC=9E=A5=EC=86=8C=20=EC=B0=BE=EA=B8=B0=20=E2=86=92=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=E2=86=92=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=93=B1=EB=A1=9D=20=EC=A7=80=EB=8F=84=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?(PLAN=20596-2=20=C2=B7=20596-5=20=C2=B7=20=EC=9A=94=EA=B5=AC=20?= =?UTF-8?q?463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_Site — 지금 주소 · 찾기(createFindList · searchPlaces) · 실패 · 끊김 한 줄 + [다시 받기] · [임도망 올리기] - 위치 = 캐시 한 걸음(Snap site · 판 5 · 되돌리기 · 떠남 물음) → [저장] 몸 site · 지점 이름 point_names - 고름 → 지도 이동 → 둘레 받기(center) · 멂 · 다른 TM 띠 = 물음 한 번 뒤 reset · 아니오 = 위치 되돌림 - 임도망 올린 뒤 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지) - 조용히 받기 = 기준점 10 km 안 지점 · 선만 · 칸 글 걷음(실패 · 끊김 · 기준점 없음 · 못 불러옴만) - 등록(D01) 지도 머리 [위성] 오른쪽 찾기 = 같은 createPlaceFind · 사전 DE_Reg_Find* - 시험 553_4 위치 칸 · 등록 찾기 부품 · 560_4 savedJson 꼴 · 584_3 올리기 자리(위치 칸) Co-Authored-By: Claude Opus 5.5 --- D01_PlanRegister/D01_PlanRegister_UI_Page.ts | 11 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 224 ++++++++++++------ D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts | 114 +++++++++ resources/tester/test_553_4_de_parts.py | 15 +- resources/tester/test_560_4_leave_save.py | 2 +- .../test_584_3_plan_forest_road_zones.py | 4 +- ui_template/ui_template_locale_de.ts | 7 + 7 files changed, 297 insertions(+), 80 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts diff --git a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts index 83cc164b6..dad0a66eb 100644 --- a/D01_PlanRegister/D01_PlanRegister_UI_Page.ts +++ b/D01_PlanRegister/D01_PlanRegister_UI_Page.ts @@ -5,6 +5,7 @@ * · 사업연도 끝 · 임도 종류 · 측점은 안 받음(끝 연도 = D03 [저장] · 나머지 = 서버 기본) · 설계속도 없음(591-1). * 기준점(591-1 · 요구 452 · 453) = 카카오 지도(공용 editMap · 마우스 설정 따름) 누름 · 끌기로 한 점 → * GET /api/map/address(VWorld · 출처 표기) → 주소 글 · 사업 지역 「시군구 읍면」 채움(손으로 고친 뒤엔 안 덮음) + * 지도 머리 [위성] 오른쪽 = 주소 · 장소 찾기(596-5 · 예정노선 위치 칸과 같은 `createPlaceFind` · 서버 VWorld) → 고름 = 그 자리로 기준점. * [등록] = POST /api/d01/project(site = 경위도 · 주소는 서버가 다시 찾음) → 현재 프로젝트 · D03 으로. * ========================================================================== */ @@ -20,6 +21,7 @@ import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { createGroupProject } from "../A00_Common/group_workflow"; import { navigateTo } from "../A00_Common/router"; import { createNameComposer } from "../B02_ProjRegister/B02_ProjRegister_UI_Name"; +import { createPlaceFind } from "../D03_PlanAnalysis/D03_PlanAnalysis_UI_Site"; const KOREA_CENTER: [number, number] = [127.8, 36.3]; const DIGITS = 6; // 경위도 소수 자리(약 0.1 m) @@ -85,12 +87,17 @@ function sitePicker(onRegion: (region: string) => void): { } draw(); + // 찾기 = 칸은 머리 줄([위성] 오른쪽 · 좁으면 줄바꿈) · 알림 · 후보 줄은 머리 아래 + const find = createPlaceFind((hit) => void moveTo(hit.lon, hit.lat)); + const field = find.search.parentElement!; + field.style.flex = "1 1 12rem"; const head = document.createElement("div"); head.className = "ui-general-block__actions"; - head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root); + head.style.flexWrap = "wrap"; + head.append(createText(t("DE_Reg_Site"), "muted", 14), typePick.root, field); const root = document.createElement("div"); root.dataset.d01Site = ""; - root.append(head, map.root, where); + root.append(head, find.root, map.root, where); return { root, value: () => site }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 07b548afc..809917986 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -19,10 +19,8 @@ import "./D03_PlanAnalysis_UI_Style.css"; import { ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; -import { pickFiles } from "@ui/ui_template_drop"; import { askConfirm } from "@ui/ui_template_modal"; import { createProgressCircle } from "@ui/ui_template_progress"; -import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -42,9 +40,12 @@ import { fetchTerrainExtent, savePlanNetwork, startTerrain, + type PlaceHit, type PlanJob, type PlanNetworkView, type PlanSurface, + type TerrainCenter, + type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; @@ -59,6 +60,7 @@ import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; import { createPlanPoints } from "./D03_PlanAnalysis_UI_Points"; import { createPlanPieces } from "./D03_PlanAnalysis_UI_Pieces"; import { createPlanProps } from "./D03_PlanAnalysis_UI_Props"; +import { createPlanSite } from "./D03_PlanAnalysis_UI_Site"; import { emptyNetwork, fromServer, @@ -75,16 +77,26 @@ const POLL_MS = 1000; const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; +/** 조용히 받기가 덮는 거리 — 기준점에서 이보다 먼 지점 · 선은 안 받음(위치를 멀리 옮긴 뒤 옛 선 = 거대 받기 막음). */ +const COVER_MAX_M = 10000; /** 되돌리기 쌓는 수. */ const UNDO_MAX = 100; -/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름)은 안 읽음. */ -const DRAFT_VERSION = 4; +/** 캐시 꼴 판 — 옛 꼴(지점 없음 · 갈래 고름 · 위치 없음)은 안 읽음. */ +const DRAFT_VERSION = 5; -/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선(되돌리기 · 캐시 · 고침 판정). */ +/** 기준점(위치 칸 · 등록과 한 칸 `projects.site_*`). */ +interface Site { + lon: number; + lat: number; + address: string | null; +} + +/** 한 걸음 = 지점(+ 이름 · 차례 같음 · 빈 값 = 「지점 N」) + 노선 + 위치(되돌리기 · 캐시 · 고침 판정). */ interface Snap { network: PlanNetwork; points: Vertex[]; pointNames: string[]; + site: Site | null; } interface Draft extends Snap { @@ -100,10 +112,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let editable = false; let points: Vertex[] = []; // 지점(긋기에 붙는 점 · 591-2) let pointNames: string[] = []; // 지점 이름(596 · 597-2) + let site: Site | null = null; // 위치(캐시 · [저장] 때 정본 596-2) + let savedSite: Site | null = null; let epsg = 0; let yearStart = new Date().getFullYear(); // 새 선 처음 해 let network: PlanNetwork = emptyNetwork(); - let savedJson = JSON.stringify({ network, points, pointNames }); // 못 읽은 채 떠남 = 고침 없음 + let savedJson = JSON.stringify({ network, points, pointNames, site }); // 못 읽은 채 떠남 = 고침 없음 let undoStack: Snap[] = []; let redoStack: Snap[] = []; let surface: PlanSurface | null = null; @@ -113,6 +127,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; let failed: string | null = null; + let note = ""; // 위치 칸 알림(기준점 없음 · 못 불러옴) let measuring = false; let lastSaveMs: number | null = null; @@ -158,7 +173,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg: () => epsg, draw, commit: (nextPoints, nextNames, next) => - step({ network: next, points: nextPoints, pointNames: nextNames }), + step({ network: next, points: nextPoints, pointNames: nextNames, site }), center: () => viewMid, show: (nextPoints, next) => { [points, network] = [nextPoints, next]; @@ -194,30 +209,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { main.className = "d03-plan"; main.append(map.root); - const terrainText = createText("", "muted"); - terrainText.classList.add("d03-plan__hint"); - const retryButton = createButton({ - label: "다시 받기", - variant: "filled", - size: 28, - onClick: () => { + const siteBox = createPlanSite({ + onPick: (hit) => void pickSite(hit), + onRetry: () => { [failed, lastTried, ready] = [null, "", true]; - void ensureCovered(); + void ensureCovered(true); }, + onUpload: (files) => void uploadRoads(files), }); - const terrainCard = createPanelCard("지형", false); - const roadButton = createButton({ - label: "임도망 올리기", - variant: "ghost", - size: 28, - onClick: () => - pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => { - void backdrop.uploadRoads(files); - }), - }); - roadButton.title = - "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; - terrainCard.append(terrainText, retryButton, roadButton); + const siteCard = createPanelCard("위치", false); + siteCard.append(...siteBox.parts); const pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); @@ -235,7 +236,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { actions.append(saveButton, resetButton); const panel = document.createElement("div"); panel.className = "ui-sidebar-panel d03-plan__panel"; - panel.append(terrainCard, pointCard, drawCard, pieceCard, actions); + panel.append(siteCard, pointCard, drawCard, pieceCard, actions); attachCollapsible(panel); // 떠날 때(단계 아이콘 · [<] [>] · 머리 메뉴 · 뒤로) 고친 선이 있으면 공용 물음 [취소][폐기][저장](572-3) mountGroupStep(root, ROUTES.D03_PLAN_ANALYSIS, { @@ -248,7 +249,27 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { root.querySelector(".ui-workflow-layout")?.classList.add("d03-plan-layout"); const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); - const snap = (): Snap => ({ network, points, pointNames }); + const snap = (): Snap => ({ network, points, pointNames, site }); + /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ + const moved = (): TerrainCenter["center"] => + site && JSON.stringify(site) !== JSON.stringify(savedSite) + ? { lon: site.lon, lat: site.lat } + : null; + /** 기준점 둘레 ±1 km(작업 좌표) — 처음 맞춤 · 위치 옮김 · 임도망 맞춤. */ + const around = (at: Site | null): Vertex[] => { + const tm = tmParams(epsg); + if (!at || !tm) return []; + const [x, y] = lonLatToTm(tm, at.lon, at.lat); + return [ + [x - SITE_RADIUS_M, y - SITE_RADIUS_M], + [x + SITE_RADIUS_M, y + SITE_RADIUS_M], + ]; + }; + const fitTo = (box: Vertex[]): void => { + if (!box.length) return; + map.update({ fit: box }); + map.fit(); + }; const dirty = (): boolean => JSON.stringify(snap()) !== savedJson; const keepDraft = (): void => dirty() @@ -259,10 +280,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function step(next: Snap): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), snap()]; redoStack = []; - [network, points, pointNames] = [ + [network, points, pointNames, site] = [ normalizePieces(next.network, yearStart), next.points, next.pointNames, + next.site, ]; keepDraft(); refresh(); @@ -270,14 +292,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { } function commit(next: PlanNetwork): void { - step({ network: next, points, pointNames }); + step({ network: next, points, pointNames, site }); } function undo(): void { const last = undoStack.pop(); if (!last) return; redoStack.push(snap()); - ({ network, points, pointNames } = last); + ({ network, points, pointNames, site } = last); keepDraft(); refresh(); } @@ -286,7 +308,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const next = redoStack.pop(); if (!next) return; undoStack.push(snap()); - ({ network, points, pointNames } = next); + ({ network, points, pointNames, site } = next); keepDraft(); refresh(); } @@ -315,14 +337,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const inside = (list: Vertex[]): boolean => !!have && list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); - const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ - async function ensureCovered(): Promise { + async function ensureCovered(again = false): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const extra = [...points, ...allVertices()]; - if (inside(extra)) return; // 받을 때 서버가 여유를 더함 + const [lo, hi] = around(site); + const near = ([x, y]: Vertex): boolean => + !lo || Math.hypot(x - (lo[0] + hi[0]) / 2, y - (lo[1] + hi[1]) / 2) <= COVER_MAX_M; + const extra = [...points, ...allVertices()].filter(near); + if (!again && inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; @@ -343,34 +367,89 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); pieces.render(); marks.render(); - retryButton.hidden = !editable || busy || !failed; - roadButton.hidden = !editable; - if (!busy) - terrainText.textContent = failed - ? `지형 받기 실패 — ${failed}` - : !surface - ? editable - ? "받은 지형 없음 — 기준점 둘레를 받습니다(길이 · 기울기는 지형을 받은 뒤)." - : "받은 지형 없음" - : outside() - ? "지점 또는 그은 선이 받은 지형 밖 — 이어 받습니다." - : "받은 지형 안"; + siteBox.render({ + address: site?.address ?? null, + note: failed ? `지형 받기 실패 — ${failed}` : note, + retry: !busy && !!failed, + editable, + }); saveButton.hidden = resetButton.hidden = !editable; saveButton.disabled = busy || !dirty(); } - async function download(extra: Vertex[]): Promise { + /** 받기 — 멂(받은 범위에서 5 km 넘음) · 다른 TM 띠 = 물음 한 번 뒤 옛 범위 버리고 새로(`reset`) · + * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ + async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) + const where: TerrainCenter = { center: moved() }; try { - await startTerrain(projectId, null, extra); + let answer: TerrainStart = await startTerrain(projectId, null, extra, where); + if (answer.status === "far" || answer.status === "band") { + const band = answer.status === "band"; + const ok = await askConfirm({ + title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", + message: band + ? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.` + : `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`, + okLabel: "새로 받기", + danger: band, + }); + if (!ok) { + busy = false; + refresh(); + return false; + } + if (answer.status === "band") clearForBand(answer.center_epsg); + answer = await startTerrain(projectId, null, [], { ...where, reset: true }); + } } catch (error) { - // 이미 도는 일(다른 창)이면 그것을 지켜봄 if (!/이미 돌고/.test(errorText(error))) { busy = false; - return void showToast(errorText(error), "error"); + refresh(); + showToast(errorText(error), "error"); + return false; } } await follow(); + return true; + } + + /** 다른 TM 띠 — 작업 좌표가 바뀜 · 선 · 지점을 걷고 되돌리기를 비움(옛 좌표라 못 되돌림). */ + function clearForBand(next: number): void { + epsg = next; + [network, points, pointNames] = [emptyNetwork(), [], []]; + [undoStack, redoStack, have] = [[], [], null]; + map.update({ epsg }); + keepDraft(); + } + + /** 위치 칸 고름 — 기준점 캐시(한 걸음 · [저장] 때 정본) → 지도 이동 → 둘레 받기(멂 · 띠 = 물음 · 아니오 = 되돌림). */ + async function pickSite(hit: PlaceHit): Promise { + if (busy) return void showToast("지형을 받는 중 — 끝난 뒤 다시 고르세요.", "info"); + epsg ||= hit.lon < 126 ? 5185 : hit.lon < 128 ? 5186 : 5187; // 기준점 없던 프로젝트(서버 띠 규칙) + [note, failed, lastTried, ready] = ["", null, "", false]; // 걸음의 조용히 받기는 막고 아래에서 한 번 + step({ ...snap(), site: { lon: hit.lon, lat: hit.lat, address: hit.address || hit.title } }); + ready = true; + fitTo(around(site)); + if (await download([])) return; // 새 자리 둘레(center) — 지점 · 선은 끝난 뒤 조용히 받기가 + undo(); // 물음에 아니오 · 받기 못 함 = 위치 그대로 + redoStack.pop(); + fitTo(around(site)); + } + + /** 임도망 올리기 — 지도 맞춤 = 주소지 둘레 ∪ 잘린 임도 상자(없으면 주소지). */ + async function uploadRoads(files: File[]): Promise { + const info = await backdrop.uploadRoads(files); + const tm = tmParams(epsg); + if (!info) return; + const roads: Vertex[] = + tm && info.bounds + ? [ + lonLatToTm(tm, info.bounds[0], info.bounds[1]), + lonLatToTm(tm, info.bounds[2], info.bounds[3]), + ] + : []; + fitTo([...around(site), ...roads]); } /** 도는 받기를 지켜보고 끝나면 지형 · 바탕을 다시 읽음. */ @@ -432,14 +511,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ? fromServer(server.lines, server.marks, server.pieces, yearStart) : emptyNetwork(); points = view.base.points.map((p): Vertex => [p.x, p.y]); - pointNames = view.base.points.map(() => ""); // 594-1 이름 칸이 들어오면 `p.name ?? ""` + pointNames = view.base.points.map((p) => p.name ?? ""); + site = savedSite = view.base.site; if (view.base.epsg) epsg = view.base.epsg; savedJson = JSON.stringify(snap()); } function serverBody(): Parameters[1] { const { lines, pieces } = network; - return { epsg, points, lines, marks: marksOf(network), pieces }; + const body = { epsg, points, point_names: pointNames, lines, marks: marksOf(network), pieces }; + return moved() && site ? { ...body, site } : body; } /** [저장] — 서버가 다시 셈 · 실패면 그대로(캐시 남음). 참 = 저장됨. */ @@ -476,7 +557,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { draw.setMode("idle"); showLoadingOverlay(); try { - const body = { epsg, points, lines: [], marks: [], pieces: [] }; + const body = { epsg, points, point_names: pointNames, lines: [], marks: [], pieces: [] }; takeServer(await savePlanNetwork(projectId, body)); clearState("d03-plan", projectId); undoStack = []; @@ -509,6 +590,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { base: () => points, have: () => have, busy: () => busy, + site: () => site, + pickSite, lineNames: () => lineNames(network.lines), screenOf: map.screenOf, screenOfLonLat: map.screenOfLonLat, @@ -555,26 +638,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); - const { epsg: workEpsg, site } = view.base; - if (!workEpsg) { - terrainText.textContent = "기준점이 없습니다 — 예정노선 등록에서 정하세요."; - return; + if (!view.base.epsg) { + note = "기준점이 없습니다 — 주소를 찾아 정하세요."; + ready = true; + return refresh(); } - epsg = workEpsg; + epsg = view.base.epsg; takeServer(view); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network && Array.isArray(draft.points)) - ({ network, points, pointNames } = draft); - const tm = tmParams(epsg); - const siteXy = site && tm ? lonLatToTm(tm, site.lon, site.lat) : null; - const around: Vertex[] = siteXy - ? [ - [siteXy[0] - SITE_RADIUS_M, siteXy[1] - SITE_RADIUS_M], - [siteXy[0] + SITE_RADIUS_M, siteXy[1] + SITE_RADIUS_M], - ] - : []; + ({ network, points, pointNames, site } = draft); const drawn = [...points, ...allVertices()]; - map.update({ fit: drawn.length ? drawn : around, epsg }); + map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); await loadSurface(); refresh(); @@ -585,12 +660,13 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await follow(); } else if (editable && !job.error) { // 들어올 때 받은 범위가 기준점 둘레 · 지점을 못 덮으면 바로 받기(서버가 셈 · 실패 뒤엔 [다시 받기]) - const extent = await fetchTerrainExtent(projectId, null, []); + const extent = await fetchTerrainExtent(projectId, null, [], moved()); ready = true; if (!extent.covered) await download([]); else await ensureCovered(); } } catch (error) { - terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; + note = `불러오지 못했습니다 — ${errorText(error)}`; + refresh(); } } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts new file mode 100644 index 000000000..1670ccf37 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -0,0 +1,114 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Site.ts + * D03 「위치」 칸(PLAN 596-2 · 요구 463-3~6 · 19) — 지금 기준점 주소 한 줄 · 주소 · 장소 찾기 + * (서버 VWorld `searchPlaces` · 부품 `createFindList` · Enter = 찾기 · ↑ ↓ Enter = 고름) · + * 알림 한 줄(받기 실패 · 끊김 · 기준점 없음 · 못 불러옴만) + [다시 받기] · [임도망 올리기]. + * 고름 = 페이지가 받기 물음(멂 · 다른 TM 띠) → 기준점 캐시 → 지도 이동 → 둘레 받기 · [저장] 때 정본(등록과 한 칸). + * ========================================================================== */ + +import { pickFiles } from "@ui/ui_template_drop"; +import { createButton } from "@ui/ui_template_elements"; +import { createFindList, type FindList } from "@ui/ui_template_find_list"; +import { t } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; +import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; + +/** 찾기 글 최소 길이(서버 VWorld 한 글자 = 쓸모없는 후보). */ +const MIN_QUERY = 2; + +export interface PlanSiteParams { + /** 후보 고름 — 받기 · 캐시 · 이동은 페이지. */ + onPick: (hit: PlaceHit) => void; + onRetry: () => void; + onUpload: (files: File[]) => void; +} + +export interface PlanSiteState { + address: string | null; + /** 알림(실패 · 끊김 · 기준점 없음 · 오류) — 빈 값 = 숨김. */ + note: string; + retry: boolean; + editable: boolean; +} + +export interface PlanSite { + /** 칸 몸(카드에 바로 붙임 · 카드 간격을 그대로 씀). */ + parts: HTMLElement[]; + render: (state: PlanSiteState) => void; +} + +/** 주소 · 장소 찾기 칸(부품 `createFindList`) — Enter = 찾기 · ↑ ↓ Enter · 누름 = 고름 · + * 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */ +export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { + const find = createFindList({ placeholder: t("DE_Reg_Find") }); + let asked = 0; // 옛 찾기 답 버림 + + async function run(): Promise { + const query = find.search.value.trim(); + find.setRows([]); + if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort")); + const at = ++asked; + find.hint.textContent = t("DE_Reg_FindBusy"); + try { + const hits = await searchPlaces(query); + if (at !== asked) return; + find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone"); + find.setRows( + hits.map((hit, i) => ({ + key: String(i), + tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"), + name: hit.title, + sub: hit.address !== hit.title ? hit.address : undefined, + onPick: () => { + find.setRows([]); + find.search.value = hit.title; + onPick(hit); + }, + })), + ); + } catch (error) { + if (at === asked) + find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`; + } + } + // 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음 + find.search.addEventListener("input", () => find.setRows([])); + find.search.addEventListener("keydown", (event) => { + if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; + event.preventDefault(); + void run(); + }); + return find; +} + +export function createPlanSite(params: PlanSiteParams): PlanSite { + const address = createText("", "muted", 14); + const note = createText("", "error"); + const find = createPlaceFind(params.onPick); + + const retry = createButton({ + label: "다시 받기", + variant: "filled", + size: 28, + onClick: params.onRetry, + }); + const upload = createButton({ + label: "임도망 올리기", + variant: "ghost", + size: 28, + onClick: () => + pickFiles({ accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, params.onUpload), + }); + upload.title = "임도망도 SHP(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)"; + return { + parts: [address, find.root, note, retry, upload], + render(state) { + address.textContent = state.address ?? ""; + address.hidden = !state.address; + note.textContent = state.note; + note.hidden = !state.note; + find.root.hidden = upload.hidden = !state.editable; + retry.hidden = !state.editable || !state.retry; + }, + }; +} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 542dd501b..785667b65 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -35,6 +35,13 @@ def test_d03_parts(): layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 + site = _src( + "D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts" + ) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품 + assert "createFindList(" in site and "searchPlaces(" in site and "createPlanSite(" in ts + assert "createPlaceFind(" in _src("D01_PlanRegister/D01_PlanRegister_UI_Page.ts") + for raw in ('createElement("button")', 'createElement("input")'): + assert raw not in site, raw # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 for src in (ts, draw, draw_panel, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): @@ -89,8 +96,12 @@ def test_d03_parts(): props = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts") # 585-2 조각 연도 = 속성 판 assert "createSelectField(" in props and "createFloatPanel(" in props assert 'createElement("button")' not in props and 'createElement("select")' not in props - assert 'createElement("select")' not in three and "createSelectField" not in three # 601 선 고름 걷음 - assert "createCheckbox" not in three and "setCorridors(" in three # 601 노선망 예상 코리도 한 번에 + assert ( + 'createElement("select")' not in three and "createSelectField" not in three + ) # 601 선 고름 걷음 + assert ( + "createCheckbox" not in three and "setCorridors(" in three + ) # 601 노선망 예상 코리도 한 번에 for dead in (".d03-plan__bar", ".d03-plan__mode", "is-picked"): assert dead not in css, dead assert "b05-route__3d-modal" not in three and "b05-route__3d-body" not in three # 빌림 끊음 diff --git a/resources/tester/test_560_4_leave_save.py b/resources/tester/test_560_4_leave_save.py index 20fc22a06..b585c2d61 100644 --- a/resources/tester/test_560_4_leave_save.py +++ b/resources/tester/test_560_4_leave_save.py @@ -113,7 +113,7 @@ def test_de_pages_guard_leave(): if name: assert f'discard: () => clearState("{name}", projectId)' in call, path # D03 — 못 읽은 채(프로젝트 없음 · 불러오기 실패) 떠나면 고침 없음 - assert "let savedJson = JSON.stringify({ network, points });" in _src( + assert "let savedJson = JSON.stringify({ network, points, pointNames, site });" in _src( "D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts" ) e03 = _src("E03_StabSection/E03_StabSection_UI_Page.ts") 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 87ad0b83e..1c05d9549 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -255,7 +255,9 @@ def test_screen_defs() -> None: assert entry and entry.group(2) == "false", key # 처음 값 끔 assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), key assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page - assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창 + site = (folder / "D03_PlanAnalysis_UI_Site.ts").read_text("utf-8") # 596-2 올리기 = 위치 칸 + assert "pickFiles(" in site and "pickFiles(" not in page # 부품 상자 고르기 창 + assert "uploadRoads" in page and "uploadPlanForestRoads(" in page assert "") # 겹 모두 바탕 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index d9f51ecfe..1f6689cbe 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -102,6 +102,13 @@ export const ui_locales_de = { DE_Reg_SiteNoAddress: ["주소를 찾지 못했습니다", "Address not found"], DE_Reg_SiteSource: ["출처", "Source"], DE_Reg_SiteRequired: ["지도에서 기준점을 찍으세요.", "Set the reference point on the map."], + DE_Reg_Find: ["주소 · 장소 찾기 (Enter)", "Find address or place (Enter)"], + DE_Reg_FindShort: ["두 글자 이상", "Two or more characters"], + DE_Reg_FindBusy: ["찾는 중…", "Searching…"], + DE_Reg_FindNone: ["찾은 곳 없음", "Nothing found"], + DE_Reg_FindFail: ["찾지 못함", "Search failed"], + DE_Reg_FindAddress: ["주소", "Address"], + DE_Reg_FindPlace: ["장소", "Place"], DE_Reg_MapType: ["바탕 지도", "Base map"], DE_Reg_MapNormal: ["일반", "Map"], DE_Reg_MapSatellite: ["위성", "Satellite"], From 36750b380f3edce48b2a8dc738ada184177ec4d2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:02:01 +0900 Subject: [PATCH 09/14] =?UTF-8?q?fix(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=B0=9B=EA=B8=B0=20=EA=BC=B4=20=EC=9D=B4?= =?UTF-8?q?=EB=A6=84=20TerrainAim=20=EB=A7=9E=EC=B6=A4=20=E2=80=94=20595?= =?UTF-8?q?=20=ED=95=A9=EC=B9=A8=20=EB=92=A4=20typecheck=200=20(PLAN=20596?= =?UTF-8?q?-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 809917986..848270be6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -44,7 +44,7 @@ import { type PlanJob, type PlanNetworkView, type PlanSurface, - type TerrainCenter, + type TerrainAim, type TerrainStart, } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; @@ -251,7 +251,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const allVertices = (): Vertex[] => network.lines.flatMap((line) => line.vertices); const snap = (): Snap => ({ network, points, pointNames, site }); /** 위치를 옮기고 [저장] 전 — 받기 · 범위 물음에 새 기준점을 실음. */ - const moved = (): TerrainCenter["center"] => + const moved = (): TerrainAim["center"] => site && JSON.stringify(site) !== JSON.stringify(savedSite) ? { lon: site.lon, lat: site.lat } : null; @@ -381,7 +381,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { * 아니오 = 안 받음(거짓). 이미 도는 일(다른 창)이면 그것을 지켜봄. */ async function download(extra: Vertex[]): Promise { busy = true; // 요청이 가는 동안 다른 받기를 막음(지켜보기가 이어 받음) - const where: TerrainCenter = { center: moved() }; + const where: TerrainAim = { center: moved() }; try { let answer: TerrainStart = await startTerrain(projectId, null, extra, where); if (answer.status === "far" || answer.status === "band") { From ed7f7fa67443aaa7c451486af17ac8d5852d7429 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:02:48 +0900 Subject: [PATCH 10/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=85=B8=EB=93=9C=20=ED=8C=90=20=E2=80=94=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=C2=B7=20=EA=B0=92=20=EA=B3=A0=EC=B9=A8(?= =?UTF-8?q?=EC=95=9E=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EC=A4=80=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=86=93=EC=9D=8C=20=C2=B7=20=EB=92=A4=20=EB=85=B8?= =?UTF-8?q?=EB=93=9C=20=EA=B7=B8=EB=8C=80=EB=A1=9C=20=C2=B7=20=EC=96=B4?= =?UTF-8?q?=EA=B8=8B=EB=82=A8=20=E2=9A=A0)=20=C2=B7=20[=EC=A7=80=EC=A0=90?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C]=20=C2=B7=20[=EB=8B=A4=EC=8B=9C=20=EA=B8=8B?= =?UTF-8?q?=EA=B8=B0]=20=EC=9D=B4=EC=9D=8C=20(PLAN=20598-4=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20463-11=20=C2=B7=20464)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - logic/Select placeNode — 지점 자리 노드면 지점 같이 · 첫 노드 = 까닭 글 - UI_Props 노드 판 = 599-1 방식 칸(createDrawKindFields) · 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 - 판 · 메뉴 [다시 긋기] = draw.respan(599-3) - 시험 598_1 placeNode 줄 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts | 7 +-- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 3 + D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts | 62 ++++++++++++++++--- .../logic/D03_PlanAnalysis_Select.ts | 50 ++++++++++++++- .../tester/test_598_1_plan_select_layers.py | 10 +++ 5 files changed, 120 insertions(+), 12 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts index a121b8816..da66c2a69 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Menu.ts @@ -22,8 +22,8 @@ export interface PlanMenuParams { edit: PlanEdit; draw: PlanDraw; editable: () => boolean; - /** [다시 긋기](599 · 없으면 항목 없음) — 구역 id · 앞 노드 · 뒤 노드. */ - respan?: (line: string, from: number, to: number) => boolean; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanMenu { @@ -80,8 +80,7 @@ export function createPlanMenu(params: PlanMenuParams): PlanMenu { const seg = at.segment; start(px, py); items.push(["여기서 연도 구역 나눔", () => edit.splitAt(px, py, map)]); - if (params.respan) - items.push(["다시 긋기", () => params.respan!(seg.line, seg.index, seg.index + 1)]); + items.push(["다시 긋기", () => params.respan(seg.line, seg.index, seg.index + 1)]); } else start(px, py); if (pin === null && !at?.node && !at?.mark && !marks.full()) items.push("-", ["지점 놓기", () => marks.place(px, py, map)]); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 760fa990a..5dc63870f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -155,6 +155,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg: () => epsg, points: () => points, addPoint: (at) => step({ network, points: [...points, at], pointNames: [...pointNames, ""] }), + commitPoints: (nextPoints, next) => step({ network: next, points: nextPoints, pointNames }), + respan: draw.respan, }); const pieces = createPlanPieces({ network: () => network, edit }); const measure = createPlanMeasure( @@ -183,6 +185,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { edit, draw, editable: () => editable, + respan: draw.respan, }); // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts index 3c567223c..e5d33520a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Props.ts @@ -12,7 +12,7 @@ * 좌측 「구역 · 연도」 목록 = `UI_Pieces.ts`(페이지가 붙임). * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createButton, showToast } from "@ui/ui_template_elements"; import { createFloatPanel } from "@ui/ui_template_float_panel"; import { createSelectField } from "@ui/ui_template_input"; import { createKv, type KvRow } from "@ui/ui_template_kv"; @@ -26,8 +26,20 @@ import { type PlanPiece, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; -import { segmentPath, segmentPieces, segmentRange } from "./logic/D03_PlanAnalysis_Select"; +import { + placeNode, + segmentPath, + segmentPieces, + segmentRange, +} from "./logic/D03_PlanAnalysis_Select"; +import { nodeOff, nodeRecord } from "./logic/D03_PlanAnalysis_Span"; import { tmParams, tmToLonLat } from "./logic/D03_PlanAnalysis_Tm"; +import { + createDrawKindFields, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + type DrawKindValue, +} from "./D03_PlanAnalysis_UI_DrawPanel"; import type { PlanEdit, PlanSelection } from "./D03_PlanAnalysis_UI_Edit"; import { PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { selectionLength } from "./D03_PlanAnalysis_UI_Select"; @@ -50,8 +62,10 @@ export interface PlanPropsParams { /** 지점(노드 판 [지점으로] — 이미 있으면 숨김). */ points: () => Vertex[]; addPoint: (point: Vertex) => void; - /** [다시 긋기](599 · 없으면 단추 없음) — 구역 id · 앞 노드 · 뒤 노드. */ - respan?: (line: string, from: number, to: number) => boolean; + /** 노드 판 방식 · 값 바꿈 — 노드 · 그 자리 지점 한 걸음. */ + commitPoints: (points: Vertex[], next: PlanNetwork) => void; + /** [다시 긋기](599-3) — 구역 id · 앞 노드 · 뒤 노드. */ + respan: (line: string, from: number, to: number) => boolean; } export interface PlanProps { @@ -125,6 +139,36 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { return rows; } + /** 노드 방식 · 값(598-4 · 599-1 부품) — 방식 고름 = 바로 · 수 칸 = Enter · 칸 떠날 때 · 앞 노드 기준 다시 놓음. */ + function nodeKind(line: number, vertex: number): HTMLElement { + const record = params.network().lines[line].nodes?.[vertex] ?? null; + let value: DrawKindValue = { + kind: record?.kind ?? "free", + lengthM: record?.length_m ?? DRAW_DEFAULT_LENGTH_M, + slopePct: record?.slope_pct ?? DRAW_DEFAULT_SLOPE_PCT, + }; + const apply = (): void => { + const network = params.network(); + const next = nodeRecord(value.kind, value.lengthM, value.slopePct); + const out = placeNode(network, params.points(), line, vertex, next, params.heightAt()); + if (typeof out === "string") return void showToast(out, "info"); + if (JSON.stringify(out.network.lines) !== JSON.stringify(network.lines)) + params.commitPoints(out.points, out.network); + }; + const fields = createDrawKindFields({ + value, + onChange: (next) => { + const kindChanged = next.kind !== value.kind; + value = next; + if (kindChanged) apply(); + }, + }); + fields.root.addEventListener("change", apply); + fields.root.dataset.role = "props-node-kind"; + fields.setDisabled(!params.editable()); + return fields.root; + } + /** 고름 꼴마다 — 머리 글 · 몸 · 단추. */ function body( picked: PlanSelection, @@ -159,13 +203,17 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { { label: "앞 선 기울기", value: percent(groundSlopePct(a, b, z)) }, ); } + const entry = network.lines.find((l) => l.id === line)!; + if (nodeOff(entry, vertex, z)) rows.push({ label: "기록", value: "⚠ 지금 자리와 어긋남" }); + const parts: HTMLElement[] = [createKv(rows)]; + if (vertex > 0) parts.push(nodeKind(network.lines.indexOf(entry), vertex)); const toPoint = button("지점으로", "props-to-point", () => params.addPoint([point[0], point[1]]), ); toPoint.hidden = params.points().some((p) => samePoint(p, point)); return { title: `${nameOf(line)} · 노드 ${vertex + 1}`, - parts: [createKv(rows)], + parts, actions: [ button("노드 지움", "props-drop-node", () => edit.dropNode(picked.node!)), toPoint, @@ -191,10 +239,10 @@ export function createPlanProps(params: PlanPropsParams): PlanProps { } rows.push({ label: "연도", value: years.map((y) => `${y}년`).join(" · ") }); const actions = [button("선 지움", "props-drop-segment", edit.dropSelected)]; - if (single && params.respan) + if (single) actions.unshift( button("다시 긋기", "props-respan", () => - params.respan!(single.line, single.index, single.index + 1), + params.respan(single.line, single.index, single.index + 1), ), ); return { diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts index ba25df693..4b44d001a 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Select.ts @@ -4,15 +4,26 @@ * 저장 꼴은 그대로(`PlanNetwork`) — 선 = 「구역 id + 토막 차례」로 가리킴 · 낱 자료 없음. * 토막 지움 = 끝 토막은 끝 점 뺌 · 가운데 토막은 구역이 둘로(뒤쪽 = 새 구역 · 갈래 고리 · 조각이 따라감). * 지점 ↔ 노드 = 같은 자리(1 mm)로 묶음 — 묶인 노드 · 지점 찾기. + * 노드 판(598-4) 방식 · 값 바꿈 = 앞 노드 기준 같은 방향으로 다시 놓음 · 뒤 노드 그대로(어긋남 ⚠ = 599-2 `nodeOff`). * DOM · fetch · sessionStorage 없음 · 조각 맞춤은 부르는 쪽(`normalizePieces`). * ========================================================================== */ -import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw"; import { + compassPoint, + fixedLengthPoint, + fixedSlopePoint, + lineLength, + segmentLength, + type HeightAt, +} from "./D03_PlanAnalysis_Draw"; +import { + moveNodesAt, + moveVertex, piecesInBox, removeLine, samePoint, orderLines, + type NodeRecord, type PlanLine, type PlanNetwork, type PlanPiece, @@ -237,3 +248,40 @@ export function nodesAt(network: PlanNetwork, point: Vertex): PlanNode[] { export function movePointsAt(points: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { return points.map((p): Vertex => (samePoint(p, from) ? [to[0], to[1]] : p)); } + +/** 노드 판(598-4) — `vertex` 노드 기록을 `record` 로 · 앞 노드 기준 지금 방향으로 다시 놓음(자유 = 자리 그대로) · + * 뒤 노드 그대로 · 그 자리 지점 · 같은 자리 노드도 같이 · 첫 노드 · 자리 못 찾음 = 까닭 글. */ +export function placeNode( + network: PlanNetwork, + points: ReadonlyArray, + line: number, + vertex: number, + record: NodeRecord, + heightAt: HeightAt, +): { network: PlanNetwork; points: Vertex[] } | string { + const entry = network.lines[line]; + if (!entry || vertex < 1 || vertex >= entry.vertices.length) return "첫 노드는 방식이 없습니다."; + const [prev, here] = [entry.vertices[vertex - 1], entry.vertices[vertex]]; + const { kind, length_m: lengthM, slope_pct: slopePct } = record; + const to = + kind === "free" + ? here + : kind === "length" + ? fixedLengthPoint(prev, here, lengthM ?? 0) + : kind === "slope" + ? fixedSlopePoint(prev, here, slopePct ?? 0, heightAt) + : compassPoint(prev, here, lengthM ?? 0, slopePct ?? 0, heightAt); + if (!to) return "그 값으로 놓을 자리가 없습니다(지형 · 길이를 보세요)."; + const bound = points.some((p) => samePoint(p, here)); + let next = bound ? moveNodesAt(network, here, to) : moveVertex(network, line, vertex, to); + next = { + ...next, + lines: next.lines.map((l) => { + if (l.id !== entry.id) return l; + const nodes = l.nodes ? [...l.nodes] : l.vertices.map(() => null); + nodes[vertex] = record; + return { ...l, nodes }; + }), + }; + return { network: next, points: bound ? movePointsAt(points, here, to) : [...points] }; +} diff --git a/resources/tester/test_598_1_plan_select_layers.py b/resources/tester/test_598_1_plan_select_layers.py index 0ae23beea..153a37311 100644 --- a/resources/tester/test_598_1_plan_select_layers.py +++ b/resources/tester/test_598_1_plan_select_layers.py @@ -52,6 +52,11 @@ writeFileSync(process.argv[2], JSON.stringify({ all: show(sel.removeAll(n)), nodes: sel.nodesAt(n, [200, 0]), moved: sel.movePointsAt([[200, 0], [5, 5]], [200, 0], [210, 5]), + placed: (() => { + const out = sel.placeNode(n, [[100, 0], [9, 9]], 0, 1, { kind: "length", length_m: 50, slope_pct: null }, (x) => x * 0.1); + return [out.network.lines[0].vertices, out.network.lines[0].nodes, out.points]; + })(), + placeFirst: sel.placeNode(n, [], 0, 0, { kind: "free", length_m: null, slope_pct: null }, () => 0), nodes594: (() => { const r = { kind: "length", length_m: 100, slope_pct: null }; const withNodes = { ...n, lines: n.lines.map((l, i) => (i ? l : { ...l, nodes: [null, r, r, r] })) }; @@ -133,6 +138,11 @@ def test_끌어_고르기_전체_지점_노드(tmp_path: Path) -> None: assert got["all"] == {"lines": [], "pieces": []} assert got["nodes"] == [{"line": "main", "vertex": 2}] # 갈래 첫 점 뺌 assert got["moved"] == [[210, 5], [5, 5]] + vertices, nodes, points = got["placed"] + assert vertices == [[0, 0], [50, 0], [200, 0], [300, 0]] # 앞 노드 기준 50 m · 뒤 노드 그대로 + assert nodes[1] == {"kind": "length", "length_m": 50, "slope_pct": None} and nodes[0] is None + assert points == [[50, 0], [9, 9]] # 그 자리 지점 같이 + assert got["placeFirst"] == "첫 노드는 방식이 없습니다." rec = {"kind": "length", "length_m": 100, "slope_pct": None} assert got["nodes594"] == [ ["main", [None, rec]], From edc20df849aa73a9b7460d68752aa868bf321988 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:12:27 +0900 Subject: [PATCH 11/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B8=89=EA=B2=BD=EC=82=AC=20=EB=B2=94=EC=9C=84?= =?UTF-8?q?=20=EC=98=B5=EC=85=98=20=EB=AA=A8=EB=8B=AC=20=E2=80=94=20?= =?UTF-8?q?=EA=B8=B0=EC=9A=B8=EA=B8=B0=20%=20=EA=B0=92=20=EA=B7=B8?= =?UTF-8?q?=EB=A6=BC=20=C2=B7=201=20%=20=EC=B9=B8=20=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B9=A0=ED=95=98=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=E2=9A=99=20(PLAN=20603=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20466~468)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 15 · 35 · 40° 단계 PNG 길을 걷고 기울기 % 값 그림 한 장(칸 = % 내림 0~250 · 빈 칸 255) + 1 % 칸 수 · 파생 저장본 판 2(열쇠 = 격자 지문) - 화면: 고른 범위대로 값 그림을 칠함(서버 왕복 없음) · 범례 비율 = 칸 수로 바로 셈 · 범례 급경사 줄 ⚙ → 모달(아래 % · 위 % · 색 · ° 환산 · 범위 추가 · 지움 · 기본값 · 빈 값 · 겹침 막음) - 기본 = 지식DB 15 · 35 · 40° 의 % 26.8 · 70.0 · 83.9 · 기억 = 화면 취향 aislo:pref:d03.slope-bands(계정) - 시험: 569_1 급경사 시험을 값 그림 · 칸 수로 고침, 581_1 은 bands 대신 counts(옛 단계 응답이 없어짐) · 새 603 시험(칸 수 합 = 유효 칸 · 기본 비율 = 옛 15 · 35 · 40° 비율) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 5 +- D03_PlanAnalysis/D03_PlanAnalysis_Layers.py | 52 ++-- D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 2 +- .../D03_PlanAnalysis_UI_LayerDefs.ts | 2 +- .../D03_PlanAnalysis_UI_LayerLegend.ts | 49 +++- .../D03_PlanAnalysis_UI_Layers.ts | 55 +++- .../D03_PlanAnalysis_UI_SlopeBands.ts | 236 ++++++++++++++++++ .../logic/D03_PlanAnalysis_SlopeBands.ts | 98 ++++++++ resources/tester/test_569_1_plan_layers.py | 28 ++- .../tester/test_581_1_plan_gis_layers.py | 2 +- resources/tester/test_603_plan_slope_bands.py | 172 +++++++++++++ 11 files changed, 641 insertions(+), 60 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts create mode 100644 resources/tester/test_603_plan_slope_bands.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 9937062db..d353e00ba 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -179,13 +179,14 @@ export async function fetchPlanSurface(projectId: string): Promise; image: string; - bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>; + /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ + counts?: number[]; } export const fetchPlanCadastral = (projectId: string): Promise => diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 1d365152e..3cc402d0e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -5,7 +5,8 @@ 한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 · 저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠 - 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` · - 15° · 35° · 40° 단계 PNG · 파생 저장본(열쇠 = 격자 지문) + 기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) · + 파생 저장본(열쇠 = 격자 지문) - 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1) - 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) · 받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 · @@ -64,13 +65,9 @@ SLOPE_KIND = "plan_slope" HILLSHADE_KIND = "plan_hillshade" HILLSHADE_VERSION = 2 # 빈 칸 투명 RASTER_KINDS = ("slope", "hillshade") -SLOPE_VERSION = 1 -# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속 -SLOPE_BANDS = ( - (15.0, 35.0, (250, 204, 21, 110)), - (35.0, 40.0, (249, 115, 22, 140)), - (40.0, 90.0, (220, 38, 38, 160)), -) +SLOPE_VERSION = 2 # 값 그림 + 1 % 칸 수(PLAN 603) +SLOPE_MAX_PCT = 250 # 칸 값 0~250 = 기울기 % 내림(250 = 250 % 이상) +SLOPE_EMPTY = 255 # 빈 칸 MAX_SIDE_PX = 4096 # ponytail: 한 변 넘으면 솎음 · 넓은 사업은 조각 그림으로 @@ -215,39 +212,28 @@ def ensure_cadastral(processed: Path, box: Box, epsg: int) -> dict[str, Any]: # ── 급경사 ────────────────────────────────────────────── -def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: - """격자(행 = 남 → 북) → 단계 PNG(위 = 북 · 15° 미만 · 빈 칸 투명) + 단계 칸 비율.""" +def slope_values_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]: + """격자(행 = 남 → 북) → 기울기 % 값 그림(회색 · 위 = 북 · 칸 값 = % 내림 0~250 · 빈 칸 255) + + 1 % 칸 수(`counts[v]` = 값 v 칸 수 · 합 = 유효 칸).""" from PIL import Image from E07_StabArea.E07_StabArea_Engine import slope_rad stride = max(1, math.ceil(max(z.shape) / MAX_SIDE_PX)) grid = np.where(valid, np.asarray(z, float), np.nan)[::stride, ::stride] - deg = np.degrees(slope_rad(grid, cell_m * stride)) - known = np.isfinite(deg) - index = np.zeros(deg.shape, np.uint8) - for i, (low, high, _) in enumerate(SLOPE_BANDS, start=1): - index[known & (deg >= low) & (deg < high)] = i - total = max(1, int(known.sum())) - image = Image.fromarray(np.flipud(index), mode="P") - palette = [0, 0, 0] + [c for _, _, rgba in SLOPE_BANDS for c in rgba[:3]] - image.putpalette(palette + [0] * (768 - len(palette))) - alpha = bytes([0] + [rgba[3] for _, _, rgba in SLOPE_BANDS]) + pct = np.tan(slope_rad(grid, cell_m * stride)) * 100.0 + known = np.isfinite(pct) + value = np.full(pct.shape, SLOPE_EMPTY, np.uint8) + value[known] = np.minimum(np.floor(pct[known]), SLOPE_MAX_PCT).astype(np.uint8) + image = Image.fromarray(np.flipud(value), mode="L") buffer = io.BytesIO() - image.save(buffer, format="PNG", optimize=True, transparency=alpha) + image.save(buffer, format="PNG", optimize=True) + counts = np.bincount(value[known], minlength=SLOPE_MAX_PCT + 1) return { "width": image.width, "height": image.height, "image": "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii"), - "bands": [ - { - "min_deg": low, - "max_deg": high, - "color": "#{:02x}{:02x}{:02x}".format(*rgba[:3]), - "ratio": round(int((index == i).sum()) / total, 4), - } - for i, (low, high, rgba) in enumerate(SLOPE_BANDS, start=1) - ], + "counts": [int(n) for n in counts], } @@ -269,17 +255,17 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict def slope_layer( project_root: Path, models_dir: Path, epsg: int, kind: str = "slope" ) -> dict[str, Any]: - """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본.""" + """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본.""" npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" stamp = file_stamp(npz) if stamp is None: raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.") store_kind, version, build = ( - (SLOPE_KIND, SLOPE_VERSION, slope_bands_png) + (SLOPE_KIND, SLOPE_VERSION, slope_values_png) if kind == "slope" else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png) ) - key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX) + key = derived_key(store_kind, version, npz.name, stamp, int(epsg), MAX_SIDE_PX) saved = read_derived(project_root, store_kind, key) if saved is not None: return saved["value"] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 3b430b71b..e1e7f351a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -320,7 +320,7 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response: @router.get("/{project_id}/plan/layers/raster", response_model=None) async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any]: - """급경사 단계 그림(15° · 35° · 40°) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · + """급경사 기울기 % 값 그림 + 1 % 칸 수(603) · 음영기복(`hillshade`) — 격자 지문 같으면 저장본 · 산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본.""" if kind == "landslide": root, epsg, box = await _layer_root(project_id) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index 9075b136a..eb9f09827 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -55,7 +55,7 @@ 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: "급경사 색(15° · 35° · 40° 이상)", on: false }, + { key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false }, ], [ { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 54256e16e..d07d5131a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -1,6 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerLegend.ts * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. + * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). * ========================================================================== */ @@ -23,6 +24,12 @@ import { type LineLayerKey, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + SLOPE_MAX_PCT, + bandRatios, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ export interface PreparedLines { @@ -39,7 +46,15 @@ export type Prepared = Record & { road: { all: PreparedLayer; small: PreparedLayer }; /** 올린 임도망도(584-3). */ forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads }; - slope: { raster: PlanRaster; image: HTMLImageElement }; + /** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */ + slope: { + raster: PlanRaster; + image: HTMLImageElement; + values: ImageData; + bands: SlopeBand[]; + url: string; + paintMs: number; + }; hillshade: { raster: PlanRaster; image: HTMLImageElement }; landslide: { raster: PlanRaster; image: HTMLImageElement }; /** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */ @@ -55,6 +70,19 @@ export type Prepared = Record & { const km = (m: number): string => `${(m / 1000).toFixed(2)} km`; +/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */ +function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] { + const ratios = bandRatios(raster.counts ?? [], bands); + const deg = (pct: number): string => pctToDeg(pct).toFixed(0); + return bands.map((band, i) => { + const open = band.high >= SLOPE_MAX_PCT; + const range = open + ? `${band.low} %↑(${deg(band.low)}°↑)` + : `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`; + return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` }; + }); +} + /** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */ function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] { const color = "var(--draw-map-landslide-zone)"; @@ -86,13 +114,7 @@ export function legendRows( data: Partial, ): LegendItem[][] { const rows: LegendItem[][] = []; - if (on.slope && data.slope) - rows.push( - data.slope.raster.bands.map((band) => ({ - label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`, - color: band.color, - })), - ); + if (on.slope && data.slope) rows.push(slopeRow(data.slope)); if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND); if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones)); if (on.zones && data.zones) { @@ -211,6 +233,17 @@ export const layerCounts = (data: Partial): Record => band_road_m2: data.bands.info.road.area_m2, }), ...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }), + ...(data.slope && { + slope_px: data.slope.image.naturalWidth, + slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0), + slope_paint_ms: data.slope.paintMs, + ...Object.fromEntries( + bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [ + `slope_band_${i}`, + r, + ]), + ), + }), ...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }), ...Object.fromEntries( LINE_KEYS.flatMap((key) => { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index fd78809cc..8bc357afe 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -16,6 +16,7 @@ * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). @@ -72,6 +73,14 @@ import { import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; +import { + openSlopeBands, + paintSlope, + readSlopeBands, + slopeGear, + slopeValues, + writeSlopeBands, +} from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -200,9 +209,38 @@ export function createPlanLayers(opts: { gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const rows = meta ? legendRows(on, data) : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); + if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); legend.hidden = !rows.length; } + /** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */ + let slopeBands = readSlopeBands(); + let slopeTurn = 0; + const gear = slopeGear(() => + openSlopeBands(slopeBands, (bands) => { + slopeBands = bands; + writeSlopeBands(bands); + if (data.slope) data.slope.bands = bands; + sync(); + void repaintSlope(); + }), + ); + async function repaintSlope(): Promise { + const got = data.slope; + if (!got) return; + const turn = ++slopeTurn; + const started = performance.now(); + const painted = await paintSlope(got.values, got.bands); + if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url); + const old = got.url; + Object.assign(got, { image: painted.image, url: painted.url }); + got.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(got.raster, painted.url)); + if (old) URL.revokeObjectURL(old); + sync(); + map.redrawBase(); + } + sync(); useGl(glOn); @@ -353,7 +391,22 @@ export function createPlanLayers(opts: { } as GeoJsonCollection); if (info.missing.length) showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); - } else if (key === "slope" || key === "hillshade" || key === "landslide") { + } else if (key === "slope") { + const raster = await fetchPlanRaster(projectId, key); + const values = slopeValues(await loadImage(raster.image)); + const bands = slopeBands; + const started = performance.now(); + const painted = await paintSlope(values, bands); + if (data.slope?.url) URL.revokeObjectURL(data.slope.url); + data.slope = { raster, values, bands, ...painted, paintMs: 0 }; + data.slope.paintMs = Math.round(performance.now() - started); + gl.setData("slope", glImage(raster, painted.url)); + // 칠하는 동안 범위가 바뀜 = 새 범위로 다시 + if (bands !== slopeBands) { + data.slope.bands = slopeBands; + void repaintSlope(); + } + } else if (key === "hillshade" || key === "landslide") { const raster = await fetchPlanRaster(projectId, key); const image = await loadImage(raster.image); data[key] = { raster, image }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts new file mode 100644 index 000000000..fb0cb92f1 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts @@ -0,0 +1,236 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_SlopeBands.ts + * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 + * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). + * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). + * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). + * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. + * ========================================================================== */ + +import { createButton, createIconButton } from "@ui/ui_template_button"; +import { createInputField, createSelectField } from "@ui/ui_template_input"; +import { createLegend } from "@ui/ui_template_flow_legend"; +import { openModal } from "@ui/ui_template_modal"; +import { themeColor } from "@ui/ui_template_palette"; +import { createText } from "@ui/ui_template_text"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { + DEFAULT_SLOPE_BANDS, + SLOPE_COLORS, + SLOPE_MAX_BANDS, + SLOPE_MAX_PCT, + bandOf, + checkBands, + parseBands, + pctToDeg, + type SlopeBand, +} from "./logic/D03_PlanAnalysis_SlopeBands"; + +const PREF_KEY = "aislo:pref:d03.slope-bands"; +/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ +const ALPHA = 150; + +export const readSlopeBands = (): SlopeBand[] => parseBands(readByKey(PREF_KEY)); +export const writeSlopeBands = (bands: SlopeBand[]): void => + writeByKey(PREF_KEY, JSON.stringify(bands)); + +/** 값 그림 → 칸 값(빨강 자리 = 회색 값). */ +export function slopeValues(image: HTMLImageElement): ImageData { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d", { willReadFrequently: true })!; + context.drawImage(image, 0, 0); + return context.getImageData(0, 0, canvas.width, canvas.height); +} + +/** 범위대로 칠한 그림(주소 = blob · 다 쓰면 부르는 쪽이 `URL.revokeObjectURL`). */ +export async function paintSlope( + values: ImageData, + bands: SlopeBand[], +): Promise<{ url: string; image: HTMLImageElement }> { + const rgb = bands.map((band) => hexRgb(themeColor(band.color, "#facc15"))); + const lut = new Uint32Array(256); + const view = new DataView(lut.buffer); + for (let v = 0; v < 256; v++) { + const i = bandOf(v, bands); + if (i >= 0) view.setUint32(v * 4, ((rgb[i] << 8) | ALPHA) >>> 0, false); + } + const out = new ImageData(values.width, values.height); + const src = values.data; + const dst = new Uint32Array(out.data.buffer); + for (let p = 0; p < dst.length; p++) dst[p] = lut[src[p * 4]]; + const canvas = document.createElement("canvas"); + canvas.width = values.width; + canvas.height = values.height; + canvas.getContext("2d")!.putImageData(out, 0, 0); + const blob = await new Promise((done, fail) => + canvas.toBlob( + (got) => (got ? done(got) : fail(new Error("그림을 만들지 못했습니다"))), + "image/png", + ), + ); + const url = URL.createObjectURL(blob); + return { url, image: await loadImage(url) }; +} + +/** `#rrggbb` → 0xrrggbb(못 읽으면 노랑). */ +function hexRgb(color: string): number { + const hex = /^#?([0-9a-f]{6})$/i.exec(color.trim())?.[1]; + return hex ? parseInt(hex, 16) : 0xfacc15; +} + +/** 범례 급경사 줄 ⚙. */ +export const slopeGear = (onClick: () => void): HTMLButtonElement => { + const button = createIconButton({ + icon: "settings", + title: "급경사 범위 바꾸기(기울기 %)", + size: 20, + onClick, + }); + button.dataset.role = "slope-bands"; + return button; +}; + +const degText = (value: string): string => { + const pct = Number(value); + return value.trim() && Number.isFinite(pct) ? `≈ ${pctToDeg(pct).toFixed(1)}°` : "—"; +}; + +/** 옵션 모달 — [적용] = 막을 것 없으면 `onApply` 후 닫음. */ +export function openSlopeBands(current: SlopeBand[], onApply: (bands: SlopeBand[]) => void): void { + let rows: Array<{ root: HTMLElement; read: () => SlopeBand }> = []; + const list = document.createElement("div"); + const error = createText("", "error"); + error.hidden = true; + const addButton = createButton({ + label: "범위 추가", + variant: "ghost", + size: 28, + iconStart: "plus", + onClick: () => { + const last = rows.at(-1)?.read(); + const low = last && Number.isFinite(last.high) ? Math.min(last.high, SLOPE_MAX_PCT - 1) : 0; + const color = SLOPE_COLORS[rows.length % SLOPE_COLORS.length].color; + add({ low, high: SLOPE_MAX_PCT, color }); + }, + }); + addButton.dataset.role = "slope-band-add"; + + function refresh(): void { + addButton.disabled = rows.length >= SLOPE_MAX_BANDS; + error.hidden = true; + } + + function add(band: SlopeBand): void { + const field = (label: string, value: number) => { + const got = createInputField({ + label, + type: "number", + unit: "%", + min: 0, + max: SLOPE_MAX_PCT, + size: 28, + value: Number.isFinite(value) ? String(value) : "", + }); + const deg = createText(degText(got.input.value), "muted"); + got.input.addEventListener("input", () => { + deg.textContent = degText(got.input.value); + refresh(); + }); + got.root.append(deg); + return got; + }; + const low = field("아래", band.low); + const high = field("위", band.high); + const color = createSelectField({ + label: "색", + size: 28, + options: SLOPE_COLORS.map((c) => ({ value: c.color, text: c.name })), + value: band.color, + }); + const chip = createLegend([{ label: "", color: `var(${band.color})` }]); + color.select.addEventListener("change", () => + chip + .querySelector(".ui-legend__mark")! + .style.setProperty("--legend-color", `var(${color.select.value})`), + ); + const root = document.createElement("div"); + root.className = "d03-plan__row"; + root.dataset.role = "slope-band"; + const entry = { + root, + read: (): SlopeBand => ({ + low: low.input.value.trim() === "" ? NaN : Number(low.input.value), + high: high.input.value.trim() === "" ? NaN : Number(high.input.value), + color: color.select.value, + }), + }; + const remove = createIconButton({ + icon: "trash", + title: "이 범위 지움", + size: 28, + onClick: () => { + rows = rows.filter((row) => row !== entry); + root.remove(); + refresh(); + }, + }); + root.append(low.root, high.root, color.root, chip, remove); + rows.push(entry); + list.append(root); + refresh(); + } + + function fill(bands: SlopeBand[]): void { + rows = []; + list.replaceChildren(); + bands.forEach(add); + } + + openModal({ + title: "급경사 범위(기울기 %)", + closeLabel: "닫기", + width: "40rem", + mount: (body, close, actions) => { + fill(current); + const reset = createButton({ + label: "기본값", + variant: "ghost", + size: 28, + onClick: () => fill(DEFAULT_SLOPE_BANDS), + }); + reset.dataset.role = "slope-band-default"; + const apply = createButton({ + label: "적용", + size: 28, + onClick: () => { + const bands = rows.map((row) => row.read()); + const why = checkBands(bands); + if (why) { + error.lastChild!.textContent = why; + error.hidden = false; + return; + } + onApply(bands.sort((a, b) => a.low - b.low)); + close(); + }, + }); + apply.dataset.role = "slope-band-apply"; + actions.append(apply); + const foot = document.createElement("div"); + foot.className = "d03-plan__row"; + foot.append(addButton, reset); + body.append( + createText( + `0~${SLOPE_MAX_PCT} % · 아래 포함 · 위 뺌 · 겹치면 안 됨 · 기본 = 15 · 35 · 40°(지식DB)`, + "hint", + ), + list, + foot, + error, + ); + }, + }); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts new file mode 100644 index 000000000..2fa0ab653 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * D03_PlanAnalysis_SlopeBands.ts + * D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). + * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · + * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. + * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. + * 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`. + * ========================================================================== */ + +export interface SlopeBand { + /** 아래 % (포함) */ + low: number; + /** 위 % (뺌 · 250 = 끝까지) */ + high: number; + /** 그림 색 이름(`--draw-map-*`) — `SLOPE_COLORS` 중 하나 */ + color: string; +} + +export const SLOPE_MAX_PCT = 250; +export const SLOPE_EMPTY = 255; +export const SLOPE_MAX_BANDS = 8; + +/** 고를 색(이름 · 그림 색 값). */ +export const SLOPE_COLORS: Array<{ name: string; color: string }> = [ + { name: "노랑", color: "--draw-map-landslide-3" }, + { name: "주황", color: "--draw-map-landslide-2" }, + { name: "빨강", color: "--draw-map-landslide-1" }, + { name: "연두", color: "--draw-map-landslide-4" }, + { name: "초록", color: "--draw-map-landslide-5" }, + { name: "하늘", color: "--draw-map-stream" }, + { name: "파랑", color: "--draw-map-zone-water" }, + { name: "보라", color: "--draw-map-zone-heritage" }, + { name: "분홍", color: "--draw-map-parcel" }, +]; + +export const DEFAULT_SLOPE_BANDS: SlopeBand[] = [ + { low: 26.8, high: 70.0, color: "--draw-map-landslide-3" }, + { low: 70.0, high: 83.9, color: "--draw-map-landslide-2" }, + { low: 83.9, high: SLOPE_MAX_PCT, color: "--draw-map-landslide-1" }, +]; + +export const pctToDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; + +/** 범위 고칠 거리 — 없으면 null(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음 · 너무 많음). */ +export function checkBands(bands: SlopeBand[]): string | null { + if (!bands.length) return "범위가 하나는 있어야 합니다"; + if (bands.length > SLOPE_MAX_BANDS) return `범위는 ${SLOPE_MAX_BANDS}개까지입니다`; + for (const [i, band] of bands.entries()) { + const at = `${i + 1}번째 범위`; + if (!Number.isFinite(band.low) || !Number.isFinite(band.high)) return `${at} — 값이 비었습니다`; + if (band.low < 0 || band.high > SLOPE_MAX_PCT) return `${at} — 0~${SLOPE_MAX_PCT} % 안으로`; + if (band.low >= band.high) return `${at} — 아래가 위보다 작아야 합니다`; + if (!SLOPE_COLORS.some((c) => c.color === band.color)) return `${at} — 색을 고르세요`; + } + const sorted = [...bands].sort((a, b) => a.low - b.low); + for (let i = 1; i < sorted.length; i++) + if (sorted[i].low < sorted[i - 1].high) + return `범위가 겹칩니다 — ${sorted[i - 1].low}~${sorted[i - 1].high} % 와 ${sorted[i].low}~${sorted[i].high} %`; + return null; +} + +/** 저장 글 → 범위(깨졌거나 맞지 않으면 기본). */ +export function parseBands(raw: string | null): SlopeBand[] { + try { + const got = JSON.parse(raw ?? "null") as unknown; + if (Array.isArray(got)) { + const bands = got.map((b: Partial) => ({ + low: Number(b?.low), + high: Number(b?.high), + color: String(b?.color), + })); + if (!checkBands(bands)) return bands; + } + } catch { + // 깨진 값 = 기본 + } + return DEFAULT_SLOPE_BANDS.map((b) => ({ ...b })); +} + +/** 칸 값 v(0~255) → 범위 차례(없으면 −1). */ +export function bandOf(v: number, bands: SlopeBand[]): number { + if (v > SLOPE_MAX_PCT) return -1; + const mid = v + 0.5; + return bands.findIndex( + (b) => b.low <= mid && (mid < b.high || (b.high >= SLOPE_MAX_PCT && v === SLOPE_MAX_PCT)), + ); +} + +/** 1 % 칸 수 → 범위별 비율(유효 칸 대비). */ +export function bandRatios(counts: number[], bands: SlopeBand[]): number[] { + const total = counts.reduce((sum, n) => sum + n, 0) || 1; + const out = bands.map(() => 0); + counts.forEach((n, v) => { + const i = bandOf(v, bands); + if (i >= 0) out[i] += n; + }); + return out.map((n) => n / total); +} diff --git a/resources/tester/test_569_1_plan_layers.py b/resources/tester/test_569_1_plan_layers.py index 60c745a83..7ee16ea06 100644 --- a/resources/tester/test_569_1_plan_layers.py +++ b/resources/tester/test_569_1_plan_layers.py @@ -1,5 +1,5 @@ """D 노선분석 지도 겹(PLAN 569-1 · 595-1) — 도엽 겹 풀어 냄 · 지적도 1 km 조각 · 1,000 꽉 차면 넷 · -겹친 필지 거름 · 실패 조각만 다시 · 급경사 15° · 35° · 40° 단계 · 격자 지문 같으면 다시 안 셈. +겹친 필지 거름 · 실패 조각만 다시 · 급경사 % 값 그림 · 1 % 칸 수(603) · 격자 지문 같으면 다시 안 셈. ./venv/Scripts/python.exe -m pytest resources/tester/test_569_1_plan_layers.py -q """ @@ -169,20 +169,22 @@ def _indices(value) -> np.ndarray: return np.asarray(Image.open(io.BytesIO(raw))) -def test_slope_bands_and_north_up() -> None: +def test_slope_values_and_north_up() -> None: + """값 그림(603) — 칸 = 기울기 % 내림 · 위 = 북 · 빈 칸 · 끝 줄 = 255 · 칸 수 합 = 유효 칸.""" z = _plane(10.0, 37.0) - value = layers.slope_bands_png(z, np.ones(z.shape, bool), 2.0) - ratios = [b["ratio"] for b in value["bands"]] - assert [b["min_deg"] for b in value["bands"]] == [15.0, 35.0, 40.0] - assert ratios[0] < 0.1 and 0.4 < ratios[1] < 0.6 and ratios[2] < 0.1 # 경계 줄만 + value = layers.slope_values_png(z, np.ones(z.shape, bool), 2.0) index = _indices(value) - assert value["width"] == value["height"] == 40 - assert (index[5, 5:35] == 2).all() and (index[-5, 5:35] == 0).all() # 위 = 북 = 37° - steep = layers.slope_bands_png(_plane(45.0, 20.0), np.ones((40, 40), bool), 2.0) - assert steep["bands"][2]["ratio"] > 0.4 and steep["bands"][0]["ratio"] > 0.4 + assert value["width"] == value["height"] == 40 and "bands" not in value + assert (index[5, 5:35] == 75).all() and (index[-5, 5:35] == 17).all() # 위 = 북 = 37° = 75 % + assert (index[0] == layers.SLOPE_EMPTY).all() # 격자 끝 = 셈 못 함 + assert sum(value["counts"]) == int((index != layers.SLOPE_EMPTY).sum()) == 38 * 38 + assert len(value["counts"]) == layers.SLOPE_MAX_PCT + 1 + cliff = layers.slope_values_png(_plane(80.0, 80.0), np.ones((40, 40), bool), 2.0) + assert cliff["counts"][layers.SLOPE_MAX_PCT] == 38 * 38 # 567 % → 250 % 이상 칸 hole = np.ones(z.shape, bool) hole[25:30, 10:20] = False - assert (_indices(layers.slope_bands_png(z, hole, 2.0))[-29:-24, 10:20] == 0).all() + holed = _indices(layers.slope_values_png(z, hole, 2.0)) + assert (holed[-29:-24, 10:20] == layers.SLOPE_EMPTY).all() def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: @@ -194,8 +196,8 @@ def test_slope_layer_fingerprint(tmp_path, monkeypatch) -> None: y = 450000.0 + np.arange(40) * 2.0 np.savez(npz, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[2.0]) counted = [] - real = layers.slope_bands_png - monkeypatch.setattr(layers, "slope_bands_png", lambda *a: counted.append(1) or real(*a)) + real = layers.slope_values_png + monkeypatch.setattr(layers, "slope_values_png", lambda *a: counted.append(1) or real(*a)) first = layers.slope_layer(tmp_path, models, EPSG) assert first["box_m"] == [199999.0, 449999.0, 200079.0, 450079.0] and len(first["box"]) == 4 assert layers.slope_layer(tmp_path, models, EPSG) == first and len(counted) == 1 diff --git a/resources/tester/test_581_1_plan_gis_layers.py b/resources/tester/test_581_1_plan_gis_layers.py index 6ac60270a..375896db7 100644 --- a/resources/tester/test_581_1_plan_gis_layers.py +++ b/resources/tester/test_581_1_plan_gis_layers.py @@ -200,7 +200,7 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None: shade = layers.slope_layer(tmp_path, models, EPSG, "hillshade") slope = layers.slope_layer(tmp_path, models, EPSG, "slope") assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == [] - assert slope["bands"] and shade["box_m"] == slope["box_m"] + assert slope["counts"] and shade["box_m"] == slope["box_m"] # 603 = 값 그림 + 칸 수 assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade diff --git a/resources/tester/test_603_plan_slope_bands.py b/resources/tester/test_603_plan_slope_bands.py new file mode 100644 index 000000000..f6d1e0935 --- /dev/null +++ b/resources/tester/test_603_plan_slope_bands.py @@ -0,0 +1,172 @@ +"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 + +`logic/D03_PlanAnalysis_SlopeBands.ts`. + +1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 · +범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_603_plan_slope_bands.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import numpy as np +import pytest + +from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers +from E07_StabArea.E07_StabArea_Engine import slope_rad + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" +CELL = 2.0 +OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS + + +def _hills(n: int = 300) -> np.ndarray: + """0 ~ 60° 남짓 고루 섞인 언덕(구멍 하나).""" + y, x = np.mgrid[0:n, 0:n] * CELL + return 40 * np.sin(x / 37) * np.cos(y / 53) + 25 * np.sin((x + 2 * y) / 19) + 0.3 * x + + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS }; +// 인자 null = 기본 범위 +for (const [key, [name, ...args]] of Object.entries(cases)) + out[key] = bands[name](...args.map((a) => (a === null ? bands.DEFAULT_SLOPE_BANDS : a))); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + +C = "--draw-map-landslide-3" + + +@pytest.fixture(scope="module") +def hills() -> dict: + z = _hills() + valid = np.ones(z.shape, bool) + valid[100:140, 100:180] = False + value = layers.slope_values_png(z, valid, CELL) + deg = np.degrees(slope_rad(np.where(valid, z, np.nan), CELL)) + known = np.isfinite(deg) + old = [float(((deg >= lo) & (deg < hi) & known).sum() / known.sum()) for lo, hi in OLD_DEG] + return {"counts": value["counts"], "known": int(known.sum()), "old": old} + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory, hills: dict) -> dict: + tmp = tmp_path_factory.mktemp("slope_bands") + out = tmp / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + band = lambda lo, hi: {"low": lo, "high": hi, "color": C} # noqa: E731 + top = [0] * 250 + [7] + cases = { + "default_ratios": ["bandRatios", hills["counts"], None], + "user_ratios": [ + "bandRatios", + hills["counts"], + [band(10, 15), band(15, 20), band(20, 250), band(0, 10)], + ], + "top": ["bandRatios", top, [band(83.9, 250)]], + "top_closed": ["bandRatios", top, [band(83.9, 249)]], + "ok": ["checkBands", [band(10, 15), band(15, 20), band(20, 250), band(0, 10)]], + "overlap": ["checkBands", [band(10, 20), band(15, 30)]], + "empty_value": ["checkBands", [band(None, 20)]], + "out_of_range": ["checkBands", [band(10, 260)]], + "upside_down": ["checkBands", [band(30, 20)]], + "none": ["checkBands", []], + "bad_color": ["checkBands", [{"low": 1, "high": 2, "color": "red"}]], + "parse_broken": ["parseBands", "{oops"], + "parse_overlap": ["parseBands", json.dumps([band(10, 20), band(15, 30)])], + "parse_ok": ["parseBands", json.dumps([band(10, 20)])], + "deg": ["pctToDeg", 100], + } + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(cases), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_칸_수_합은_유효_칸(hills): + assert sum(hills["counts"]) == hills["known"] + assert len(hills["counts"]) == layers.SLOPE_MAX_PCT + 1 + + +def test_기본은_지식DB_15_35_40도(r): + assert [(b["low"], b["high"]) for b in r["DEFAULT"]] == [ + (26.8, 70.0), + (70.0, 83.9), + (83.9, 250), + ] + + +def test_기본_비율은_옛_15_35_40도_비율과_같음(r, hills): + """1 % 칸 경계(26.8 → 27 · 83.9 → 84 %) 차이만 — 범례 0.1 % 자리 반 칸 안.""" + for new, old in zip(r["default_ratios"], hills["old"], strict=True): + assert old > 0.02 + assert new == pytest.approx(old, abs=0.002) + + +def test_사용자_범위_넷(r): + ratios = r["user_ratios"] + assert len(ratios) == 4 and sum(ratios) == pytest.approx(1.0) + + +def test_250_퍼센트_이상은_끝_범위(r): + assert r["top"] == [1.0] + assert r["top_closed"] == [0.0] + + +def test_범위_막음(r): + assert r["ok"] is None + assert "겹칩니다" in r["overlap"] + assert "비었습니다" in r["empty_value"] + assert "0~250" in r["out_of_range"] + assert "아래가 위보다" in r["upside_down"] + assert "하나는" in r["none"] + assert "색" in r["bad_color"] + + +def test_깨진_취향은_기본(r): + assert r["parse_broken"] == r["DEFAULT"] + assert r["parse_overlap"] == r["DEFAULT"] + assert r["parse_ok"] == [{"low": 10, "high": 20, "color": C}] + assert r["deg"] == pytest.approx(45.0) From da473b041be67ff3a2d5b5e2c8b283292b4cc0a8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:15:08 +0900 Subject: [PATCH 12/14] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EC=A4=8C=EC=95=84=EC=9B=83=20=ED=95=9C=EB=8F=84?= =?UTF-8?q?=20=EC=9D=B8=EC=9E=90=20=C2=B7=20=EB=8A=90=EB=A6=B0=20=EC=A1=B0?= =?UTF-8?q?=EC=9E=91=20=EC=8B=9C=EA=B0=84=20=ED=91=9C=EC=8B=9D=20(PLAN=206?= =?UTF-8?q?00-3=20=C2=B7=20600-4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-27)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 휠 부품 하한 인자 minScale(안 주면 0.5 = B05 그대로) · 예정노선 지도만 화면 폭 약 600 km(한반도)까지 뺌 - performance 표식 perfTime — 지도 다시 그림 · GL 카메라 · 바탕 · 위 그림 · 마우스 움직임(__aisloPerf 켰을 때만) Co-Authored-By: Claude Sonnet 5.5 --- B05_Profile/B05_Profile_UI_RouteEdit_Input.ts | 7 ++++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 29 ++++++++++++++----- ui_template/ui_template_gl_map.ts | 15 ++++++++-- 3 files changed, 41 insertions(+), 10 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts index 59e14f86a..be18299bc 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts @@ -47,6 +47,8 @@ export interface RouteEditNavigationParams { leftPan?: boolean; /** 왼쪽이 옮기기 버튼일 때 페이지가 그 누름을 잡았나(노드 · 마커) — 안 주면 포인터를 잡은 겹이 있으면 잡음 */ claimed?: () => boolean; + /** 배율 하한 — 안 주면 0.5(B05 그대로). 예정노선 지도만 한반도까지 빼려고 낮은 값을 줌(600-3). */ + minScale?: () => number; /** Ctrl+휠 = 돌리기(390-3) — 안 주면 Ctrl+휠도 확대(추천 지도). 손가락 · 트랙패드 벌림은 늘 확대. */ rotateWheel?: (sign: 1 | -1) => void; draw: () => void; @@ -81,7 +83,10 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void MAX_SCALE_FALLBACK, ); const factor = wheelZoomsIn(event) ? ZOOM_IN_FACTOR : ZOOM_OUT_FACTOR; - const scale = Math.min(maxScale, Math.max(MIN_SCALE, view.scale * factor)); + const scale = Math.min( + maxScale, + Math.max(params.minScale?.() ?? MIN_SCALE, view.scale * factor), + ); const ratio = scale / view.scale; const rect = canvas.getBoundingClientRect(); // 커서 자리를 **화면 중심 기준**으로 잡는다 — 그래야 그 지점이 제자리에 남는다. diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index fc69c07cd..54b93fc0a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -16,6 +16,7 @@ * 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림. * ========================================================================== */ +import { perfTime } from "@ui/ui_template_gl_map"; import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; import { routeColor, themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; @@ -60,6 +61,9 @@ export function planColors(): { }; } +/** 줌아웃 한도(600-3) — 화면 폭 약 600 km(한반도 · 위성 z6). 휠 하한 배율 = 이 폭이 화면에 드는 값(B05 는 0.5 그대로). */ +export const MAP_MAX_VIEW_WIDTH_M = 600_000; + /** 위성이 없을 때 맞출 점 둘레로 둘 가짜 도엽 여유(m). */ const FAKE_MARGIN_M = 600; @@ -406,9 +410,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { function flush(): void { cancelAnimationFrame(frame); frame = 0; - if (baseDirty) glCamera(); - if (baseDirty) paint(baseContext, true); - if (topDirty) paint(context, false); + perfTime("plan:flush", () => { + if (baseDirty) perfTime("plan:glCamera", glCamera); + if (baseDirty) perfTime("plan:paintBase", () => paint(baseContext, true)); + if (topDirty) perfTime("plan:paintTop", () => paint(context, false)); + }); baseDirty = topDirty = false; } /** 다음 화면에 한 번 — 바탕까지(화면 · 겹이 바뀜) 또는 위만(마우스). */ @@ -455,10 +461,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { }); canvas.addEventListener("pointermove", (event) => { if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 - const [px, py] = at(event); - const map = screenMap(); - for (const layer of layers) layer.hover?.(px, py, map); - drawTop(); + perfTime("plan:pointermove", () => { + const [px, py] = at(event); + const map = screenMap(); + for (const layer of layers) layer.hover?.(px, py, map); + drawTop(); + }); }); canvas.addEventListener("pointerleave", () => { for (const layer of layers) layer.leave?.(); @@ -502,6 +510,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { getMeta: () => meta, unrotateDelta: rotation.unrotateDelta, rotateWheel: rotation.wheel, + minScale: () => + meta && view.mapRect.width > 0 + ? Math.min( + 0.5, + (view.width * meta.width_meters) / (view.mapRect.width * MAP_MAX_VIEW_WIDTH_M), + ) + : 0.5, draw, }); diff --git a/ui_template/ui_template_gl_map.ts b/ui_template/ui_template_gl_map.ts index e61fe4e95..c3ec62d0b 100644 --- a/ui_template/ui_template_gl_map.ts +++ b/ui_template/ui_template_gl_map.ts @@ -65,6 +65,17 @@ export function satelliteTileSource(projectId: string): SourceSpecification { }; } +/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */ +export function perfTime(name: string, run: () => T): T { + if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run(); + const start = performance.now(); + try { + return run(); + } finally { + performance.measure(name, { start, end: performance.now() }); + } +} + export function createGlMap(opts: { /** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */ specs: () => GlSpec[]; @@ -190,8 +201,8 @@ export function createGlMap(opts: { camera(center, zoom, bearingDeg) { if (!map) return; if (ready && themeKey() !== theme) apply(); - map.jumpTo({ center, zoom, bearing: bearingDeg }); - if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) + perfTime("gl:jumpTo", () => map!.jumpTo({ center, zoom, bearing: bearingDeg })); + if (ready) perfTime("gl:redraw", () => map!.redraw()); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) }, resize: () => map?.resize(), }; From 4a1f230b423ad2a3fdf13a22123caf80fb40aafc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:20:25 +0900 Subject: [PATCH 13/14] =?UTF-8?q?chore(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B8=89=EA=B2=BD=EC=82=AC=20=EB=8B=A4=EC=8B=9C?= =?UTF-8?q?=20=EC=B9=A0=ED=95=98=EB=8A=94=20=EB=8F=99=EC=95=88=20=EC=B9=A0?= =?UTF-8?q?=ED=95=98=EA=B8=B0=20ms=20=3D=20-1=20=ED=91=9C=EC=8B=9C(?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B0=BD=EA=B5=AC=20=C2=B7=20PLAN=20603)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 8bc357afe..0ee401c75 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -229,6 +229,7 @@ export function createPlanLayers(opts: { const got = data.slope; if (!got) return; const turn = ++slopeTurn; + got.paintMs = -1; // 칠하는 중(검증 창구) const started = performance.now(); const painted = await paintSlope(got.values, got.bands); if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url); From 7c5303a79ceab7e31c773043dae48e8e95a402b1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 15:26:44 +0900 Subject: [PATCH 14/14] =?UTF-8?q?fix(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EC=9C=84=EC=B9=98=20=EC=B9=B8=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=EA=B3=A0=EC=B9=A8=20=E2=80=94=20=EB=B9=88=20?= =?UTF-8?q?=EC=95=8C=EB=A6=BC=20=C2=B7=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=88=A8=EA=B9=80=20=C2=B7=20=EB=A9=80=EB=8B=A4=20=EB=AC=BC?= =?UTF-8?q?=EC=9D=8C=20=EA=B8=80=20=EA=B2=B9=EC=B9=A8=20=C2=B7=20=EB=9D=A0?= =?UTF-8?q?=20=EB=84=98=EA=B9=80=20=EB=A7=9E=EC=B6=A4=20=EC=83=81=EC=9E=90?= =?UTF-8?q?=20=C2=B7=20=EC=9E=84=EB=8F=84=EB=A7=9D=20=EA=B1=B0=EC=A0=88=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20(PLAN=20596-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 찾기 칸 알림 · 후보 줄이 비면 숨김(칸 사이 빈 틈) - 멀다 물음 = 서버 글 그대로 · 띠 = 「그은 선 · 지점을 지웁니다」 덧 - 다른 TM 띠로 넘을 때 지도 맞춤 상자도 새 띠 좌표로 - 임도망 올리기 거절이 처리 안 된 거절로 새던 길 막음(알림은 그대로) Co-Authored-By: Claude Opus 5.5 --- .../D03_PlanAnalysis_UI_Layers.ts | 6 +++- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 7 ++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts | 30 +++++++++++++------ 3 files changed, 29 insertions(+), 14 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index fd78809cc..37c06a608 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -257,9 +257,13 @@ export function createPlanLayers(opts: { ); return info; }); + // 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음) pending.set( "forestRoadFile", - job.then(() => undefined), + job.then( + () => undefined, + () => undefined, + ), ); try { return await job; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 848270be6..a83b23978 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -388,9 +388,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const band = answer.status === "band"; const ok = await askConfirm({ title: band ? "다른 좌표 띠로 옮김" : "멀리 옮김", - message: band - ? `${answer.message} 그은 선 · 지점을 지우고 새 자리 둘레를 받습니다.` - : `${answer.message} 받은 지형을 버리고 새 자리 둘레를 받습니다.`, + message: band ? `${answer.message} 그은 선 · 지점을 지웁니다.` : answer.message, okLabel: "새로 받기", danger: band, }); @@ -419,7 +417,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { epsg = next; [network, points, pointNames] = [emptyNetwork(), [], []]; [undoStack, redoStack, have] = [[], [], null]; - map.update({ epsg }); + map.update({ epsg, fit: around(site) }); // 맞춤 상자도 새 띠 좌표로 + map.fit(); keepDraft(); } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts index 1670ccf37..b788950eb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts @@ -8,7 +8,7 @@ import { pickFiles } from "@ui/ui_template_drop"; import { createButton } from "@ui/ui_template_elements"; -import { createFindList, type FindList } from "@ui/ui_template_find_list"; +import { createFindList, type FindList, type FindRow } from "@ui/ui_template_find_list"; import { t } from "@ui/ui_template_locale"; import { createText } from "@ui/ui_template_text"; import { searchPlaces, type PlaceHit } from "./D03_PlanAnalysis_Api"; @@ -41,26 +41,38 @@ export interface PlanSite { * 등록(D01) 지도 머리 · 이 「위치」 칸이 같이 씀. */ export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { const find = createFindList({ placeholder: t("DE_Reg_Find") }); + const list = find.root.querySelector(".ui-find__list")!; let asked = 0; // 옛 찾기 답 버림 + // 빈 알림 · 빈 목록은 숨김(칸 사이 빈 틈 없앰) + const say = (text: string): void => { + find.hint.textContent = text; + find.hint.hidden = !text; + }; + const show = (rows: FindRow[]): void => { + find.setRows(rows); + list.hidden = !rows.length; + }; + say(""); + show([]); async function run(): Promise { const query = find.search.value.trim(); - find.setRows([]); - if (query.length < MIN_QUERY) return void (find.hint.textContent = t("DE_Reg_FindShort")); + show([]); + if (query.length < MIN_QUERY) return say(t("DE_Reg_FindShort")); const at = ++asked; - find.hint.textContent = t("DE_Reg_FindBusy"); + say(t("DE_Reg_FindBusy")); try { const hits = await searchPlaces(query); if (at !== asked) return; - find.hint.textContent = hits.length ? "" : t("DE_Reg_FindNone"); - find.setRows( + say(hits.length ? "" : t("DE_Reg_FindNone")); + show( hits.map((hit, i) => ({ key: String(i), tag: t(hit.kind === "place" ? "DE_Reg_FindPlace" : "DE_Reg_FindAddress"), name: hit.title, sub: hit.address !== hit.title ? hit.address : undefined, onPick: () => { - find.setRows([]); + show([]); find.search.value = hit.title; onPick(hit); }, @@ -68,11 +80,11 @@ export function createPlaceFind(onPick: (hit: PlaceHit) => void): FindList { ); } catch (error) { if (at === asked) - find.hint.textContent = `${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`; + say(`${t("DE_Reg_FindFail")} — ${error instanceof Error ? error.message : error}`); } } // 고를 줄이 있으면 부품이 Enter 를 먼저 씀(고름) · 없으면 찾기 · 글을 고치면 옛 줄 걷음 - find.search.addEventListener("input", () => find.setRows([])); + find.search.addEventListener("input", () => show([])); find.search.addEventListener("keydown", (event) => { if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; event.preventDefault();