From 2aaad7fe32e9e38f0cf87dad13afa69bc5c5eefc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:37:39 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=8B=A8=EA=B3=84=20=EC=9D=B4=EB=A6=84=20?= =?UTF-8?q?=E3=80=8C=EB=85=B8=EC=84=A0=20=EB=B6=84=EC=84=9D=E3=80=8D=20?= =?UTF-8?q?=E2=86=92=20=E3=80=8C=EC=98=88=EC=A0=95=EB=85=B8=EC=84=A0?= =?UTF-8?q?=E3=80=8D=20=E2=80=94=20=EC=82=AC=EC=A0=84=20=C2=B7=20=EB=8C=80?= =?UTF-8?q?=EC=8B=9C=EB=B3=B4=EB=93=9C=20=EB=8B=A8=EA=B3=84=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A8=EC=B6=95=ED=82=A4=20=ED=91=9C=20(PLAN=20596-1=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20463)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 폴더 · 주소 · 열쇠 그대로 · 시험 547_1 단언 새 이름 Co-Authored-By: Claude Opus 5.5 --- A00_Common/key_registry.ts | 2 +- B01_Dashboard/B01_Dashboard_Summary.py | 2 +- resources/tester/test_547_1_project_summary.py | 4 ++-- ui_template/ui_template_locale_de.ts | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/A00_Common/key_registry.ts b/A00_Common/key_registry.ts index e9e624aa5..a42902472 100644 --- a/A00_Common/key_registry.ts +++ b/A00_Common/key_registry.ts @@ -47,7 +47,7 @@ export const KEY_SCOPES: Record = { routeEdit: { label: ["B05 노선 편집(평면)", "B05 route edit (plan)"], page: "b05" }, drainage: { label: ["B05 배수 · 3D", "B05 drainage · 3D"], page: "b05" }, section: { label: ["B06 횡단", "B06 cross section"], page: "b05" }, - plan: { label: ["D03 노선분석", "D03 route analysis"], page: "plan" }, + plan: { label: ["D03 예정노선", "D03 planned route"], page: "plan" }, b04: { label: ["B04 지도 · 3D", "B04 map · 3D"], page: "b04" }, cad: { label: ["CAD", "CAD"], page: "cad" }, b08: { label: ["B08 수량 · 이정표", "B08 quantity · milestones"], page: "b08" }, diff --git a/B01_Dashboard/B01_Dashboard_Summary.py b/B01_Dashboard/B01_Dashboard_Summary.py index e2a0b2089..2306927f4 100644 --- a/B01_Dashboard/B01_Dashboard_Summary.py +++ b/B01_Dashboard/B01_Dashboard_Summary.py @@ -25,7 +25,7 @@ THUMB_POINTS = 100 # 단계 이름 = 화면 단계 막대 글(ui_template_locale_common · _de) 한국어 · 모르는 종류 = 임도 STAGE_NAMES: dict[str, tuple[str, ...]] = { "ROAD": ("파일입력", "전처리", "노선설계", "횡단설계", "상세설계", "수량산출", "원가계산"), - "PLAN": ("노선 분석", "확정 · 내보내기"), + "PLAN": ("예정노선", "확정 · 내보내기"), "STABILITY": ("지반 조건", "2D 해석", "3D 해석", "암반 해석", "광역 위험", "보고서"), } diff --git a/resources/tester/test_547_1_project_summary.py b/resources/tester/test_547_1_project_summary.py index 05a7b296d..28c8acf69 100644 --- a/resources/tester/test_547_1_project_summary.py +++ b/resources/tester/test_547_1_project_summary.py @@ -22,7 +22,7 @@ def test_thumb_line_fits_unit_box_and_caps_points(): def test_stage_names_follow_step_bar(): assert len(summary.stage_names("ROAD")) == 7 - assert summary.stage_names("PLAN")[0] == "노선 분석" # 591-2 기준 지정 걷음 + assert summary.stage_names("PLAN")[0] == "예정노선" # 591-2 기준 지정 걷음 assert summary.stage_names("EROSION") == summary.stage_names("ROAD") @@ -122,7 +122,7 @@ def test_summaries_in_batch(monkeypatch, tmp_path): assert a["stale_days"] == 20 and a["owner_names"] == ["엄", "박"] assert a["metrics"] == {"length_m": 15.0, "stations": 3, "structures": 2} assert len(a["thumb"]) == 3 and a["done"] is False - assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "노선 분석" + assert b["thumb"] is None and b["metrics"] == {} and b["stage_name"] == "예정노선" assert db["queries"] == 5 diff --git a/ui_template/ui_template_locale_de.ts b/ui_template/ui_template_locale_de.ts index b23b12a3c..d9f51ecfe 100644 --- a/ui_template/ui_template_locale_de.ts +++ b/ui_template/ui_template_locale_de.ts @@ -10,7 +10,7 @@ import type { LocaleEntry } from "./ui_template_locale_common"; export const ui_locales_de = { /* --- 단계 막대 · 빈 단계 --- */ - DE_Step_PlanAnalysis: ["노선 분석", "Route Analysis"], + DE_Step_PlanAnalysis: ["예정노선", "Planned Route"], DE_Step_PlanConfirm: ["확정 · 내보내기", "Confirm · Export"], DE_Step_StabGround: ["지반 조건", "Ground"], DE_Step_StabSection: ["2D 해석", "2D Analysis"], @@ -28,7 +28,7 @@ export const ui_locales_de = { D04_Col_Chainage: ["측점", "Station"], D04_Col_Confirmed: ["확정", "Confirmed"], D04_Col_Assignees: ["지정 업체", "Assigned firms"], - D04_NotSaved: ["노선 분석 [저장] 전", "Route not saved"], + D04_NotSaved: ["예정노선 [저장] 전", "Route not saved"], D04_Company: ["업체(등록 회사 찾기 또는 이름)", "Firm (search or name)"], D04_Email: ["메일", "Email"], D04_Add: ["추가", "Add"], @@ -90,8 +90,8 @@ export const ui_locales_de = { DE_Reg_KindPlan: ["노선 계획", "Route plan"], DE_Reg_Site: ["기준점", "Reference point"], DE_Reg_SiteHint: [ - "지도를 눌러 기준점 하나를 찍습니다 — 노선분석이 처음 자료를 받는 기준(노선과 무관) · 표지를 끌어 옮깁니다.", - "Click the map to set one reference point — where route analysis first loads data (not part of the route). Drag the marker to move it.", + "지도를 눌러 기준점 하나를 찍습니다 — 예정노선 화면이 처음 자료를 받는 기준(노선과 무관) · 표지를 끌어 옮깁니다.", + "Click the map to set one reference point — where the planned route page first loads data (not part of the route). Drag the marker to move it.", ], DE_Reg_SiteEmpty: [ "주소 — 지도를 눌러 기준점을 찍으세요.", From 26c5c0343dd53fa5da8e62740dafc182aab5d7f4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:37:54 +0900 Subject: [PATCH 2/7] =?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=EB=8F=84=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EA=B7=B8=EB=A6=BC=20=EC=83=89=20=C2=B7=20=EC=84=A0=EB=A7=88?= =?UTF-8?q?=EB=8B=A4=20=EC=B0=A8=EB=A1=80=20=EC=83=89=20=C2=B7=20=EA=B2=80?= =?UTF-8?q?=EC=9D=80=20=ED=85=8C=20(PLAN=20600-1=20=C2=B7=20=EC=9A=94?= =?UTF-8?q?=EA=B5=AC=20463-24)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 값 --draw-map-sketch · measure · warn · route-0~4 (테마 안 따름) · routeColor(index) 차례 색 함수 - planColors 가 지도 전용 색 + casing · 미리보기 · 거리재기 · 마커 · 핀 · 지점 검은 테 (strokeCased) - 시험 551_2 에 지도 전용 색 대비(어두운 바탕 · 검은 테 ≥ 3 : 1) 덧 Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_DrawPaint.ts | 51 ++++++++++++++----- D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 23 ++++++--- .../D03_PlanAnalysis_UI_Measure.ts | 31 +++++------ resources/tester/test_551_2_draw_tokens.py | 24 ++++++++- ui_template/ui_template_palette.ts | 13 +++++ ui_template/ui_template_theme.css | 8 +++ 6 files changed, 112 insertions(+), 38 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index a5ef6888a..0e81c63f5 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -33,6 +33,32 @@ export const LINE_HIT_PX = 8; /** 구간 길이 라벨을 보일 화면 길이(px). */ const LABEL_MIN_PX = 70; +/** 지도 위 그림 검은 테(600-1) — 지금 길을 검은 선 → 색 선 두 번 긋고 점선 무늬를 되돌림 · 길은 부르는 쪽이 먼저 만듦. */ +export function strokeCased( + context: CanvasRenderingContext2D, + color: string, + width: number, + dash: number[] = [], +): void { + context.setLineDash(dash); + context.lineWidth = width + 3; + context.strokeStyle = planColors().casing; + context.stroke(); + context.lineWidth = width; + context.strokeStyle = color; + context.stroke(); + context.setLineDash([]); +} + +/** 지도 위 점 — 색 채움 + 검은 테. */ +function fillCased(context: CanvasRenderingContext2D, color: string): void { + context.fillStyle = color; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = planColors().casing; + context.stroke(); +} + export const fmt = (value: number): string => value.toFixed(1); const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; /** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */ @@ -151,6 +177,9 @@ export function pointLayer(points: () => Vertex[]): PlanMapLayer { context.closePath(); context.fillStyle = pointColor(i); context.fill(); + context.lineWidth = 5; + context.strokeStyle = planColors().casing; + context.stroke(); context.lineWidth = 2; context.strokeStyle = "#ffffff"; context.stroke(); @@ -218,6 +247,9 @@ export function paintMark( context.closePath(); context.fillStyle = hollow ? "#ffffff" : color; context.fill(); + context.lineWidth = hollow ? 5.5 : 4.5; + context.strokeStyle = planColors().casing; + context.stroke(); context.lineWidth = hollow ? 2.5 : 1.5; context.strokeStyle = hollow ? color : "#ffffff"; context.stroke(); @@ -235,31 +267,24 @@ export function paintPreview( const [ax, ay] = map.toScreen(from); const [bx, by] = map.toScreen(preview.point); if (compass) { - context.strokeStyle = colors.active; - context.lineWidth = 1; - context.setLineDash([3, 4]); context.beginPath(); context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2); - context.stroke(); - context.fillStyle = colors.active; + strokeCased(context, colors.active, 1.5, [3, 4]); for (const c of compass.candidates) { const [cx, cy] = map.toScreen(c); context.beginPath(); - context.arc(cx, cy, 3, 0, Math.PI * 2); - context.fill(); + context.arc(cx, cy, 3.5, 0, Math.PI * 2); + fillCased(context, colors.active); } } - context.strokeStyle = preview.blocked ? colors.warn : colors.active; - context.lineWidth = 2; - context.setLineDash([6, 4]); + const color = preview.blocked ? colors.warn : colors.active; context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); - context.stroke(); - context.setLineDash([]); + strokeCased(context, color, 2.5, [6, 4]); context.beginPath(); context.arc(bx, by, 5, 0, Math.PI * 2); - context.stroke(); + strokeCased(context, color, 2.5); const value = `${fmt(preview.lengthM)} m · ` + (preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct)); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index de02713cf..fc69c07cd 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -17,7 +17,7 @@ * ========================================================================== */ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; -import { themeColor } from "@ui/ui_template_palette"; +import { routeColor, themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { computeMapRect, @@ -26,7 +26,6 @@ import { createNormalizer, lonLatToScreen, normalizedToScreen, - routeLineColor, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; @@ -41,12 +40,22 @@ export const YEAR_MARK_DRAG = "application/x-aislo-year-mark"; /** 측점 간격(m) — B 규칙 측점과 같음. */ export const PLAN_STATION_INTERVAL_M = 20; -/** 겹 그림 색(그림 전용 값 `--draw-*` · 밝음 · 어두움 같음) — 선 · 그리는 중 · 경고 · 글 테두리. */ -export function planColors(): { line: string; active: string; warn: string; halo: string } { +/** 지도 위 그림 색(600-1) — 지도 전용 값 `--draw-map-*`(테마 안 따름 · 바탕 늘 어두움) + 검은 테 `casing`. + * `line` = 첫 선 색 · 선마다 차례 색 = `routeColor(index)`. */ +export function planColors(): { + line: string; + active: string; + warn: string; + measure: string; + casing: string; + halo: string; +} { return { - line: routeLineColor(), - active: themeColor("--draw-primary", "#6d28d9"), - warn: themeColor("--draw-danger", "#dc2626"), + line: routeColor(0), + active: themeColor("--draw-map-sketch", "#ffe94d"), + warn: themeColor("--draw-map-warn", "#ff5a4d"), + measure: themeColor("--draw-map-measure", "#ffffff"), + casing: "rgba(0, 0, 0, 0.85)", halo: "rgba(0, 0, 0, 0.65)", }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts index fb4dbe80a..0102c9d31 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -7,6 +7,7 @@ import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; export interface PlanMeasure { layer: PlanMapLayer; @@ -40,25 +41,23 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb if (!from) return; const colors = planColors(); const [ax, ay] = map.toScreen(from); - context.lineWidth = 2; - context.strokeStyle = colors.active; - context.fillStyle = colors.active; - context.setLineDash([6, 4]); + const dot = (x: number, y: number): void => { + context.beginPath(); + context.arc(x, y, 4.5, 0, Math.PI * 2); + context.fillStyle = colors.measure; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = colors.casing; + context.stroke(); + }; if (to) { const [bx, by] = map.toScreen(to); context.beginPath(); context.moveTo(ax, ay); context.lineTo(bx, by); - context.stroke(); - context.setLineDash([]); - for (const [x, y] of [ - [ax, ay], - [bx, by], - ]) { - context.beginPath(); - context.arc(x, y, 4, 0, Math.PI * 2); - context.fill(); - } + 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 · ` + @@ -75,9 +74,7 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb context.fillText(text, mx, my); }); } else { - context.beginPath(); - context.arc(ax, ay, 4, 0, Math.PI * 2); - context.fill(); + dot(ax, ay); } }, press(px, py, map, button) { diff --git a/resources/tester/test_551_2_draw_tokens.py b/resources/tester/test_551_2_draw_tokens.py index 321e7467d..04accf07d 100644 --- a/resources/tester/test_551_2_draw_tokens.py +++ b/resources/tester/test_551_2_draw_tokens.py @@ -34,7 +34,7 @@ DRAW_READS = [ ("B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts", '"--draw-text-secondary"'), ("B05_Profile/B05_Profile_UI_Viewer.ts", '"--draw-surface-raised"'), ("B04_PreProcess/B04_PreProcess_UI_Camera.ts", '"--draw-surface-raised"'), - ("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-primary"'), + ("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts", '"--draw-map-sketch"'), ("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-warning"'), ("B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts", '"--draw-canvas"'), ("B07_DesignDetail/openwebcad/src/App.css", "--cad-canvas: var(--draw-canvas);"), @@ -161,3 +161,25 @@ def test_changing_screen_tokens_keeps_drawing_colors(): a = resolve(value, before[mode]) assert "var(" not in a, (mode, rel, value) assert a == resolve(value, after[mode]), (mode, rel, value) + + +def _lum(hex_color: str) -> float: + rgb = [int(hex_color[i : i + 2], 16) / 255 for i in (1, 3, 5)] + lin = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in rgb] + return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2] + + +def test_map_drawing_colors_fixed_and_readable_on_dark_map(): + """600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1.""" + blocks = _blocks(THEME.read_text(encoding="utf-8")) + names = [ + k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k) + ] + assert len(names) == 8, names + ground = _lum("#1f2937") + for name in names: + for mode in ("dark", "auto"): + assert name not in blocks[mode], (mode, name) + lum = _lum(blocks["light"][name]) + assert (lum + 0.05) / (ground + 0.05) >= 3, name + assert (lum + 0.05) / 0.05 >= 3, name diff --git a/ui_template/ui_template_palette.ts b/ui_template/ui_template_palette.ts index 93420d4dd..034218b25 100644 --- a/ui_template/ui_template_palette.ts +++ b/ui_template/ui_template_palette.ts @@ -50,3 +50,16 @@ export function pointColor(index: number): string { const [name, fallback] = POINT_COLORS[index % POINT_COLORS.length]; return themeColor(name, fallback); } + +/** 600-1 예정노선 선마다 차례 색 — 다섯 차례 돌림 · 지도 전용 값(테마 안 따름 · 겹 색과 가름) · 지도 선 · 구역 목록 색 띠가 같은 번호 같은 색 */ +const ROUTE_COLORS = [ + ["--draw-map-route-0", "#3df5c4"], + ["--draw-map-route-1", "#5aa9ff"], + ["--draw-map-route-2", "#c58bff"], + ["--draw-map-route-3", "#ff6ea8"], + ["--draw-map-route-4", "#9dff4d"], +] as const; +export function routeColor(index: number): string { + const [name, fallback] = ROUTE_COLORS[((index % 5) + 5) % 5]; + return themeColor(name, fallback); +} diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index ebc6038fa..f5a61f805 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -585,6 +585,14 @@ html.is-bm-route .m01lab-src__body * { --draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */ --draw-map-forest-road-file: #ea580c; /* 올린 임도망도(실선 · 짐작 = 주황 점선) */ --draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */ + --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ + --draw-map-measure: #ffffff; /* 거리재기 */ + --draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */ + --draw-map-route-0: #3df5c4; /* 예정노선 선마다 차례 색(개발제한 · 산사태 주황과 가름) */ + --draw-map-route-1: #5aa9ff; + --draw-map-route-2: #c58bff; + --draw-map-route-3: #ff6ea8; + --draw-map-route-4: #9dff4d; } :root[data-theme="dark"] { From b2138e213d4ca71bde02668094122a28420f546a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:39:41 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=EC=B9=B8=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D=20=EC=B9=B8=20=ED=95=A8=EC=88=98=20=C2=B7=20?= =?UTF-8?q?=EB=A7=88=EB=94=94=20=EA=B3=A0=EB=A6=84=202=20=C3=97=202=20?= =?UTF-8?q?=C2=B7=20=EA=B8=B8=EC=9D=B4=20=C2=B7=20=EA=B8=B0=EC=9A=B8?= =?UTF-8?q?=EA=B8=B0=20=EB=91=90=20=EC=97=B4=20=C2=B7=20=EC=97=B0=EB=8F=84?= =?UTF-8?q?=20=EA=B5=AC=EC=97=AD=20=EB=A7=88=EC=BB=A4=20=EA=B7=B8=EB=A6=AC?= =?UTF-8?q?=EA=B8=B0=20=EC=B9=B8=EC=9C=BC=EB=A1=9C=20(PLAN=20599-1=20?= =?UTF-8?q?=C2=B7=20599-4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-10=20=C2=B7?= =?UTF-8?q?=2022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_DrawPanel.ts — createDrawKindFields(노드 판 598-4 가 다시 씀) · 그리기 칸 몸 - 「둘 다」 → 「길이·기울기 고정」 · 칸 설명 글 걷음(요구 463-6) - 부품 마디 고름에 columns 덧(여러 줄 꼴) — 한 줄 꼴 그대로 - 지도 도구줄 연도 마커 단추 · 공용 표 줄 걷음 → 그리기 칸 끌기 단추 - 시험 553_4 · 568_1 고침(마커 자리 옮김 · 방식 칸 새 파일) Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 126 +++--------- .../D03_PlanAnalysis_UI_DrawPanel.ts | 190 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 14 +- .../D03_PlanAnalysis_UI_Style.css | 18 ++ resources/tester/test_553_4_de_parts.py | 12 +- resources/tester/test_568_1_map_tools.py | 1 - ui_template/ui_template_map_tools.ts | 1 - ui_template/ui_template_segment.css | 13 ++ ui_template/ui_template_segment.ts | 7 + 9 files changed, 266 insertions(+), 116 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 25439f9da..c6bb83bee 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -11,18 +11,15 @@ * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · - * 여러 해 = 다 그은 뒤 도구줄 「연도 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. + * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. * 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`. * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). * ========================================================================== */ -import { createButton, showToast } from "@ui/ui_template_elements"; +import { showToast } from "@ui/ui_template_elements"; import { keyIs } from "../A00_Common/key_registry"; -import { createInputField } from "@ui/ui_template_input"; -import { createSegment } from "@ui/ui_template_segment"; -import { createText } from "@ui/ui_template_text"; import { compassCandidates, compassPoint, @@ -50,17 +47,19 @@ import { paintNetwork, paintPreview, screenGap, - slopeDeg, SNAP_PX, } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { + createDrawKindFields, + createDrawPanel, + DRAW_DEFAULT_LENGTH_M, + DRAW_DEFAULT_SLOPE_PCT, + type DrawConstraint, +} from "./D03_PlanAnalysis_UI_DrawPanel"; import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ -export const DRAW_DEFAULT_LENGTH_M = 20; -export const DRAW_DEFAULT_SLOPE_PCT = 9; const START_HINT = "지점 · 선 끝 · 선 위를 눌러 시작합니다 — 빈 땅은 우클릭 「여기서 긋기 시작」."; -export type DrawConstraint = "free" | "length" | "slope" | "both"; export type DrawMode = "idle" | "draw"; export interface DrawPreview { @@ -87,7 +86,7 @@ export interface PlanDrawParams { export interface PlanDraw { layer: PlanMapLayer; - /** 좌측 「그리기」 칸 몸(모드 단추 · 제약 칸 · 안내). */ + /** 좌측 「그리기」 칸 몸(`D03_PlanAnalysis_UI_DrawPanel` — 긋기 · 연도 구역 마커 · 방식 칸). */ panel: HTMLElement; mode: () => DrawMode; setMode: (mode: DrawMode) => void; @@ -119,94 +118,23 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let slopePct = DRAW_DEFAULT_SLOPE_PCT; // ── 좌측 칸 ───────────────────────────────────────────── - const drawButton = createButton({ - label: "긋기", - variant: "filled", - size: 28, - onClick: () => setMode(mode === "draw" ? "idle" : "draw"), + const kindFields = createDrawKindFields({ + value: { kind, lengthM, slopePct }, + onChange: (value) => ({ kind, lengthM, slopePct } = value), }); - const modeRow = document.createElement("div"); - modeRow.className = "d03-plan__row"; - modeRow.append(drawButton); - const kindSegment = createSegment({ - options: [ - { value: "free", label: "자유" }, - { value: "length", label: "길이 고정" }, - { value: "slope", label: "기울기 고정" }, - { value: "both", label: "둘 다" }, - ], - value: kind, - size: 24, - ariaLabel: "긋기 제약", - onChange: (value) => { - kind = value as DrawConstraint; - fieldsState(); - }, + const drawPanel = createDrawPanel({ + kind: kindFields, + onDraw: () => setMode(mode === "draw" ? "idle" : "draw"), }); - kindSegment.root.dataset.role = "draw-constraint"; - const lengthField = createInputField({ - label: "길이", - type: "number", - value: String(lengthM), - min: 1, - unit: "m", - size: 24, - onInput: (value) => { - const next = Number(value); - if (next > 0) lengthM = next; - lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); - }, - }); - lengthField.input.dataset.role = "draw-length"; - const slopeField = createInputField({ - label: "기울기(± 같음)", - type: "number", - value: String(slopePct), - unit: "%", - size: 24, - onInput: (value) => { - const next = Number(value); - const ok = value.trim() !== "" && Number.isFinite(next); - if (ok) slopePct = next; - slopeField.setError(ok ? undefined : "숫자"); - degText(); - }, - }); - slopeField.input.dataset.role = "draw-slope"; - const slopeDegText = createText("", "muted"); - slopeDegText.dataset.role = "draw-slope-deg"; - const degText = (): void => { - slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`; - }; - degText(); - const fieldRow = document.createElement("div"); - fieldRow.className = "d03-plan__row"; - fieldRow.append(lengthField.root, slopeField.root, slopeDegText); - const hint = createText("", "muted"); - hint.classList.add("d03-plan__hint"); - const panel = document.createElement("div"); - panel.className = "d03-plan__draw"; - panel.append(modeRow, kindSegment.root, fieldRow, hint); - - function fieldsState(): void { - lengthField.input.disabled = kind === "free" || kind === "slope"; - slopeField.input.disabled = kind === "free" || kind === "length"; - } + const panel = drawPanel.root; function panelState(): void { - drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기"; - drawButton.disabled = !editable; - drawButton.setAttribute("aria-pressed", String(mode === "draw")); - panel.dataset.mode = mode; - hint.textContent = !editable - ? "보기만 — 산림청 계정에서 긋습니다." - : mode === "draw" - ? active === null - ? START_HINT - : "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" - : "[긋기]로 지점 · 선에서 긋습니다 · 지도 우클릭 = 긋기 시작 · 지점 놓기 · 선 누름 = 고름 · 「연도 마커」를 선 위로 끌면 구역을 나눕니다."; + drawPanel.state({ + drawing: mode === "draw", + editable, + canMark: params.network().lines.some((line) => line.vertices.length > 1), + }); } - fieldsState(); // ── 셈 ────────────────────────────────────────────────── const lastOf = (line: number): Vertex | null => { @@ -524,13 +452,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { constraint: () => ({ kind, lengthM, slopePct }), setConstraint(next, length, slope) { kind = next; - kindSegment.set(next); - if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)]; - if (slope !== undefined) { - [slopePct, slopeField.input.value] = [slope, String(slope)]; - degText(); - } - fieldsState(); + if (length !== undefined) lengthM = length; + if (slope !== undefined) slopePct = slope; + kindFields.set({ kind, lengthM, slopePct }); }, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts new file mode 100644 index 000000000..18b8214d7 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts @@ -0,0 +1,190 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_DrawPanel.ts + * D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸. + * + * 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) + + * 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀. + * 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop). + * 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만. + * ========================================================================== */ + +import type { UiSize } from "@ui/ui_template_button"; +import { createButton } from "@ui/ui_template_elements"; +import { createInputField } from "@ui/ui_template_input"; +import { createSegment } from "@ui/ui_template_segment"; +import { createText } from "@ui/ui_template_text"; +import { fmt, slopeDeg } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { YEAR_MARK_DRAG } from "./D03_PlanAnalysis_UI_Map"; + +/** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ +export const DRAW_DEFAULT_LENGTH_M = 20; +export const DRAW_DEFAULT_SLOPE_PCT = 9; + +export type DrawConstraint = "free" | "length" | "slope" | "both"; + +export interface DrawKindValue { + kind: DrawConstraint; + lengthM: number; + slopePct: number; +} + +export const DRAW_KIND_LABELS: Record = { + free: "자유", + length: "길이 고정", + slope: "기울기 고정", + both: "길이·기울기 고정", +}; + +export interface DrawKindFields { + root: HTMLElement; + get: () => DrawKindValue; + /** 값을 바꿈(onChange 는 안 부름). */ + set: (value: Partial) => void; + setDisabled: (disabled: boolean) => void; +} + +/** 긋기 방식 칸 — 고름 · 길이 · 기울기가 바뀔 때마다 `onChange(지금 값)`(빈 · 틀린 수는 칸 오류만). */ +export function createDrawKindFields(opts: { + value: DrawKindValue; + onChange: (value: DrawKindValue) => void; + size?: UiSize; +}): DrawKindFields { + const size = opts.size ?? 24; + let value = { ...opts.value }; + let disabled = false; + const changed = (): void => opts.onChange({ ...value }); + const segment = createSegment({ + options: (Object.keys(DRAW_KIND_LABELS) as DrawConstraint[]).map((kind) => ({ + value: kind, + label: DRAW_KIND_LABELS[kind], + })), + value: value.kind, + size, + columns: 2, + ariaLabel: "긋기 방식", + onChange: (next) => { + value.kind = next as DrawConstraint; + fieldsState(); + changed(); + }, + }); + segment.root.dataset.role = "draw-constraint"; + const lengthField = createInputField({ + label: "길이", + type: "number", + value: String(value.lengthM), + min: 1, + unit: "m", + size, + onInput: (text) => { + const next = Number(text); + lengthField.setError(next > 0 ? undefined : "0 보다 큰 수"); + if (!(next > 0)) return; + value.lengthM = next; + changed(); + }, + }); + lengthField.input.dataset.role = "draw-length"; + const slopeField = createInputField({ + label: "기울기", + type: "number", + value: String(value.slopePct), + unit: "%", + size, + onInput: (text) => { + const next = Number(text); + const ok = text.trim() !== "" && Number.isFinite(next); + slopeField.setError(ok ? undefined : "숫자"); + if (!ok) return; + value.slopePct = next; + degText(); + changed(); + }, + }); + slopeField.input.dataset.role = "draw-slope"; + const slopeDegText = createText("", "muted"); + slopeDegText.dataset.role = "draw-slope-deg"; + const degText = (): void => { + slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(value.slopePct)))}°`; + }; + const slopeColumn = document.createElement("div"); + slopeColumn.append(slopeField.root, slopeDegText); + const fields = document.createElement("div"); + fields.className = "d03-plan__draw-fields"; + fields.append(lengthField.root, slopeColumn); + const root = document.createElement("div"); + root.className = "d03-plan__draw-kind"; + root.append(segment.root, fields); + + function fieldsState(): void { + for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled; + lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope"; + slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length"; + } + degText(); + fieldsState(); + + return { + root, + get: () => ({ ...value }), + set(next) { + value = { ...value, ...next }; + segment.set(value.kind); + lengthField.input.value = String(value.lengthM); + slopeField.input.value = String(value.slopePct); + lengthField.setError(undefined); + slopeField.setError(undefined); + degText(); + fieldsState(); + }, + setDisabled(next) { + disabled = next; + fieldsState(); + }, + }; +} + +export interface DrawPanel { + root: HTMLElement; + /** 단추 글 · 막힘 — 긋는 중 · 고칠 수 있음 · 마커 놓을 선 있음. */ + state: (state: { drawing: boolean; editable: boolean; canMark: boolean }) => void; +} + +/** 좌측 「그리기」 칸 — [긋기] · 연도 구역 마커(끌기) · 방식 칸. */ +export function createDrawPanel(opts: { kind: DrawKindFields; onDraw: () => void }): DrawPanel { + const drawButton = createButton({ + label: "긋기", + variant: "filled", + size: 28, + onClick: opts.onDraw, + }); + const markButton = createButton({ + label: "연도 구역 마커", + variant: "ghost", + size: 28, + iconStart: "flag", + }); + markButton.title = "선 위로 끌어 놓으면 구역을 나눕니다"; + markButton.dataset.role = "draw-year-mark"; + markButton.draggable = true; + markButton.addEventListener("dragstart", (event) => { + event.dataTransfer?.setData(YEAR_MARK_DRAG, "1"); + if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"; + }); + const row = document.createElement("div"); + row.className = "d03-plan__row"; + row.append(drawButton, markButton); + const root = document.createElement("div"); + root.className = "d03-plan__draw"; + root.append(row, opts.kind.root); + return { + root, + state({ drawing, editable, canMark }) { + drawButton.querySelector(".ui-btn__label")!.textContent = drawing ? "긋기 끝" : "긋기"; + drawButton.disabled = !editable; + drawButton.setAttribute("aria-pressed", String(drawing)); + markButton.disabled = !editable || drawing || !canMark; + root.dataset.mode = drawing ? "draw" : "idle"; + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index de02713cf..e4ef9740a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -36,7 +36,7 @@ import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm"; -/** 도구줄 「연도 마커」 끌어 놓기 자료 꼴(585-3). */ +/** 「연도 구역 마커」 끌어 놓기 자료 꼴(585-3 · 단추 = 그리기 칸 599-4). */ export const YEAR_MARK_DRAG = "application/x-aislo-year-mark"; /** 측점 간격(m) — B 규칙 측점과 같음. */ @@ -115,7 +115,7 @@ export interface PlanTools { view3d: boolean; /** 거리재기 켜짐(눌림). */ measure: boolean; - /** 「연도 마커」 끌 수 있음(고칠 수 있고 선 있음). */ + /** 받기만 — 연도 구역 마커 단추는 그리기 칸(599-4 · `D03_PlanAnalysis_UI_DrawPanel`)이 켬끔. */ yearMark: boolean; } @@ -188,12 +188,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const redoButton = mapToolButton("redo", { onClick: events.onRedo }); const viewButton = mapToolButton("view3d", { onClick: events.on3d }); const measureButton = mapToolButton("measure", { onClick: events.onMeasure }); - const yearMarkButton = mapToolButton("year-mark", { disabled: true }); - yearMarkButton.draggable = true; - yearMarkButton.addEventListener("dragstart", (event) => { - event.dataTransfer?.setData(YEAR_MARK_DRAG, "1"); - if (event.dataTransfer) event.dataTransfer.effectAllowed = "copy"; - }); const tools = createMapTools({ label: "평면 도구", hint: { @@ -207,7 +201,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Shift+누름 = 구간 더함 · 뺌", "빈 곳 오른쪽으로 끌기 = 창 안 구간 · 왼쪽으로 = 걸친 구간까지", "고른 선 위 끌기 = 꺾임점 더해 옮김", - "「연도 마커」 선 위로 끌어 놓기 = 구역 나눔", + "그리기 칸 「연도 구역 마커」 선 위로 끌어 놓기 = 구역 나눔", "꺾임점 · 마크 끌기 = 옮김 · 마크를 선 밖으로 = 지움(앞 구간과 합침)", "오른쪽 누름 = 꺾임점 · 마크 지움", "Delete = 고른 선 · 마크 지움", @@ -220,7 +214,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { [undoButton, redoButton], [viewButton, "rotate-ccw", "rotate-cw"], [markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton], - [yearMarkButton], ], }); const toolbar = tools.root; @@ -517,7 +510,6 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (tools.view3d !== undefined) viewButton.disabled = !tools.view3d; if (tools.measure !== undefined) measureButton.setAttribute("aria-pressed", String(tools.measure)); - if (tools.yearMark !== undefined) yearMarkButton.disabled = !tools.yearMark; }, redraw: drawTop, redrawBase: draw, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 14f869430..ed11d04f3 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; } + +/* 599 그리기 칸 — 방식 칸(마디 고름 2 × 2 · 길이 · 기울기 두 열) */ +.d03-plan__draw-kind { + display: flex; + flex-direction: column; + gap: 6px; +} + +.d03-plan__draw-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-8); + align-items: start; +} + +.d03-plan__draw-fields .ui-field { + margin: 0; +} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index b6ff5235e..749bcbca4 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -30,11 +30,12 @@ def test_d03_parts(): three = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_3D.ts") css = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css") draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts") + draw_panel = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPanel.ts") tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert 'createElement("progress")' not in ts and "createProgressBar()" in ts # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 - for src in (ts, draw, tools, layers): + for src in (ts, draw, draw_panel, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): assert raw not in src, raw # 568-3 — 도구줄 = 공용 지도 도구줄 · 아홉([?] · 되돌리기 · 다시하기 · 3D · 돌리기 둘 · 측점 · 기울기 · 재기) @@ -76,7 +77,14 @@ def test_d03_parts(): ("place", "false"), ], keys assert ".d03-plan__layers" in css and ".d03-plan__legend" in css - assert "createSegment({" in draw and draw.count("createInputField(") == 2 + # 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수 + assert "createSegment({" in draw_panel and draw_panel.count("createInputField(") == 2 + assert "columns: 2" in draw_panel and "export function createDrawKindFields(" in draw_panel + assert ".d03-plan__draw-fields" in css and "createDrawKindFields(" in draw + assert '"길이·기울기 고정"' in draw_panel and '"둘 다"' not in draw_panel + assert "d03-plan__hint" not in draw # 칸 설명 글 걷음(요구 463-6) + # 599-4 — 연도 구역 마커 = 그리기 칸 단추(끌어 놓기) · 지도 도구줄에서 뺌 + assert "YEAR_MARK_DRAG" in draw_panel and '"year-mark"' not in tools 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 diff --git a/resources/tester/test_568_1_map_tools.py b/resources/tester/test_568_1_map_tools.py index c14681ef0..9cfd9653f 100644 --- a/resources/tester/test_568_1_map_tools.py +++ b/resources/tester/test_568_1_map_tools.py @@ -55,7 +55,6 @@ def test_단추_목록_아이콘_차례(): "footprint", "measure", "station-snap", - "year-mark", ] names = _icon_names() assert all(icon in names for _, icon, _ in rows) diff --git a/ui_template/ui_template_map_tools.ts b/ui_template/ui_template_map_tools.ts index afacf9875..47c7e28cd 100644 --- a/ui_template/ui_template_map_tools.ts +++ b/ui_template/ui_template_map_tools.ts @@ -31,7 +31,6 @@ export const MAP_TOOLS = { footprint: { icon: "road", title: "절/성토선", toggle: true }, measure: { icon: "ruler", title: "거리 재기", toggle: true }, "station-snap": { icon: "magnet", title: "측점 스냅", toggle: true }, - "year-mark": { icon: "flag", title: "연도 구역 마커 — 선 위로 끌어 놓기" }, } satisfies Record; export type MapToolAct = keyof typeof MAP_TOOLS; diff --git a/ui_template/ui_template_segment.css b/ui_template/ui_template_segment.css index 984b571b9..0c721748d 100644 --- a/ui_template/ui_template_segment.css +++ b/ui_template/ui_template_segment.css @@ -48,6 +48,19 @@ cursor: not-allowed; } +/* 여러 줄(columns) — 칸 사이 1px 틈에 틀 색 바탕이 보여 줄 · 칸 나눔선 */ +.ui-segment[data-columns] { + display: grid; + grid-template-columns: repeat(var(--ui-segment-columns), 1fr); + gap: 1px; + height: auto; + background: var(--color-border); +} +.ui-segment[data-columns] > .ui-segment__item { + height: var(--ui-size-h); + border-left: 0; +} + /* 숨김 — display 가 [hidden] 을 덮지 않게(공용 기본 꼴이 안 실린 곳 · 페이지 꾸밈 위에서도) */ :is(.ui-segment, .ui-segment__item)[hidden] { display: none !important; diff --git a/ui_template/ui_template_segment.ts b/ui_template/ui_template_segment.ts index 81a591655..1ba81b868 100644 --- a/ui_template/ui_template_segment.ts +++ b/ui_template/ui_template_segment.ts @@ -4,6 +4,7 @@ * * 꼴 = 글 · 아이콘(+ title) · 글 + 아이콘 * 크기 = data-size 20 · 24 · 28 · 32(기본 28) + * 줄 = 한 줄(기본) · `columns: 2` = 2 × 2 처럼 여러 줄 칸(긴 이름 넷 · 599-1 D03 긋기 방식) * 상태 = 마우스 `:hover` · 눌림 `:active` · 막힘 `disabled` · 고름 = aria-checked — CSS 는 `[data-state]` 로도 받음 * 쓰기 = `createSegment({ options: [{ value: "list", label: "목록" }, { value: "card", label: "카드" }], value: "list", onChange })` * 대신 = B01 `.b01-sys-seg` · `.b01-home__seg` · `.b01-proj-seg` · B04 `b04-surface__sheet-method` · B05 `b05-routerec__pills` · @@ -37,12 +38,18 @@ export function createSegment(opts: { /** 20 · 24 · 28 · 32 권함 */ size?: UiSize; ariaLabel?: string; + /** 한 줄 칸 수 — 넘으면 다음 줄(없음 = 한 줄) */ + columns?: number; }): SegmentHandle { const root = document.createElement("div"); root.className = "ui-segment"; root.setAttribute("role", "radiogroup"); if (opts.ariaLabel) root.setAttribute("aria-label", opts.ariaLabel); setUiSize(root, opts.size ?? 28); + if (opts.columns) { + root.dataset.columns = String(opts.columns); + root.style.setProperty("--ui-segment-columns", String(opts.columns)); + } let current = opts.value; const items = opts.options.map((o) => { const b = document.createElement("button"); From 7ae441be67d89dda5c2ceb92ca1e0768ad1a0cbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:40:29 +0900 Subject: [PATCH 4/7] =?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 3b34301b56660b28414d376fba96827c2a2551db Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:44:25 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=8B=A4=EC=8B=9C=20=EA=B8=8B=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EA=B3=A0=EB=A5=B8=20=EA=B5=AC=EA=B0=84=20=EC=95=9E?= =?UTF-8?q?=20=EB=85=B8=EB=93=9C=EB=B6=80=ED=84=B0=20=EA=B8=8B=EA=B3=A0=20?= =?UTF-8?q?=EC=9B=90=EB=9E=98=20=EB=92=A4=20=EB=85=B8=EB=93=9C=EC=97=90=20?= =?UTF-8?q?=EB=B6=99=EC=9C=BC=EB=A9=B4=20=EB=81=9D=20=C2=B7=20=EB=8B=A4?= =?UTF-8?q?=EB=A5=B8=20=EA=B5=AC=EC=97=AD=EC=97=90=20=EB=8B=BF=EC=95=84=20?= =?UTF-8?q?=EB=81=9D=EB=82=9C=20=EB=B3=B8=EC=84=A0=20=3D=20=EC=A0=80?= =?UTF-8?q?=EC=A0=88=EB=A1=9C=20=EA=B0=88=EB=9E=98=20(PLAN=20599-3=20?= =?UTF-8?q?=C2=B7=20=EC=9A=94=EA=B5=AC=20463-12=20=C2=B7=2015)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 logic/D03_PlanAnalysis_Span.ts — replaceSpan · respanBlocked(속 점 갈래 막음 · 뒤 갈래 차례 밈 · 마크 길이 비율 · 조각 id 그대로) - draw.respan(lineId, from, to) — 598 판 [다시 긋기]가 부름 · 속 구간 흐리게(paintNetwork dim) · Enter = 취소 · Backspace = 점 빼기 - 긋기 끝 꼬리가 다른 구역 꺾임점 = 뒤집어 그 점의 갈래(No.0 = 닿은 점) - 새 시험 599_3 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 121 +++++++++++++++--- .../D03_PlanAnalysis_UI_DrawPaint.ts | 26 ++-- .../logic/D03_PlanAnalysis_Span.ts | 73 +++++++++++ resources/tester/test_599_3_plan_span.py | 101 +++++++++++++++ 4 files changed, 292 insertions(+), 29 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts create mode 100644 resources/tester/test_599_3_plan_span.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index c6bb83bee..66db04074 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -3,12 +3,15 @@ * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음. * * 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기). - * [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) · + * [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점) · * 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 * 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 — - * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래. + * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에 + * 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10). + * 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 · + * Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음. * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: - * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 + * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. @@ -41,6 +44,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; import { fmt, lineAt, @@ -103,6 +107,8 @@ export interface PlanDraw { stroke: () => { line: number; flip: boolean } | null; constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; + /** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */ + respan: (lineId: string, fromVertex: number, toVertex: number) => boolean; } export function createPlanDraw(params: PlanDrawParams): PlanDraw { @@ -116,6 +122,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let kind: DrawConstraint = "free"; let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; + // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 + let span: { line: string; from: number; to: number; through: Vertex[] } | null = null; + const spanLine = (): number => + span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1; // ── 좌측 칸 ───────────────────────────────────────────── const kindFields = createDrawKindFields({ @@ -171,13 +181,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { /** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */ function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { - if (active === null) return null; - const from = lastOf(active); + const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined; + if (active === null && !spanVertices) return null; + const from = spanVertices + ? (span!.through[span!.through.length - 1] ?? spanVertices[span!.from]) + : lastOf(active!); if (!from) return null; const z = params.heightAt(); const cursor = map.toMetric(px, py); candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; - const snap = snapAt(px, py, map); + // 다시 긋기 = 원래 뒤 노드에만 붙음 + const end = spanVertices?.[span!.to]; + const snap = end + ? screenGap(map, end, px, py) <= SNAP_PX + ? { point: end, note: "뒤 노드에 붙음", onLine: null } + : null + : snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; if (snap) { @@ -227,6 +246,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { samePoint(other.vertices[0], tail), ); if (next >= 0) network = attachHead(network, next); + else if (!entry.branch) { + // 꼬리가 다른 구역 꺾임점에 닿음 = 그 점의 갈래(No.0 = 닿은 점) + const turned = reverseLine(network, line); + const joined = typeof turned === "string" ? turned : attachHead(turned, line); + if (typeof joined !== "string" && joined !== turned) network = joined; + } } if (network !== params.network()) params.commit(network); } @@ -235,6 +260,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { strokeStart = null; preview = null; candidates = []; + span = null; + } + + function respan(lineId: string, from: number, to: number): boolean { + if (!editable) return false; + const index = params.network().lines.findIndex((line) => line.id === lineId); + const blocked = respanBlocked(params.network(), index, from, to); + if (blocked) { + showToast(blocked, "info"); + return false; + } + setMode("draw"); + span = { line: lineId, from, to, through: [] }; + panelState(); + params.onMode(); + return true; } function setMode(next: DrawMode): void { @@ -335,12 +376,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { } function addPoint(): boolean { - if (active === null || !preview) return false; + if ((active === null && !span) || !preview) return false; if (preview.blocked) { showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); return false; } + if (span) { + if (!preview.snapped) return (span.through.push(preview.point), true); + const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through); + if (typeof next === "string") showToast(next, "info"); + else params.commit(next); + span = null; + setMode("idle"); + return true; + } const line = active; + if (line === null) return false; const ended = preview.snapped; let next = params.network(); if (preview.onLine) @@ -355,23 +406,41 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { const layer: PlanMapLayer = { draw(context, map) { - const network = params.network(); - paintNetwork(context, map, network, active, params.heightAt(), () => { - const from = active === null ? null : lastOf(active); - if (from && preview) - paintPreview( - context, - map, - from, - preview, - kind === "both" ? { lengthM, candidates } : null, - ); - }); + let network = params.network(); + let stroke = active; + const index = spanLine(); + const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null; + if (span && index >= 0) { + // 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴) + const head = network.lines[index].vertices[span.from]; + const temp = { id: "", vertices: [head, ...span.through], branch: null }; + network = { ...network, lines: [...network.lines, temp] }; + stroke = network.lines.length - 1; + } + paintNetwork( + context, + map, + network, + stroke, + params.heightAt(), + () => { + const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1); + if (from && preview) + paintPreview( + context, + map, + from, + preview, + kind === "both" ? { lengthM, candidates } : null, + ); + }, + dim, + ); }, press(px, py, map, button) { if (!editable) return false; if (mode === "draw" && button === 0) { - if (active === null) { + if (active === null && !span) { const started = begin(px, py, map); panelState(); params.onMode(); @@ -413,6 +482,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { }, key(event) { if (mode === "draw" && keyIs(event, "plan.drawFinish")) { + if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info"); setMode("idle"); return true; } @@ -423,6 +493,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { setMode("idle"); return true; } + if (mode === "draw" && span && keyIs(event, "plan.drawBack")) { + if (!span.through.pop()) setMode("idle"); + return true; + } if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) { const network = params.network(); const step = @@ -440,9 +514,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; }, sync() { - if (active !== null && active >= params.network().lines.length) { + if ( + (active !== null && active >= params.network().lines.length) || + (span && spanLine() < 0) + ) { active = null; flip = false; + span = null; mode = "idle"; } panelState(); @@ -456,5 +534,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (slope !== undefined) slopePct = slope; kindFields.set({ kind, lengthM, slopePct }); }, + respan, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index a5ef6888a..3c1037f3f 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -86,7 +86,7 @@ export function lineAt( } /** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기). - * `active` = 그리는 선(굵은 보라). */ + * `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */ export function paintNetwork( context: CanvasRenderingContext2D, map: ScreenMap, @@ -94,6 +94,7 @@ export function paintNetwork( active: number | null, z: HeightAt, extra: () => void, + dim: { line: number; from: number; to: number } | null = null, ): void { const colors = planColors(); context.lineCap = "round"; @@ -102,13 +103,22 @@ export function paintNetwork( if (line.vertices.length < 2) return; context.strokeStyle = index === active ? colors.active : colors.line; context.lineWidth = line.branch ? 2.5 : 3; - context.beginPath(); - line.vertices.forEach((v, i) => { - const [sx, sy] = map.toScreen(v); - if (i) context.lineTo(sx, sy); - else context.moveTo(sx, sy); - }); - context.stroke(); + const path = (vertices: ReadonlyArray): void => { + context.beginPath(); + vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + }; + if (dim?.line === index) { + path(line.vertices.slice(0, dim.from + 1)); + path(line.vertices.slice(dim.to)); + context.globalAlpha = 0.3; + path(line.vertices.slice(dim.from, dim.to + 1)); + context.globalAlpha = 1; + } else path(line.vertices); if (map.stations) drawStationTicks( context, diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts new file mode 100644 index 000000000..06b7fd896 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * D03_PlanAnalysis_Span.ts + * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. + * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · + * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * ========================================================================== */ + +import { lineLength } from "./D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; + +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */ +export function respanBlocked( + network: PlanNetwork, + line: number, + from: number, + to: number, +): string | null { + const entry = network.lines[line]; + if (!entry || !(from >= 0 && to > from && to < entry.vertices.length)) + return "다시 그을 구간을 고르세요."; + if ( + network.lines.some( + (o) => o.branch?.line === entry.id && o.branch.vertex > from && o.branch.vertex < to, + ) + ) + return "구간 속 점에 갈래가 붙어 있습니다 — 갈래를 먼저 지우세요."; + return null; +} + +/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */ +export function replaceSpan( + network: PlanNetwork, + line: number, + from: number, + to: number, + through: ReadonlyArray, +): PlanNetwork | string { + const blocked = respanBlocked(network, line, from, to); + if (blocked) return blocked; + const entry = network.lines[line]; + const old = entry.vertices; + const vertices: Vertex[] = [ + ...old.slice(0, from + 1), + ...through.map(([x, y]): Vertex => [x, y]), + ...old.slice(to), + ]; + const shift = through.length - (to - from - 1); + const at = (list: ReadonlyArray, i: number): number => lineLength(list.slice(0, i + 1)); + const [startM, oldEndM, newEndM] = [at(old, from), at(old, to), at(vertices, to + shift)]; + const carry = (m: number): number => + m <= startM + ? m + : m >= oldEndM + ? m - oldEndM + newEndM + : startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM); + return { + lines: network.lines.map((other, i) => { + if (i === line) return { ...other, vertices }; + const branch = other.branch; + return branch?.line === entry.id && branch.vertex >= to + ? { ...other, branch: { ...branch, vertex: branch.vertex + shift } } + : other; + }), + pieces: network.pieces.map((piece) => + piece.line === entry.id + ? { ...piece, from_m: round3(carry(piece.from_m)), to_m: round3(carry(piece.to_m)) } + : piece, + ), + }; +} diff --git a/resources/tester/test_599_3_plan_span.py b/resources/tester/test_599_3_plan_span.py new file mode 100644 index 000000000..2a7a3a83c --- /dev/null +++ b/resources/tester/test_599_3_plan_span.py @@ -0,0 +1,101 @@ +"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`. + +두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 · +마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로. + +./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.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 span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js"; +const Y = 2026; +let n = plan.newLine(plan.emptyNetwork(), [0, 0]); +for (const p of [[100, 0], [200, 0], [300, 0]]) n = plan.appendVertex(n, 0, p); +n = plan.normalizePieces(n, Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 250), Y); +n = plan.addBranch(n, 0, 2); +n = plan.normalizePieces(plan.appendVertex(n, 1, [200, 80]), Y); +const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]); +const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]); +const bad = span.replaceSpan(n, 0, 2, 2, []); +const straight = span.replaceSpan(n, 0, 0, 2, []); +writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight })); +""" + + +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, + ) + 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_replace_span(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + done = r["done"] + main = done["lines"][0] + assert main["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0], [300, 0]] + assert done["lines"][1]["branch"] == {"line": "main", "vertex": 3} # 뒤 갈래 한 칸 밈 + new_span = 2 * math.hypot(50, 50) + pieces = [(p["id"], p["from_m"], p["to_m"]) for p in done["pieces"] if p["line"] == "main"] + before = [p["id"] for p in r["before"]["pieces"] if p["line"] == "main"] + assert [p[0] for p in pieces] == before # 조각 id 그대로 + assert pieces[1][1] == round(100 + new_span / 2, 3) # 구간 속 마크 = 길이 비율 + assert pieces[2][1] == round(250 - 200 + 100 + new_span, 3) # 뒤 마크 = 길이 차 + assert pieces[2][2] == round(300 - 200 + 100 + new_span, 3) + + +def test_blocked_and_straight(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + assert "갈래" in r["wide"] # 속 점(2)에 갈래 + assert "고르세요" in r["bad"] + straight = r["straight"]["lines"][0]["vertices"] + assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게 + assert r["straight"]["lines"][1]["branch"]["vertex"] == 1 From acdd0aa469e037f5fac3518b1d830d46b7b9d470 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:44:39 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat(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=EC=A7=84=ED=96=89=20=3D=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=20=EA=B7=80=ED=89=81=EC=9D=B4=20=EC=84=9C?= =?UTF-8?q?=ED=81=B4=20=C2=B7=20=EB=81=8A=EA=B9=80=20=3D=20[=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9B=EA=B8=B0]=20=C2=B7=20=EA=B2=B9=20?= =?UTF-8?q?=EB=8B=A4=EC=8B=9C=20=EB=B0=9B=EA=B8=B0=20=3D=20=EB=B0=9B?= =?UTF-8?q?=EC=9D=80=20=EB=B2=94=EC=9C=84=20=ED=8C=90=EC=A0=95=20=C2=B7=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=98=AE=EA=B9=80=20=EB=B0=9B=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EC=9D=8C=20(PLAN=20596-3=20=C2=B7=20596-4=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 - 부품 progress 원에 corner 꼴(오른쪽 아래 · 글 왼쪽) · 부품 모음 견본 - 서버 다시 켜짐(끝 아닌데 멎음 · 진행 못 읽음) = 끊김 → [다시 받기] - 겹: 받은 범위(have) 바뀌면 새로 받음 · 등고 · 세류 주소에 범위 지문 &v= · 등고 자르는 상자 = 받은 범위 - 임도망 [올리기] 지도 겹 아이콘 줄에서 왼쪽 칸 단추로(Layers 가 uploadRoads 내줌) - Api: 받기 center · reset · 답 far · band 꼴 · 진행 interrupted · 임도망 bounds · distance_m(595 꼴) - 시험 553_4: 막대 대신 귀퉁이 서클 단언 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 28 +++++- .../D03_PlanAnalysis_UI_LayerDefs.ts | 2 +- .../D03_PlanAnalysis_UI_Layers.ts | 99 ++++++++++--------- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 80 +++++++-------- resources/tester/test_553_4_de_parts.py | 3 +- ui_template/ui_template_progress.css | 9 ++ ui_template/ui_template_progress.ts | 8 +- ui_template/ui_template_ui_kit_parts.ts | 5 +- 8 files changed, 136 insertions(+), 98 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 1e7cb00e4..211712a90 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -32,6 +32,20 @@ export interface PlanJob { percent: number; message: string | null; error: string | null; + /** 서버 다시 켜짐 등으로 끊김(595-3). */ + interrupted?: boolean; +} + +/** 받기 시작 답(595-2) — 받음 · 멂(받은 범위에서 5 km 넘음 → 묻고 `reset`) · 다른 TM 띠(노선 지움 확인 뒤 `reset`). */ +export type TerrainStart = + | (PlanJob & { status?: "accepted" }) + | { status: "far"; distance_m: number; message: string } + | { status: "band"; epsg: number; center_epsg: number; message: string }; + +/** 위치 칸 기준점(캐시 · [저장] 전) — 서버가 둘레 ±1 km 를 더 덮음 · `reset` = 옛 받은 범위 버림. */ +export interface TerrainCenter { + center?: { lon: number; lat: number } | null; + reset?: boolean; } /** 받기 전 범위(561-2) — 상자(작업 좌표계 m · 경위도 네 귀) · 받은 범위 · 도엽 · 칸 수 어림. */ @@ -94,10 +108,15 @@ export const fetchTerrainExtent = ( projectId: string, marginM: number | null, extra: Vertex[], + center: TerrainCenter["center"] = 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}`); }; @@ -105,10 +124,11 @@ export const startTerrain = ( projectId: string, marginM: number | null, extra: Vertex[], -): Promise => + where: TerrainCenter = {}, +): Promise => request(`${base(projectId)}/terrain`, { method: "POST", - body: JSON.stringify({ margin_m: marginM, extra }), + body: JSON.stringify({ margin_m: marginM, extra, ...where }), }); export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => @@ -214,6 +234,10 @@ export interface PlanForestRoads { crs: string[]; source: { files: string[]; bytes: number; uploaded_at: string } | null; collection: GeoJsonCollection; + /** 잘린 임도 상자 [서, 남, 동, 북] 경위도 · 없으면 null(595-4). */ + bounds?: [number, number, number, number] | null; + /** 기준점 ~ 가장 가까운 임도(m). */ + distance_m?: number | null; } /** 올린 적 없으면 오류(404 글 그대로). */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index e3a50f9fb..9075b136a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -70,7 +70,7 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [ { key: "forestRoadFile", icon: "map", - title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 옆 단추로 올림)", + title: "올린 임도망도(산림청 시군구 SHP = 진한 주황 실선 · 왼쪽 「위치」 칸에서 올림)", on: false, }, ], diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 0f0f818ee..fd78809cc 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -5,12 +5,13 @@ * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) · - * 올린 임도망도(시군구 SHP · 옆 올리기 단추)) · + * 올린 임도망도(시군구 SHP · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) · * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) · * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. - * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림. + * 받기 = 켤 때 · 받은 지형 범위(`have`)가 바뀌면 다시(596-4 · 등고 · 세류 주소에 범위 지문) · 지형을 안 받았으면 단추를 막음 · + * 받기 실패 = 그 겹만 꺼짐 + 알림. * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. @@ -22,11 +23,11 @@ import { showToast } from "@ui/ui_template_elements"; import { createLegend } from "@ui/ui_template_flow_legend"; -import { pickFiles } from "@ui/ui_template_drop"; import { createIconButton } from "@ui/ui_template_button"; import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; -import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; +import { API_BASE_URL } from "@config/config_frontend"; +import { fetchCachedJson } from "../A00_Common/b_asset_cache"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -51,6 +52,7 @@ import { fetchPlanZeroOrder, fetchPlanZones, uploadPlanForestRoads, + type PlanForestRoads, } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, @@ -81,7 +83,7 @@ const glFont = (weight: string, text: string): Promise => const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림"; -/** 등고선을 받아 올 그은 선 둘레(m). */ +/** 등고선을 받아 올 그은 선 둘레(m) — 받은 범위를 모를 때만. */ const CONTOUR_PAD_M = 600; type DataKey = keyof Prepared; @@ -94,8 +96,10 @@ export interface PlanLayers { legend: HTMLElement; /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ layers: PlanMapLayer[]; - /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */ + /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위가 바뀌면 겹을 새로 받음. */ load: () => Promise; + /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ + uploadRoads: (files: File[]) => Promise; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ state: () => { on: Record; @@ -109,15 +113,17 @@ export interface PlanLayers { export function createPlanLayers(opts: { projectId: string; map: PlanMap; - /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */ + /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선) — 받은 범위를 모를 때만. */ points: () => Vertex[]; + /** 받은 지형 범위(작업 좌표계 m) — 바뀜 판정 · 등고 · 세류 주소 지문. */ + have: () => [number, number, number, number] | null; }): PlanLayers { const { projectId, map } = opts; const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); - let uploading = false; let meta: VWorldMeta | null = null; + let loadedHave = ""; /** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */ let hillshade: { plain: GlImage; shade: GlImage } | null = null; const gl = createGlBase(projectId, (why) => { @@ -142,19 +148,6 @@ export function createPlanLayers(opts: { return button; }), ); - const upload = createIconButton({ - icon: "upload", - title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)", - size: 32, - onClick: () => - pickFiles( - { accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, - (files) => void uploadRoads(files), - ), - }); - upload.dataset.layer = "forestRoadUpload"; - const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!; - roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload); const glButton = createIconButton({ icon: "gauge", title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", @@ -203,7 +196,6 @@ export function createPlanLayers(opts: { button.title = title; button.setAttribute("aria-label", title); } - upload.disabled = !meta || uploading; for (const def of LAYERS) gl.setVisible(def.key, on[def.key]); gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const rows = meta ? legendRows(on, data) : []; @@ -249,16 +241,13 @@ export function createPlanLayers(opts: { return job; } - /** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ - async function uploadRoads(files: File[]): Promise { - if (!meta) return; + /** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ + async function uploadRoads(files: File[]): Promise { const at = meta; const job = uploadPlanForestRoads(projectId, files).then((info) => { - if (meta !== at) return; - data.forestRoadFile = { - layer: prepareLayer(info.collection, createNormalizer(at)), - info, - }; + if (meta !== at) return info; + if (at) + data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info }; gl.setData("forestRoadFile", info.collection); on.forestRoadFile = true; writeByKey(PREF_KEY, JSON.stringify(on)); @@ -266,25 +255,35 @@ export function createPlanLayers(opts: { `임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`, "success", ); + return info; }); - uploading = true; - pending.set("forestRoadFile", job); - sync(); + pending.set( + "forestRoadFile", + job.then(() => undefined), + ); try { - await job; + return await job; } catch (error) { showToast( `임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`, "error", ); + return null; } finally { - uploading = false; pending.delete("forestRoadFile"); sync(); map.redrawBase(); } } + /** 받은 범위 지문 — 등고 · 세류 주소에 붙여 브라우저 보관함이 옛 범위를 안 줌. */ + const haveTag = (): string => (opts.have() ?? []).map((v) => Math.round(v)).join("_"); + const sheetLayer = (layer: string, extra = ""): Promise => + fetchCachedJson( + projectId, + `${API_BASE_URL}/projects/${projectId}/geojson?layer=${encodeURIComponent(layer)}${extra}&v=${haveTag()}`, + ); + async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise { const normalizer = createNormalizer(at); const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => @@ -297,26 +296,26 @@ export function createPlanLayers(opts: { features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})), }); if (key === "contour") { + // 받은 범위 통째(모르면 그은 선 둘레) — 범위가 늘면 `load` 가 다시 받게 함 const points = opts.points(); const xs = points.map(([x]) => x); const ys = points.map(([, y]) => y); - const bbox = [ - Math.min(...xs) - CONTOUR_PAD_M, - Math.min(...ys) - CONTOUR_PAD_M, - Math.max(...xs) + CONTOUR_PAD_M, - Math.max(...ys) + CONTOUR_PAD_M, - ].map((v) => v.toFixed(0)); + const bbox = ( + opts.have() ?? [ + Math.min(...xs) - CONTOUR_PAD_M, + Math.min(...ys) - CONTOUR_PAD_M, + Math.max(...xs) + CONTOUR_PAD_M, + Math.max(...ys) + CONTOUR_PAD_M, + ] + ).map((v) => v.toFixed(0)); const [collection] = await Promise.all([ - fetchCachedSheetLayer(projectId, "도엽_등고선", "route", bbox.join(",")), + sheetLayer("도엽_등고선", `&scope=route&bbox=${bbox.join(",")}`), glFont("400", "0123456789-"), ]); data.contour = indexLayerLod(prepare(collection, ["등고수치"])); gl.setData("contour", collection); } else if (key === "stream") { - const collection = await fetchCachedSheetLayer( - projectId, - "도엽_하천중심선", - ); + const collection = await sheetLayer("도엽_하천중심선"); data.stream = [ prepare(pick(collection, (props) => props["구분"] === "세류")), prepare(pick(collection, (props) => props["구분"] !== "세류")), @@ -491,10 +490,13 @@ export function createPlanLayers(opts: { async function load(): Promise { const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); + // 바뀜 = 위성 틀 또는 받은 범위(같은 도엽 안에서 늘어도 겹을 새로 · 596-4) + const have = JSON.stringify(opts.have()); const changed = + have !== loadedHave || JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !== - JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]); - meta = next; + JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]); + [meta, loadedHave] = [next, have]; if (changed) { for (const key of Object.keys(data)) delete data[key as DataKey]; hillshade = null; @@ -511,6 +513,7 @@ export function createPlanLayers(opts: { legend, layers, load, + uploadRoads, state: () => ({ on: { ...on }, loaded: LAYERS.map((def) => def.key).filter(loadedOf), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 6d5284cf0..6fa77bc8b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -19,8 +19,9 @@ 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 { createProgressBar } from "@ui/ui_template_progress"; +import { createProgressCircle } from "@ui/ui_template_progress"; import { createText } from "@ui/ui_template_text"; import { createButton, @@ -70,10 +71,8 @@ import { import { lonLatToTm, tmParams } from "./logic/D03_PlanAnalysis_Tm"; const POLL_MS = 1000; -/** 지도 보기 가운데(보기의 반)가 받은 범위 밖이면 이어 받음 — 그 상자가 이보다 넓으면(멀리 줄여 봄) 안 받음(m). */ -const VIEW_FETCH_MAX_M = 2000; -/** 지도 보기 바뀐 뒤 이만큼 멈추면 범위를 봄(ms). */ -const VIEW_SETTLE_MS = 800; +/** 받기가 끊김(서버 다시 켜짐 · 진행을 못 읽음) — [다시 받기]. */ +const INTERRUPTED = "끊김(서버 다시 켜짐 등)"; /** 처음 맞춤 = 기준점 둘레(서버 `SITE_RADIUS_M`). */ const SITE_RADIUS_M = 1000; /** 되돌리기 쌓는 수. */ @@ -109,9 +108,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let redoStack: Snap[] = []; let surface: PlanSurface | null = null; let have: [number, number, number, number] | null = null; - let viewCore: Vertex[] = []; // 지도 보기 가운데 상자 두 귀(넓으면 빈 것) let viewMid: Vertex | null = null; // 지도 보기 가운데(지점 [추가] 자리) - let viewTimer = 0; let lastTried = ""; // 같은 받기를 거듭하지 않음 let ready = false; // 들어올 때 받기 판단이 끝남(그 앞 보기 바뀜은 안 받음) let busy = false; @@ -166,30 +163,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { idle: () => draw.mode() === "idle" && !measuring, editable: () => editable, }); - // 지도 보기 가운데 상자 — 그릴 때마다 보이는 화면 가운데 반을 작업 좌표로(바뀌면 잠시 뒤 범위 봄) + // 지도 보기 가운데 — 그릴 때마다 작업 좌표로(지점 [추가] 자리 · 보기만 옮김 = 안 받음 596-4) const viewWatch: PlanMapLayer = { draw(context, screen) { const [w, h] = [context.canvas.clientWidth, context.canvas.clientHeight]; - const corners = [ - [w / 4, h / 4], - [(3 * w) / 4, h / 4], - [(3 * w) / 4, (3 * h) / 4], - [w / 4, (3 * h) / 4], - ].map(([x, y]) => screen.toMetric(...screen.unrotate(x, y))); - const xs = corners.map((c) => c[0]); - const ys = corners.map((c) => c[1]); viewMid = w ? screen.toMetric(...screen.unrotate(w / 2, h / 2)) : null; - const box: Vertex[] = [ - [Math.min(...xs), Math.min(...ys)], - [Math.max(...xs), Math.max(...ys)], - ]; - const wide = - box[1][0] - box[0][0] > VIEW_FETCH_MAX_M || box[1][1] - box[0][1] > VIEW_FETCH_MAX_M; - const next = wide || !w ? [] : box; - if (JSON.stringify(next) === JSON.stringify(viewCore)) return; - viewCore = next; - clearTimeout(viewTimer); - viewTimer = window.setTimeout(() => void ensureCovered(), VIEW_SETTLE_MS); }, }; const map = createPlanMap({ @@ -202,16 +180,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { projectId, map, points: () => [...points, ...allVertices()], + have: () => have, }); - map.root.append(backdrop.bar, backdrop.legend, props.panel); + // 받기 진행 = 지도 오른쪽 아래 작은 서클(받을 때만 · 지도 조작 안 막음 596-3) + const circle = createProgressCircle({ size: 40, corner: true }); + circle.root.hidden = true; + map.root.append(backdrop.bar, backdrop.legend, props.panel, circle.root); const main = document.createElement("div"); main.className = "d03-plan"; main.append(map.root); const terrainText = createText("", "muted"); terrainText.classList.add("d03-plan__hint"); - const bar = createProgressBar(); - bar.root.hidden = true; const retryButton = createButton({ label: "다시 받기", variant: "filled", @@ -222,7 +202,18 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }, }); const terrainCard = createPanelCard("지형", false); - terrainCard.append(terrainText, bar.root, retryButton); + 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 pointCard = createPanelCard("지점", false); pointCard.append(marks.panel); const drawCard = createPanelCard("그리기", false); @@ -322,13 +313,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { list.every(([x, y]) => x >= have![0] && x <= have![2] && y >= have![1] && y <= have![3]); const outside = (): boolean => !!have && !inside([...points, ...allVertices()]); - /** 조용히 받기(요구 453) — 지점 · 선 · 지도 보기 가운데가 받은 범위 밖이면 이어 받음 · - * 단추 · 막는 창 없음 · 실패 = [다시 받기] · 같은 요청은 한 번만. */ + /** 조용히 받기(요구 453) — 지점 · 선이 받은 범위 밖이면 이어 받음(지도 보기만 옮김 = 안 받음 596-4) · + * 단추 · 막는 창 없음 · 실패 · 끊김 = [다시 받기] · 같은 요청은 한 번만. */ async function ensureCovered(): Promise { if (!ready || !editable || busy || failed || !epsg || !root.isConnected) return; - const need = [...points, ...allVertices()]; - if (inside(need) && inside(viewCore)) return; // 받을 때 서버가 여유를 더함 - const extra = [...need, ...viewCore]; + const extra = [...points, ...allVertices()]; + if (inside(extra)) return; // 받을 때 서버가 여유를 더함 const key = JSON.stringify([have, extra]); if (key === lastTried) return; lastTried = key; @@ -349,6 +339,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { props.render(); marks.render(); retryButton.hidden = !editable || busy || !failed; + roadButton.hidden = !editable; if (!busy) terrainText.textContent = failed ? `지형 받기 실패 — ${failed}` @@ -387,29 +378,32 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { await ensureCovered(); // 받는 동안 옮긴 지점 · 선 · 보기 } - /** 지형 받기를 끝날 때까지 지켜봄 — 진행은 지형 칸에. */ + /** 지형 받기를 끝날 때까지 지켜봄 — 진행 = 지도 귀퉁이 서클 · 끊김(서버 다시 켜짐 = 끝 아닌데 멎음 · + * 진행을 못 읽음) = 실패 꼴 + [다시 받기]. */ async function watch(): Promise { busy = true; - bar.root.hidden = false; + circle.set(null, "지형"); + circle.root.hidden = false; refresh(); try { for (;;) { if (!root.isConnected) return null; const job = (await fetchPlanProgress(projectId)).terrain; - bar.set(job.percent / 100); - terrainText.textContent = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; + circle.set(job.percent / 100, `지형 ${job.message ?? ""}`.trim()); + circle.root.title = `지형 받기 ${job.percent}% · ${job.message ?? ""}`; if (!job.running) { + const cut = !job.error && (job.interrupted || job.percent < 100); if (job.error) showToast(job.error, "error"); - return job; + return cut ? { ...job, error: INTERRUPTED } : job; } await sleep(POLL_MS); } } catch (error) { showToast(`진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); - return null; + return { running: false, percent: 0, message: null, error: INTERRUPTED }; } finally { busy = false; - bar.root.hidden = true; + circle.root.hidden = true; refresh(); } } diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index b6ff5235e..56eb893de 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -32,7 +32,8 @@ def test_d03_parts(): draw = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts") tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") - assert 'createElement("progress")' not in ts and "createProgressBar()" in ts + assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts + assert "createProgressCircle({ size: 40, corner: true })" in ts # 596-3 받기 = 지도 귀퉁이 서클 # 562-3 — 날 단추 · 입력 · 고르기 없음 · 도구줄 · 제약 칸 · 연도 고름은 부품 for src in (ts, draw, tools, layers): for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'): diff --git a/ui_template/ui_template_progress.css b/ui_template/ui_template_progress.css index fd04f96f5..3cb312117 100644 --- a/ui_template/ui_template_progress.css +++ b/ui_template/ui_template_progress.css @@ -19,6 +19,15 @@ justify-content: center; } +/* 오른쪽 아래 귀퉁이 — 지도 가운데(그리는 자리)를 안 가림 · 글은 원 왼쪽 */ +.ui-progress-circle--corner { + position: absolute; + z-index: 1; + right: var(--spacing-8); + bottom: var(--spacing-8); + flex-direction: row-reverse; +} + .ui-progress-circle__dial { position: relative; width: var(--ui-progress-size); diff --git a/ui_template/ui_template_progress.ts b/ui_template/ui_template_progress.ts index b2f2bdfe9..d2fe401d3 100644 --- a/ui_template/ui_template_progress.ts +++ b/ui_template/ui_template_progress.ts @@ -4,6 +4,7 @@ import "./ui_template_progress.css"; * ui_template_progress.ts * 부품 「진행 막대」(552-3 · 목록 30) — 막대 · 쌓임 막대 · 원. * 꼴 = 막대(`createProgressBar` · 값 하나) · 쌓임 막대(`parts` · 갈래 셋 accent · soft · warn) · 원(`createProgressCircle` · 아래) + * · 원 자리 = 흐름(기본) · 가운데 덮음(`overlay`) · 오른쪽 아래 귀퉁이(`corner` · 글은 원 왼쪽) * 크기 = 막대 굵기 6 · 8(`thick` → data-size) · 원 지름 72(기본) * 상태 = 기본 · 오류(`.is-error` · 막대가 위험색) · 판정(`tone`: warn · bad) * 쓰는 법 = `createProgressBar({ value: 0.4, label: "40 %" })` · `bar.set(0.8)` · `createProgressBar({ parts: [{ value: 3, tone: "accent" }, { value: 1, tone: "warn" }] })` @@ -28,6 +29,8 @@ export interface ProgressCircleOptions { label?: string; /** 컨테이너 정중앙에 띄우는 오버레이로 만든다(컨테이너는 position: relative 여야 한다). */ overlay?: boolean; + /** 컨테이너 오른쪽 아래 귀퉁이에 작게 띄운다(가운데를 안 가림 · 컨테이너는 position: relative). */ + corner?: boolean; } export interface ProgressCircleHandle { @@ -40,7 +43,10 @@ export interface ProgressCircleHandle { export function createProgressCircle(options: ProgressCircleOptions = {}): ProgressCircleHandle { const root = document.createElement("div"); - root.className = "ui-progress-circle" + (options.overlay ? " ui-progress-circle--overlay" : ""); + root.className = + "ui-progress-circle" + + (options.overlay ? " ui-progress-circle--overlay" : "") + + (options.corner ? " ui-progress-circle--corner" : ""); root.setAttribute("role", "status"); root.setAttribute("aria-live", "polite"); if (options.size) root.style.setProperty("--ui-progress-size", `${options.size}px`); diff --git a/ui_template/ui_template_ui_kit_parts.ts b/ui_template/ui_template_ui_kit_parts.ts index f5c310ded..afb3a99e8 100644 --- a/ui_template/ui_template_ui_kit_parts.ts +++ b/ui_template/ui_template_ui_kit_parts.ts @@ -337,9 +337,10 @@ export const KIT_PARTS: KitPart[] = [ demo: (s) => (s === "default" ? col(createKv([{ label: "연장", value: "1,080 m", big: true }, { label: "종단 경사", value: "12 %", ok: true }, { label: "최소 곡선반경", value: "12 m", ok: false }, { label: "비고", value: null }]), createKv([{ label: kitKeys("Ctrl", "Z"), value: "되돌리기" }, { label: kitKeys("휠 끌기"), value: "화면 옮기기" }, { label: kitKeys("Esc") }], { rule: true })) : null) }, { key: "calc-row", name: "계산 줄", group: "compound", kinds: "값 줄(이름 · 값 112 · 덧칸) · 식 줄(이름 · 식 · 값) · 위 · 아래첨자 · 중요 결과 굵게", file: "ui_template_calc_row.ts", demo: (s) => (s === "default" ? kitCalc() : null) }, - { key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 오류", file: "ui_template_progress.ts", + { key: "progress", name: "진행 막대", group: "compound", kinds: "막대 6 · 8 · 쌓임 막대 · 원 · 원 귀퉁이(corner) · 오류", file: "ui_template_progress.ts", demo: (s) => { if (s === "error") return markState(createProgressBar({ value: 0.6, label: "실패" }).root, s); if (s !== "default") return null; const ring = createProgressCircle({ size: 48 }); ring.set(0.4); - return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root); } }, + const corner = createProgressCircle({ size: 40, corner: true }); corner.set(0.65, "지형 65%"); const box = document.createElement("div"); box.style.cssText = "position:relative;height:72px;border:1px dashed var(--color-border);border-radius:var(--radius-inputs)"; box.append(corner.root); + return col(createProgressBar({ value: 0.4, label: "40 %" }).root, createProgressBar({ value: 0.7, thick: 6 }).root, createProgressBar({ parts: [{ value: 2, tone: "soft" }, { value: 3 }, { value: 1, tone: "warn" }], label: "6 건" }).root, ring.root, box); } }, { key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts", demo: (s) => (s === "default" ? col(createBarChart([{ label: "산림설계", value: 12 }, { label: "녹색기술", value: 7 }, { label: "그 밖 3 곳", value: 4 }]), createDonut([{ label: "임도", value: 5 }, { label: "예정노선", value: 3 }, { label: "안정성", value: 2 }]), createLineChart({ series: [{ name: "CPU", values: [20, 35, 30, 50, 42, 60], colorIndex: 0 }], height: 120 })) : null) }, { key: "legend", name: "범례", group: "compound", kinds: "색 칩 · 선 · 점선 · 점 · 꺼짐 · 세로", file: "ui_template_flow_legend.ts", From 9febb63e131c077af274de8faba12cd05b63f15a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:49:21 +0900 Subject: [PATCH 7/7] =?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=EB=A7=88=EB=8B=A4=20=EA=B8=8B?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=20=C2=B7=20=EA=B0=92=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=E2=80=94=20=EA=BC=AD=EC=A7=93=EC=A0=90=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=9B=80=EC=A7=81=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B4=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EA=B2=BD=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20(PLAN=20599-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 점 찍을 때 그때 방식 · 값을 nodes 칸에(594-1 꼴 · 붙은 점 = 자유 · 처음 기록 = 앞 노드 기록 없음) - 끼움 · 더함 · 뺌 · 지움 · 뒤집기(토막 끝 노드 몫 한 칸 밀림) · 잇기 · 다시 긋기(뒤 노드 = 자유)가 기록 길이를 꼭짓점과 맞춤 — [저장] 400 막음 - nodeRecord · nodeOff(길이 1 cm · 기울기 0.1 % 넘게 어긋남) — Span.ts(Plan 700 줄 넘지 않게) · 598 노드 판이 씀 - 시험 599_3 에 기록 따라감 · 어긋남 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 35 ++++++++--- .../D03_PlanAnalysis_UI_DrawPaint.ts | 14 ++++- .../logic/D03_PlanAnalysis_Plan.ts | 52 +++++++++++++---- .../logic/D03_PlanAnalysis_Span.ts | 48 ++++++++++++--- resources/tester/test_599_3_plan_span.py | 58 ++++++++++++++++++- 5 files changed, 175 insertions(+), 32 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 66db04074..7d18054d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -41,10 +41,11 @@ import { removeLine, reverseLine, samePoint, + type NodeRecord, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; -import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; +import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; import { fmt, lineAt, @@ -122,8 +123,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let kind: DrawConstraint = "free"; let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; - // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 - let span: { line: string; from: number; to: number; through: Vertex[] } | null = null; + // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 + let span: { + line: string; + from: number; + to: number; + through: Vertex[]; + records: NodeRecord[]; + } | null = null; const spanLine = (): number => span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1; @@ -272,7 +279,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; } setMode("draw"); - span = { line: lineId, from, to, through: [] }; + span = { line: lineId, from, to, through: [], records: [] }; panelState(); params.onMode(); return true; @@ -381,9 +388,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); return false; } + // 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2) + const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct); if (span) { - if (!preview.snapped) return (span.through.push(preview.point), true); - const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through); + if (!preview.snapped) { + span.through.push(preview.point); + span.records.push(record); + return true; + } + const next = replaceSpan( + params.network(), + spanLine(), + span.from, + span.to, + span.through, + span.records, + ); if (typeof next === "string") showToast(next, "info"); else params.commit(next); span = null; @@ -396,7 +416,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let next = params.network(); if (preview.onLine) next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point); - params.commit(appendVertex(next, line, preview.point)); + params.commit(appendVertex(next, line, preview.point, record)); if (ended) { showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success"); setMode("idle"); @@ -494,6 +514,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return true; } if (mode === "draw" && span && keyIs(event, "plan.drawBack")) { + span.records.pop(); if (!span.through.pop()) setMode("idle"); return true; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 5332eb23b..2e3a0a29a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; import { planColors, PLAN_STATION_INTERVAL_M, @@ -159,15 +160,22 @@ export function paintNetwork( ); if (map.slopes) segmentLabels(context, map, line.vertices, z); }); - // 꺾임점 + // 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2) context.fillStyle = "#ffffff"; + context.strokeStyle = colors.warn; + context.lineWidth = 2; for (const line of network.lines) - for (const v of line.vertices.slice(1)) { + line.vertices.forEach((v, i) => { + if (!i) return; const [sx, sy] = map.toScreen(v); context.beginPath(); context.arc(sx, sy, 3, 0, Math.PI * 2); context.fill(); - } + if (!nodeOff(line, i, z)) return; + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.stroke(); + }); drawPieces(context, map, network); extra(); } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index a56e04d06..07266668f 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -2,7 +2,8 @@ * D03_PlanAnalysis_Plan.ts * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 · * 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) · - * 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1). + * 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) · + * 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`). * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ @@ -21,6 +22,15 @@ export interface NodeRecord { slope_pct: number | null; } +/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */ +function withNodes( + line: PlanLine, + vertices: Vertex[], + edit: (nodes: Array) => Array, +): PlanLine { + return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices }; +} + /** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) · * 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) · * `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */ @@ -201,7 +211,7 @@ export function insertVertex( if (i === line) { const vertices = [...entry.vertices]; vertices.splice(segment + 1, 0, point); - return { ...entry, vertices }; + return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes)); } const branch = entry.branch; return branch && branch.line === id && branch.vertex > segment @@ -211,13 +221,21 @@ export function insertVertex( }; } -/** 선 끝에 점을 더함. */ -export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork { +/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */ +export function appendVertex( + network: PlanNetwork, + line: number, + point: Vertex, + record: NodeRecord | null = null, +): PlanNetwork { return { ...network, - lines: network.lines.map((entry, i) => - i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry, - ), + lines: network.lines.map((entry, i) => { + if (i !== line) return entry; + const vertices = [...entry.vertices, point]; + const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null); + return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices }; + }), }; } @@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork return { ...network, lines: network.lines.map((other, i) => - i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other, + i === line + ? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last)) + : other, ), }; } @@ -430,7 +450,8 @@ export function removeVertex( }; return { lines: network.lines.map((other, i) => { - if (i === line) return { ...other, vertices }; + if (i === line) + return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex)); const branch = other.branch; return branch?.line === entry.id && branch.vertex > vertex ? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } } @@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s const length = round3(lineLength(entry.vertices)); return { lines: network.lines.map((other, i) => { - if (i === line) return { ...other, vertices: [...other.vertices].reverse() }; + if (i === line) + return withNodes(other, [...other.vertices].reverse(), (nodes) => + nodes.map((_, k) => (k ? nodes[n - k] : null)), + ); const branch = other.branch; return branch?.line === entry.id ? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } } @@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork { ); const lines = network.lines .map((line, i) => { - if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] }; + if (i === a) { + const vertices = [...line.vertices, ...tail.vertices.slice(1)]; + if (!line.nodes && !tail.nodes) return { ...line, vertices }; + const nodesOf = (entry: PlanLine): Array => + entry.nodes ?? entry.vertices.map(() => null); + return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] }; + } const branch = line.branch; return branch?.line === tail.id ? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts index 06b7fd896..ad88e9d36 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -3,14 +3,35 @@ * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). + * 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유. * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * ========================================================================== */ -import { lineLength } from "./D03_PlanAnalysis_Draw"; -import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; +import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw"; +import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; const round3 = (value: number): number => Math.round(value * 1000) / 1000; +/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */ +export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord { + return { + kind, + length_m: kind === "length" || kind === "both" ? lengthM : null, + slope_pct: kind === "slope" || kind === "both" ? slopePct : null, + }; +} + +/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */ +export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean { + const record = line.nodes?.[vertex]; + if (!record || vertex < 1) return false; + const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]]; + if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01) + return true; + const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z); + return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1; +} + /** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */ export function respanBlocked( network: PlanNetwork, @@ -30,13 +51,15 @@ export function respanBlocked( return null; } -/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */ +/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. + * `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */ export function replaceSpan( network: PlanNetwork, line: number, from: number, to: number, through: ReadonlyArray, + records: ReadonlyArray = [], ): PlanNetwork | string { const blocked = respanBlocked(network, line, from, to); if (blocked) return blocked; @@ -56,13 +79,22 @@ export function replaceSpan( : m >= oldEndM ? m - oldEndM + newEndM : startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM); + const branched = (other: PlanLine): PlanLine => + other.branch?.line === entry.id && other.branch.vertex >= to + ? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } } + : other; return { lines: network.lines.map((other, i) => { - if (i === line) return { ...other, vertices }; - const branch = other.branch; - return branch?.line === entry.id && branch.vertex >= to - ? { ...other, branch: { ...branch, vertex: branch.vertex + shift } } - : other; + if (i !== line) return branched(other); + if (!other.nodes && !records.some(Boolean)) return { ...other, vertices }; + const nodes = other.nodes ?? old.map(() => null); + const inner = through.map((_, k) => records[k] ?? null); + const end = nodeRecord("free", 0, 0); + return { + ...other, + vertices, + nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)], + }; }), pieces: network.pieces.map((piece) => piece.line === entry.id diff --git a/resources/tester/test_599_3_plan_span.py b/resources/tester/test_599_3_plan_span.py index 2a7a3a83c..1138265e8 100644 --- a/resources/tester/test_599_3_plan_span.py +++ b/resources/tester/test_599_3_plan_span.py @@ -1,7 +1,8 @@ -"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`. +"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`. 두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 · -마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로. +마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감 +(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남. ./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q """ @@ -34,7 +35,32 @@ const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]); const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]); const bad = span.replaceSpan(n, 0, 2, 2, []); const straight = span.replaceSpan(n, 0, 0, 2, []); -writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight })); +// 599-2 노드 기록 +const L = span.nodeRecord("length", 20, 9); +const S = span.nodeRecord("slope", 20, 9); +let r = plan.newLine(plan.emptyNetwork(), [0, 0]); +r = plan.appendVertex(r, 0, [10, 0]); +r = plan.appendVertex(r, 0, [30, 0], L); +r = plan.appendVertex(r, 0, [30, 5], S); +const recorded = r; +const inserted = plan.insertVertex(r, 0, 0, [5, 0]); +const dropped = plan.dropLastVertex(r, 0); +const removed = plan.removeVertex(r, 0, 1); +const reversed = plan.reverseLine(r, 0); +let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L); +const joined = plan.attachHead(t, 1); +const moved = plan.moveVertex(r, 0, 2, [33, 0]); +const flat = () => 0; +const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat)); +const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]); +const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]); +writeFileSync( + process.argv[2], + JSON.stringify({ + before: n, done, wide, bad, straight, + recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain, + }), +); """ @@ -99,3 +125,29 @@ def test_blocked_and_straight(tmp_path: Path) -> None: straight = r["straight"]["lines"][0]["vertices"] assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게 assert r["straight"]["lines"][1]["branch"]["vertex"] == 1 + + +L = {"kind": "length", "length_m": 20, "slope_pct": None} +S = {"kind": "slope", "length_m": None, "slope_pct": 9} +F = {"kind": "free", "length_m": None, "slope_pct": None} + + +def _nodes(network: dict, i: int = 0) -> list: + line = network["lines"][i] + assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음 + return line.get("nodes") + + +def test_node_records_follow_vertices(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음 + assert _nodes(r["inserted"]) == [None, None, None, L, S] + assert _nodes(r["dropped"]) == [None, None, L] + assert _nodes(r["removed"]) == [None, L, S] + assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림 + assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L] + assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음 + # 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음 + assert r["off"] == [False, True, True] + assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유 + assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음