diff --git a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts index 4ef0f6a6..c26e2d56 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapOverlays.ts @@ -202,6 +202,9 @@ export interface StationTickOptions { toScreen: (x: number, y: number) => [number, number]; /** 관 마커가 놓인 누가거리 목록 — 겹치면 라벨을 반대쪽으로 민다. */ avoidChainages?: ReadonlyArray; + /** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. + * 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */ + uprightRad?: number; } export function drawStationTicks( @@ -274,11 +277,18 @@ export function drawStationTicks( ); if (collides) continue; drawn.push({ x: lx, y: ly, half }); + context.save(); + if (options.uprightRad) { + context.translate(lx, ly); + context.rotate(options.uprightRad); + context.translate(-lx, -ly); + } // 배경을 깔아 등고선 위에서도 읽히게 한다. context.fillStyle = "rgba(255, 255, 255, 0.78)"; context.fillRect(lx - half, ly - 8, width, 16); context.fillStyle = "#222222"; context.fillText(label, lx, ly); + context.restore(); } context.restore(); } diff --git a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts index 7921d46e..1ae0211f 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapRender.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapRender.ts @@ -534,6 +534,8 @@ export function drawPreparedLabels( view: ViewState, color: string, everyM = 25, + /** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다. */ + uprightRad = 0, ): void { const affine = affineOf(view); const margin = CULL_MARGIN; @@ -559,11 +561,19 @@ export function drawPreparedLabels( continue; } drawn.push({ x, y, half }); + context.save(); + if (uprightRad) { + // 글자 **자리는 그대로** 두고 글자만 되돌린다 — 180°에서 숫자가 뒤집혀 안 읽힌다. + context.translate(x, y); + context.rotate(uprightRad); + context.translate(-x, -y); + } context.lineWidth = 3; context.strokeStyle = haloColor(); context.strokeText(feature.labelText, x, y); context.fillStyle = color; context.fillText(feature.labelText, x, y); + context.restore(); } } diff --git a/B05_Profile/B05_Profile_UI_Page_Actions.ts b/B05_Profile/B05_Profile_UI_Page_Actions.ts index b709f688..591cbe61 100644 --- a/B05_Profile/B05_Profile_UI_Page_Actions.ts +++ b/B05_Profile/B05_Profile_UI_Page_Actions.ts @@ -25,6 +25,7 @@ import { } from "./B05_Profile_Api_Fetch"; import { flushCulvertOptions } from "../B06_Section/B06_Section_Api_Culvert_Options"; import { flushPendingPipes } from "./B05_Profile_Api_Pipes_Draft"; +import { stateKey } from "../A00_Common/b_page_state"; import { invalidateSectionDetail, saveCachedCrossPatches, @@ -170,10 +171,17 @@ export async function tempSaveAction(ctx: PageActionContext): Promise { ); // B06 조정창에서 만진 배수관 구간값도 세션에만 있다 — 함께 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). 실패해도 저장은 진행한다. + // ⚠ 키 **셋을 다 넘긴다**(2026-09-12 사용자: 어느 페이지에서 저장해도 결과가 같아야 + // 한다). 종전에는 구간값 키만 넘겨, B06 에서 예약한 관 이동·추가·삭제가 B05 [임시저장] + // 에서는 조용히 빠졌다. if (latest?.route?.id != null) { - await flushCulvertOptions(projectId, `b06:culvertopt:${projectId}:${latest.route.id}`).catch( - () => undefined, - ); + const routeId = latest.route.id; + await flushCulvertOptions( + projectId, + stateKey("culvertopt", projectId, routeId), + stateKey("culvertmove", projectId, routeId), + stateKey("culvertedit", projectId, routeId), + ).catch(() => undefined); } // B06에서 만져 **캐시에 얹힌** 횡단 수정분을 함께 남긴다 — 안 보내면 바로 아래 // 캐시 비우기에서 사라진다. 계획선 저장 **뒤에** 보내야 사용자 수정 1세트가 diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index dd517a27..87521bd7 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -46,6 +46,7 @@ import { } from "./B05_Profile_UI_RouteEdit_Input"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; +import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; import { createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { @@ -93,54 +94,8 @@ export async function openRouteEditModal( options: RouteEditOptions = {}, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; - const overlay = document.createElement("div"); - overlay.className = "b05-routeedit"; - overlay.innerHTML = ` - `; - document.body.append(overlay); - - const canvas = overlay.querySelector(".b05-routeedit__canvas")!; - const status = overlay.querySelector(".b05-routeedit__status")!; - const busy = overlay.querySelector(".b05-routeedit__busy")!; + const chrome = createRouteEditChrome(); + const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -177,7 +132,7 @@ export async function openRouteEditModal( /** 측점 횡단 미리보기 창 — 측점 눈금을 누르면 뜬다(계획서 0-9 ⑧). */ const crossPreview = createCrossPreview({ projectId, - bounds: () => canvas.getBoundingClientRect(), + side: overlay.querySelector(".b05-routeedit__side")!, request: () => ({ vertices: planned.map(([x, y], index) => ({ x, @@ -198,10 +153,36 @@ export async function openRouteEditModal( toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, onChange: () => { - status.textContent = `${routeHead()} — ${measure.hint()}`; + syncMeasureBox(); draw(); }, }); + + /** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */ + function syncMeasureBox(): void { + const on = measure.active(); + measureBox.hidden = !on; + measureText.textContent = measure.hint(); + if (on && picked >= 0) { + picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다. + syncCurveBar(); + } + } + + /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ + let measureMode = false; + measureButton.addEventListener("click", () => { + measureMode = !measureMode; + measureButton.classList.toggle("is-active", measureMode); + if (!measureMode) measure.clear(); + }); + overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => { + measure.clear(); // 닫으면 잰 것이 지워진다(㉔). + measureMode = false; + measureButton.classList.remove("is-active"); + syncMeasureBox(); + draw(); + }); let view: ViewState = { width: 0, height: 0, @@ -226,7 +207,6 @@ export async function openRouteEditModal( window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. curveLabelBox.destroy(); // 패널은 `document.body` 에 붙어 있어 스스로 안 사라진다. - crossPreview.destroy(); overlay.remove(); }; overlay.querySelector(".b05-routeedit__close")!.addEventListener("click", close); @@ -292,7 +272,8 @@ export async function openRouteEditModal( pickedContour, contourStepM: contourStepM(), rotationRad: rotation.radians(), - measure: measure.points(), + uprightRad: rotation.uprightRad(), + measure: measure.marks(), expected, plannedLine, planned, @@ -355,7 +336,8 @@ export async function openRouteEditModal( /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ const routeHead = (): string => - `길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + + `예상노선 ${polylineLengthM(expected).toFixed(1)}m · ` + + `계획노선 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + `노드 ${planned.length}개`; /** 고른 등고선의 높이 — 못 읽었으면 높이 없이 「고른 등고선」만(계획서 0-9 ⑦). */ @@ -395,6 +377,11 @@ export async function openRouteEditModal( }), toScreen: (vertex) => rotation.rerotate(...toScreen(vertex)), applyEdit: (message) => applyEdit(message), + onUnselect: () => { + picked = -1; + syncCurveBar(); + draw(); + }, }); const curveLabelBox = curveBar.label; const syncCurveBar = curveBar.sync; @@ -441,7 +428,7 @@ export async function openRouteEditModal( if (event.button !== 0) return; const rect = canvas.getBoundingClientRect(); const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - if (event.shiftKey) { + if (event.shiftKey || measureMode) { // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). void measure.pick(px, py); return; @@ -454,12 +441,26 @@ export async function openRouteEditModal( dragMoved = false; if (dragNode >= 0) { picked = dragNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). syncCurveBar(); draw(); } else if (dragHandle) { picked = dragHandle.node; + measure.clear(); syncCurveBar(); draw(); + } else if ( + // 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나 + // 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다. + ((): boolean => { + if (picked >= 0) { + picked = -1; + syncCurveBar(); + } + return false; + })() + ) { + /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { @@ -547,6 +548,9 @@ export async function openRouteEditModal( if (dragMoved) { history?.commit(snapshotNow()); historyControls.sync(); + // 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲). + // 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초). + void crossPreview.refresh(); } dragNode = -1; dragHandle = null; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts new file mode 100644 index 00000000..11db2f50 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -0,0 +1,98 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Chrome.ts + * 계획노선 편집 모달의 **뼈대** — 창·단추·오버레이 판을 만들고 자주 쓰는 요소를 집어 준다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고 + * 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다. + * + * **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다. + * 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽 + * 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다. + * ========================================================================== */ + +/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린 + * 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */ +const HALF_TURN_ICON = { + ccw: ``, + cw: ``, +}; + +export interface RouteEditChrome { + overlay: HTMLElement; + canvas: HTMLCanvasElement; + status: HTMLElement; + busy: HTMLElement; + measureBox: HTMLElement; + measureText: HTMLElement; + measureButton: HTMLButtonElement; +} + +/** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ +export function createRouteEditChrome(): RouteEditChrome { + const overlay = document.createElement("div"); + overlay.className = "b05-routeedit"; + overlay.innerHTML = ` + +
`; + document.body.append(overlay); + + const canvas = overlay.querySelector(".b05-routeedit__canvas")!; + const status = overlay.querySelector(".b05-routeedit__status")!; + const measureBox = overlay.querySelector(".b05-routeedit__measure")!; + const measureText = overlay.querySelector(".b05-routeedit__measure-text")!; + const measureButton = overlay.querySelector('[data-act="measure"]')!; + const busy = overlay.querySelector(".b05-routeedit__busy")!; + + return { overlay, canvas, status, busy, measureBox, measureText, measureButton }; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts index 7d8b47ad..f990ada5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross.ts @@ -1,30 +1,30 @@ /* ============================================================================= * B05_Profile_UI_RouteEdit_Cross.ts - * 계획노선 편집 중 **한 측점의 횡단 미리보기** — 따로 뜨는 작은 창(계획서 0-9 ⑧). + * 계획노선 편집 중 **측점 횡단** — 메인 창 오른쪽 세로 칸에 **붙박이 두 판**(계획서 0-9 ⑱⑲). + * + * · 위 판 = **지금 횡단**. 측점 눈금을 누르면 그 측점을 셈해 여기에 낸다. + * · 아래 판 = **이전 횡단**. 평소에는 빈 화면이고, 노선을 고쳐 **노드를 놓는 순간** + * 위 판의 것이 이리로 내려오고 새로 셈한 것이 위로 올라간다 — 전후를 나란히 본다. * * 보이는 것은 셋뿐이다(2026-09-12 사용자 확정) — **원지반 횡단선 · 기본 계획 횡단선 · * 계획 횡단의 성토사면 길이**. 구조물은 그리지 않는다. * * ⚠ **계획고는 편집 중에 없다** — [확인] 뒤 전 체인이 낳는 값이다. 그래서 서버가 그 측점의 - * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. 확정 뒤의 - * 횡단과 다를 수 있고, 창 머리에 그렇게 적어 둔다. + * 지반고를 그대로 계획고로 놓고(지반 추종) 사면만 세운 「기본 계획 횡단」을 낸다. * * 셈은 **B05·B06 정본을 그대로 재사용**한다 — 측점·지반 샘플은 `generate_sections`, 설계선은 - * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`(여기). + * `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`. * ========================================================================== */ -import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; -import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan"; - -/** 그림 가장자리 여백(px). */ -const PAD = 28; +import { drawCross, summarizeCross } from "./B05_Profile_UI_RouteEdit_Cross_Draw"; +import { formatStation } from "./B05_Profile_Util_Station"; export interface CrossPreviewParams { projectId: string; - /** 창을 처음 띄울 테두리(화면 좌표) — 보통 모달의 지도 칸. */ - bounds: () => DOMRect; - /** 지금 편집값 — 누른 순간에 읽어 서버로 보낸다. */ + /** 두 판이 들어앉을 오른쪽 세로 칸. */ + side: HTMLElement; + /** 지금 편집값 — 셈을 부르는 순간에 읽는다. */ request: () => { vertices: Array<{ x: number; y: number; curve: boolean; radius_m: number | null }>; min_radius_m: number; @@ -33,202 +33,99 @@ export interface CrossPreviewParams { } export interface CrossPreviewWindow { - /** 그 측점의 횡단을 띄운다. 이미 떠 있으면 내용만 갈아 끼운다. */ + /** 그 측점의 횡단을 위 판에 낸다. 같은 측점을 다시 누르면 보던 것을 아래로 내린다. */ open: (chainageM: number) => Promise; - /** 모달을 닫을 때 — 몸통이 `document.body` 에 붙어 있어 스스로 안 사라진다. */ - destroy: () => void; + /** 노선을 고쳤다 — 보던 측점을 **다시 셈해** 전후로 늘어놓는다. 보던 것이 없으면 아무 일도 없다. */ + refresh: () => Promise; } -export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { - const root = document.createElement("div"); +interface CrossPane { + root: HTMLElement; + /** 셈해 온 횡단을 그린다. `null` 이면 빈 화면으로 되돌린다. */ + show: (preview: CrossPreviewResponse | null, intervalM: number) => void; + /** 기다리는 중임을 알린다. */ + wait: (text: string) => void; +} + +function createPane(title: string, empty: string): CrossPane { + const root = document.createElement("section"); root.className = "b05-routeedit__cross"; - root.hidden = true; root.innerHTML = `
- 횡단 미리보기 - + ${title} +
- -
`; - document.body.append(root); - - const head = root.querySelector(".b05-routeedit__cross-head")!; - const title = root.querySelector(".b05-routeedit__cross-title")!; + +
${empty}
`; + const station = root.querySelector(".b05-routeedit__cross-station")!; const foot = root.querySelector(".b05-routeedit__cross-foot")!; const canvas = root.querySelector(".b05-routeedit__cross-canvas")!; const context = canvas.getContext("2d")!; - - root.querySelector(".b05-routeedit__cross-close")!.addEventListener("click", () => { - root.hidden = true; - }); - // 창 위에서 누른 것이 지도로 새어 나가면 노드가 딸려 움직인다. - for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) { - root.addEventListener(type, (event) => event.stopPropagation()); - } - - // ── 머리를 잡아 옮기기 — 노선을 가리면 손으로 치울 수 있어야 한다 ── - let dragFrom: { x: number; y: number; left: number; top: number } | null = null; - head.addEventListener("pointerdown", (event) => { - if ((event.target as HTMLElement).closest("button")) return; - dragFrom = { x: event.clientX, y: event.clientY, left: root.offsetLeft, top: root.offsetTop }; - head.setPointerCapture(event.pointerId); - event.preventDefault(); - }); - head.addEventListener("pointermove", (event) => { - if (!dragFrom) return; - root.style.left = `${Math.round(dragFrom.left + event.clientX - dragFrom.x)}px`; - root.style.top = `${Math.round(dragFrom.top + event.clientY - dragFrom.y)}px`; - }); - const stopDrag = (event: PointerEvent): void => { - if (head.hasPointerCapture(event.pointerId)) head.releasePointerCapture(event.pointerId); - dragFrom = null; + return { + root, + show(preview, intervalM) { + context.clearRect(0, 0, canvas.width, canvas.height); + if (!preview) { + station.textContent = ""; + foot.textContent = empty; + return; + } + // 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물 + // 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다. + station.textContent = formatStation(preview.chainage_m, intervalM); + drawCross(context, canvas, preview); + foot.textContent = summarizeCross(preview); + }, + wait(text) { + context.clearRect(0, 0, canvas.width, canvas.height); + foot.textContent = text; + }, }; - head.addEventListener("pointerup", stopDrag); - head.addEventListener("pointercancel", stopDrag); +} - /** 이번에 물은 측점 — 늦게 온 응답을 옛 자리에 적지 않으려고 들고 있는다. */ - let asked = -1; +export function createCrossPreview(params: CrossPreviewParams): CrossPreviewWindow { + const current = createPane("횡단", "측점 눈금을 누르면 그 측점 횡단이 뜹니다."); + const previous = createPane("이전 횡단", "노선을 고치면 고치기 전 횡단이 여기 남습니다."); + params.side.append(current.root, previous.root); + + /** 지금 보고 있는 측점(누가거리). 아직 없으면 null. */ + let watching: number | null = null; + /** 위 판에 그려 둔 것 — 다음 번에 아래로 내릴 재료. */ + let shown: CrossPreviewResponse | null = null; + /** 지금 부른 셈 — 늦게 온 응답을 새 자리에 적지 않으려고 든다. */ + let ticket = 0; + + async function load(chainageM: number, keepPrevious: boolean): Promise { + const mine = ++ticket; + const request = params.request(); + if (keepPrevious && shown) previous.show(shown, request.station_interval_m); + current.wait("읽는 중…"); + try { + const preview = await fetchCrossPreview(params.projectId, { + ...request, + chainage_m: chainageM, + }); + if (mine !== ticket) return; // 그 사이 다른 측점을 눌렀다. + shown = preview; + current.show(preview, request.station_interval_m); + } catch (error) { + if (mine !== ticket) return; + shown = null; + current.wait(error instanceof Error ? error.message : "횡단을 읽지 못했습니다."); + } + } return { async open(chainageM) { - asked = chainageM; - root.hidden = false; - if (!root.style.left) { - // 처음 열 때만 자리를 잡는다 — 그 뒤에는 사용자가 옮긴 자리를 지킨다. - // 지도 칸 **오른쪽 아래**에 붙인다 — 모달 머리·하단 정보행을 가리지 않는 자리다. - const box = params.bounds(); - root.style.left = `${Math.round(box.right - root.offsetWidth - 16)}px`; - root.style.top = `${Math.round(box.bottom - root.offsetHeight - 16)}px`; - } - title.textContent = "횡단 미리보기 — 읽는 중…"; - foot.textContent = ""; - context.clearRect(0, 0, canvas.width, canvas.height); - let preview: CrossPreviewResponse; - try { - preview = await fetchCrossPreview(params.projectId, { - ...params.request(), - chainage_m: chainageM, - }); - } catch (error) { - if (asked !== chainageM) return; - title.textContent = "횡단 미리보기"; - foot.textContent = error instanceof Error ? error.message : "횡단을 읽지 못했습니다."; - return; - } - if (asked !== chainageM || root.hidden) return; - title.textContent = `횡단 미리보기 — ${preview.label ?? `${preview.chainage_m}m`}`; - drawCross(context, canvas, preview); - foot.textContent = summarize(preview); + // 다른 측점을 고른 것이라면 전후 비교가 아니다 — 아래 판을 비운다. + const sameStation = watching !== null && Math.abs(watching - chainageM) < 1e-6; + if (!sameStation) previous.show(null, params.request().station_interval_m); + watching = chainageM; + await load(chainageM, sameStation); }, - destroy() { - root.remove(); + async refresh() { + if (watching === null) return; + await load(watching, true); }, }; } - -/** 성토사면 길이·절성토 면적 한 줄. 계획고가 없다는 것도 여기 적는다. */ -function summarize(preview: CrossPreviewResponse): string { - const design = preview.design; - if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; - // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 - // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. - const lengths = fillSlopeLengths({ - samples: preview.samples, - design, - } as unknown as CrossSection); - const sides = (["left", "right"] as const) - .filter((side) => lengths[side] !== null) - .map((side) => { - const value = lengths[side]!; - const label = side === "left" ? "좌" : "우"; - // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. - return `${label} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; - }); - const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; - return ( - `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡` + - " · 계획고는 [확인] 뒤에 정해지므로 지반을 따라 세운 기본 계획임" - ); -} - -/** 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+)가 왼쪽에 오게 눕힌다. */ -function drawCross( - context: CanvasRenderingContext2D, - canvas: HTMLCanvasElement, - preview: CrossPreviewResponse, -): void { - const ground = preview.samples - .filter((sample) => sample.valid && sample.elevation_m !== null) - .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); - const design = (preview.design?.design_line ?? []).map( - (point) => [point.offset_m, point.elevation_m] as [number, number], - ); - const all = [...ground, ...design]; - context.clearRect(0, 0, canvas.width, canvas.height); - if (all.length < 2) return; - - const offsets = all.map((point) => point[0]); - const heights = all.map((point) => point[1]); - const minOffset = Math.min(...offsets); - const maxOffset = Math.max(...offsets); - const minZ = Math.min(...heights); - const maxZ = Math.max(...heights); - const spanX = maxOffset - minOffset || 1; - const spanZ = maxZ - minZ || 1; - // **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 - // 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). - const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); - const centerOffset = (minOffset + maxOffset) / 2; - const centerZ = (minZ + maxZ) / 2; - // 좌(+offset)가 화면 왼쪽 — 횡단도 규약(generate_sections cad_exchange)과 같은 방향이다. - const toScreen = (point: [number, number]): [number, number] => [ - canvas.width / 2 + (centerOffset - point[0]) * scale, - canvas.height / 2 + (centerZ - point[1]) * scale, - ]; - - const stroke = (points: Array<[number, number]>, color: string, width: number): void => { - if (points.length < 2) return; - context.beginPath(); - points.forEach((point, index) => { - const [x, y] = toScreen(point); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.strokeStyle = color; - context.lineWidth = width; - context.stroke(); - }; - - // 중심선 — 어디가 노선 가운데인지 먼저 보이게. - const [centerX] = toScreen([0, centerZ]); - context.save(); - context.setLineDash([4, 4]); - context.strokeStyle = "rgba(148,163,184,0.7)"; - context.lineWidth = 1; - context.beginPath(); - context.moveTo(centerX, PAD / 2); - context.lineTo(centerX, canvas.height - PAD / 2); - context.stroke(); - context.restore(); - - stroke(ground, "#94a3b8", 1.6); // 원지반 - stroke(design, "#f97316", 2.2); // 기본 계획 횡단 - - context.font = "11px system-ui, sans-serif"; - context.textBaseline = "top"; - context.fillStyle = "#94a3b8"; - context.textAlign = "left"; - context.fillText("원지반", PAD, 6); - context.fillStyle = "#f97316"; - context.textAlign = "right"; - context.fillText("기본 계획 횡단", canvas.width - PAD, 6); - context.fillStyle = "#94a3b8"; - context.textAlign = "center"; - context.textBaseline = "bottom"; - context.fillText( - `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + - ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, - canvas.width / 2, - canvas.height - 4, - ); -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts new file mode 100644 index 00000000..825e2aa1 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Cross_Draw.ts @@ -0,0 +1,120 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Cross_Draw.ts + * 횡단 한 장을 캔버스에 그린다 — **원지반선·기본 계획 횡단선**과 아래 한 줄 요약. + * + * `B05_Profile_UI_RouteEdit_Cross.ts` 에서 떼어낸 조각이다(2026-09-12, 700줄 규정). + * 값은 서버가 B05·B06 정본으로 낸 것을 그대로 그린다 — 여기서 기하를 만들지 않는다. + * ========================================================================== */ + +import type { CrossSection } from "./../B06_Section/B06_Section_Api_Fetch"; +import { fillSlopeLengths } from "./../B06_Section/B06_Section_UI_Cross_Fit"; +import type { CrossPreviewResponse } from "./B05_Profile_Api_Replan"; + +/** 그림 가장자리 여백(px). */ +const PAD = 24; + +/** 성토사면 길이·절성토 면적 한 줄. */ +export function summarizeCross(preview: CrossPreviewResponse): string { + const design = preview.design; + if (!design) return "계획고를 못 세워 계획 횡단을 그리지 못했습니다."; + // 성토사면 길이는 **B06 화면이 쓰는 그 함수**를 그대로 부른다 — 두 화면이 다른 길이를 + // 말하면 안 된다. 필요한 것은 `samples` 와 `design` 둘뿐이라 그만 담아 넘긴다. + const lengths = fillSlopeLengths({ + samples: preview.samples, + design, + } as unknown as CrossSection); + const sides = (["left", "right"] as const) + .filter((side) => lengths[side] !== null) + .map((side) => { + const value = lengths[side]!; + // 계산 반폭 안에서 원지반을 못 만난 사면은 거기까지만 잰 하한값이라 「≥」로 구분한다. + return `${side === "left" ? "좌" : "우"} ${value.open ? "≥" : ""}${value.lengthM.toFixed(2)}m`; + }); + const slope = sides.length ? `성토사면 ${sides.join(" · ")}` : "성토측 없음"; + return `${slope} · 절토 ${design.cut_area_m2.toFixed(2)}㎡ · 성토 ${design.fill_area_m2.toFixed(2)}㎡`; +} + +/** + * 원지반선과 기본 계획 횡단선을 한 판에 그린다. 좌(+offset)가 화면 왼쪽이다 + * (`generate_sections` cad_exchange 규약과 같은 방향). + * + * **가로·세로를 같은 배율로** 둔다 — 따로 늘리면 사면 기울기가 거짓으로 보인다. 횡단도는 + * 기울기를 눈으로 읽는 그림이라 왜곡하면 안 된다(2026-09-12 실화면: 노면이 안 보였다). + */ +export function drawCross( + context: CanvasRenderingContext2D, + canvas: HTMLCanvasElement, + preview: CrossPreviewResponse, +): void { + const ground = preview.samples + .filter((sample) => sample.valid && sample.elevation_m !== null) + .map((sample) => [Number(sample.offset_m), Number(sample.elevation_m)] as [number, number]); + const design = (preview.design?.design_line ?? []).map( + (point) => [point.offset_m, point.elevation_m] as [number, number], + ); + const all = [...ground, ...design]; + context.clearRect(0, 0, canvas.width, canvas.height); + if (all.length < 2) return; + + const offsets = all.map((point) => point[0]); + const heights = all.map((point) => point[1]); + const minOffset = Math.min(...offsets); + const maxOffset = Math.max(...offsets); + const minZ = Math.min(...heights); + const maxZ = Math.max(...heights); + const spanX = maxOffset - minOffset || 1; + const spanZ = maxZ - minZ || 1; + const scale = Math.min((canvas.width - PAD * 2) / spanX, (canvas.height - PAD * 2) / spanZ); + const centerOffset = (minOffset + maxOffset) / 2; + const centerZ = (minZ + maxZ) / 2; + const toScreen = (point: [number, number]): [number, number] => [ + canvas.width / 2 + (centerOffset - point[0]) * scale, + canvas.height / 2 + (centerZ - point[1]) * scale, + ]; + + const stroke = (points: Array<[number, number]>, color: string, width: number): void => { + if (points.length < 2) return; + context.beginPath(); + points.forEach((point, index) => { + const [x, y] = toScreen(point); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.strokeStyle = color; + context.lineWidth = width; + context.stroke(); + }; + + // 중심선 — 어디가 노선 가운데인지 먼저 보이게. + const [centerX] = toScreen([0, centerZ]); + context.save(); + context.setLineDash([4, 4]); + context.strokeStyle = "rgba(148,163,184,0.7)"; + context.lineWidth = 1; + context.beginPath(); + context.moveTo(centerX, PAD / 2); + context.lineTo(centerX, canvas.height - PAD / 2); + context.stroke(); + context.restore(); + + stroke(ground, "#94a3b8", 1.6); // 원지반 + stroke(design, "#f97316", 2.2); // 기본 계획 횡단 + + context.font = "11px system-ui, sans-serif"; + context.textBaseline = "top"; + context.fillStyle = "#94a3b8"; + context.textAlign = "left"; + context.fillText("원지반", PAD, 4); + context.fillStyle = "#f97316"; + context.textAlign = "right"; + context.fillText("기본 계획 횡단", canvas.width - PAD, 4); + context.fillStyle = "#94a3b8"; + context.textAlign = "center"; + context.textBaseline = "bottom"; + context.fillText( + `좌 ${maxOffset.toFixed(0)}m ← 중심 → 우 ${Math.abs(minOffset).toFixed(0)}m` + + ` · 표고 ${minZ.toFixed(1)}~${maxZ.toFixed(1)}m`, + canvas.width / 2, + canvas.height - 2, + ); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts index 7abac1d8..5e4ec28c 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_CurveBar.ts @@ -41,6 +41,8 @@ export interface CurveBarParams { toScreen: (vertex: Vertex) => [number, number]; /** 한 번의 편집을 마무리한다 — 다시 그리고 되돌리기에 쌓는다. */ applyEdit: (message: string) => void; + /** 고른 꺾임점을 푼다 — 닫기 단추와 「빈 곳 누르기」가 부른다(계획서 0-9 ㉖). */ + onUnselect: () => void; } export interface CurveBar { @@ -51,6 +53,7 @@ export interface CurveBar { export function createCurveBar(params: CurveBarParams): CurveBar { const label = createCurveLabel({ + onClose: () => params.onUnselect(), onRadius: (value) => { const { picked, curveRadius } = params.state(); if (picked < 0) return; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index c6388f8d..0f0703b1 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -77,6 +77,8 @@ export interface CurveLabelState { } export interface CurveLabelHandlers { + /** 닫기 단추 — 고른 꺾임점을 푼다(계획서 0-9 ㉖). */ + onClose: () => void; onRadius: (value: number | null) => void; onArcLength: (value: number | null) => void; onCurveOn: (on: boolean) => void; @@ -114,6 +116,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
+
- - 칸을 비우면 자동`; + `; document.body.append(root); const head = root.querySelector(".b05-routeedit__label-head")!; @@ -171,6 +174,9 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { radius.addEventListener("change", () => handlers.onRadius(numberOf(radius, limitRadius))); arc.addEventListener("change", () => handlers.onArcLength(numberOf(arc, limitArc))); toggle.addEventListener("click", () => handlers.onCurveOn(!curveOn)); + root + .querySelector('[data-act="curve-close"]')! + .addEventListener("click", () => handlers.onClose()); lockRadius.addEventListener("click", () => handlers.onLock(lock === "radius" ? null : "radius")); lockArc.addEventListener("click", () => handlers.onLock(lock === "arc" ? null : "arc")); @@ -274,16 +280,12 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { arc.value = state.arcLengthShown === null ? "" : String(Math.round(state.arcLengthShown * 10) / 10); const inner = state.innerAngleDeg; - const held = - lock === "radius" ? "반지름 고정" : lock === "arc" ? "곡선 길이 고정" : "고정 없음"; - const floors = [ - limitRadius > 0 ? `R ≥ ${limitRadius}m` : "", - limitArc > 0 ? `L ≥ ${limitArc}m` : "", - ] - .filter(Boolean) - .join(" · "); + // 하단에는 **내각만** 남긴다(2026-09-12 사용자 지시 ㉗) — 고정 여부는 단추 색으로, + // 하한은 칸이 이미 막으므로 글로 또 적을 까닭이 없다. info.textContent = state.curveOn - ? `${held}${inner ? ` · 내각 ${Math.round(inner)}°` : ""}${floors ? ` · ${floors}` : ""}` + ? inner + ? `내각 ${Math.round(inner)}°` + : "" : "곡선 없음 — 직선이 그대로 꺾입니다"; place(state); // 글자가 바뀌면 상자 높이가 한 박자 늦게 자란다 — 다음 그림 직전에 한 번 더 맞춘다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 089611ad..6ec35fb9 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -37,13 +37,23 @@ export interface MeasureToolParams { onChange: () => void; } +export interface MeasureMark { + point: Vertex; + /** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */ + chainageM: number; +} + export interface MeasureTool { /** 찍힌 자리(0~2개) — 그리기가 쓴다. */ - points: () => Vertex[]; - /** 상태줄에 낼 한 줄. */ + marks: () => MeasureMark[]; + /** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */ hint: () => string; - /** Shift+클릭 한 번. 두 점이 차면 지반고를 한 번만 물어 온다. */ + /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ + active: () => boolean; + /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ pick: (px: number, py: number) => Promise; + /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ + clear: () => void; } export function createMeasureTool(params: MeasureToolParams): MeasureTool { @@ -51,7 +61,7 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { let picked: MeasurePoint[] = []; const hint = (): string => { - if (picked.length === 0) return "Shift+클릭으로 두 점을 찍으면 거리와 기울기가 보입니다."; + if (picked.length === 0) return ""; const first = picked[0]; if (picked.length === 1) { return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; @@ -73,8 +83,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }; return { - points: () => picked.map((entry) => entry.point), + marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })), hint, + active: () => picked.length > 0, + clear() { + if (picked.length === 0) return; + picked = []; + params.onChange(); + }, async pick(px, py) { const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); if (!hit) { diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 7e19c6e5..9bfa6af0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -15,6 +15,7 @@ import { drawPreparedLabels, drawPreparedLayer, layerScreenBounds, + normalizedToScreen, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; @@ -80,10 +81,32 @@ export interface RouteEditScene { picked: number; /** 규칙 측점 간격(m). */ stationIntervalM: number; - /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리(계획서 0-9 ⑤). */ - measure: ReadonlyArray; + /** 구간 재기로 찍은 점(0~2개) — 노선 위 자리와 누가거리(계획서 0-9 ⑤). */ + measure: ReadonlyArray<{ point: Vertex; chainageM: number }>; /** 지도를 돌린 각(라디안) — 캔버스 한가운데를 축으로 **그림 전체**가 돈다(계획서 0-9 ⑯). */ rotationRad: number; + /** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */ + uprightRad: number; +} + +/** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */ +function upright( + context: CanvasRenderingContext2D, + radians: number, + x: number, + y: number, + paint: () => void, +): void { + if (!radians) { + paint(); + return; + } + context.save(); + context.translate(x, y); + context.rotate(radians); + context.translate(-x, -y); + paint(); + context.restore(); } export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { @@ -137,6 +160,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed"; context.lineWidth = 2.6; drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view); + drawPickedContourLabel(context, scene, view); } // 등고 높이값 — 확대가 클수록 촘촘히 낸다(계획서 0-9 ③). context.font = "10px system-ui, sans-serif"; @@ -148,6 +172,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou view, style.getPropertyValue("--map-sheet-contour") || "#a5b4fc", everyM, + scene.uprightRad, ); } context.restore(); @@ -259,37 +284,71 @@ function drawMeasureMarks( context.font = "bold 11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; - scene.measure.forEach((vertex, index) => { - const [x, y] = scene.toScreen(vertex); + scene.measure.forEach((mark, index) => { + const [x, y] = scene.toScreen(mark.point); context.fillStyle = "rgba(255,255,255,0.95)"; context.beginPath(); context.arc(x, y, 7, 0, Math.PI * 2); context.fill(); context.stroke(); context.fillStyle = "#14532d"; - context.fillText(index === 0 ? "a" : "b", x, y); + upright(context, scene.uprightRad, x, y, () => context.fillText(index === 0 ? "a" : "b", x, y)); }); context.restore(); } -/** 두 점 사이의 노선 조각 — 가장 가까운 정점부터 정점까지. 어디를 쟀는지 보이기만 하면 된다. */ -function spanBetween(line: ReadonlyArray, from: Vertex, to: Vertex): Vertex[] { - const nearest = (target: Vertex): number => { - let best = 0; - let bestDistance = Infinity; - line.forEach((vertex, index) => { - const distance = Math.hypot(vertex[0] - target[0], vertex[1] - target[1]); - if (distance < bestDistance) { - bestDistance = distance; - best = index; - } - }); - return best; - }; - const start = nearest(from); - const end = nearest(to); - const [low, high] = start <= end ? [start, end] : [end, start]; - return [from, ...line.slice(low, high + 1), to]; +/** + * 두 점 사이의 노선 조각 — **누가거리로** 자른다(계획서 0-9 ㉕). + * + * ⚠ 예전에는 **가장 가까운 정점**으로 잘랐다. 노선이 되꺾이는 자리에서는 a 옆에 b 쪽 정점이 + * 더 가까이 붙어 있어 엉뚱한 자리를 골랐고, 그 결과 초록 띠가 노선을 벗어나 **삼각형으로 + * 얽혔다**(2026-09-12 사용자 화면). 찍을 때 이미 누가거리를 알고 있으므로 그것으로 자른다. + */ +function spanBetween( + line: ReadonlyArray, + from: { point: Vertex; chainageM: number }, + to: { point: Vertex; chainageM: number }, +): Vertex[] { + const low = Math.min(from.chainageM, to.chainageM); + const high = Math.max(from.chainageM, to.chainageM); + const head = from.chainageM <= to.chainageM ? from.point : to.point; + const tail = from.chainageM <= to.chainageM ? to.point : from.point; + const inside: Vertex[] = []; + let travelled = 0; + for (let index = 1; index < line.length; index += 1) { + const step = Math.hypot( + line[index][0] - line[index - 1][0], + line[index][1] - line[index - 1][1], + ); + // 정점의 누가거리가 두 점 사이면 그대로 잇는다 — 사이에 없는 정점은 건너뛴다. + if (travelled > low && travelled < high) inside.push(line[index - 1]); + travelled += step; + } + return [head, ...inside, tail]; +} + +/** 고른 등고선의 **높이값을 크게** 붙인다(계획서 0-9 ㉘) — 색만 바뀌면 몇 m 인지 안 보인다. */ +function drawPickedContourLabel( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + view: ViewState, +): void { + const feature = scene.contours?.layer.features[scene.pickedContour]; + if (!feature || feature.labelValue === null) return; + const [x, y] = normalizedToScreen(view, feature.labelAnchorX, feature.labelAnchorY); + const text = `${feature.labelValue}m`; + upright(context, scene.uprightRad, x, y, () => { + context.save(); + context.font = "bold 13px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + const width = context.measureText(text).width + 10; + context.fillStyle = "#7c3aed"; + context.fillRect(x - width / 2, y - 9, width, 18); + context.fillStyle = "#ffffff"; + context.fillText(text, x, y); + context.restore(); + }); } /** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ @@ -307,11 +366,12 @@ function drawStationMarks( intervalM: scene.stationIntervalM, pxPerMeter: scene.pxPerMeter, toScreen: (x, y) => scene.toScreen([x, y]), + uprightRad: scene.uprightRad, }, ); const total = polylineLengthM(line); const last = line.length - 1; - endLabel(context, scene, line[0], line[1], "시점 0+0.0"); + endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`); endLabel( context, scene, @@ -339,6 +399,11 @@ function endLabel( const x = x0 + ((x0 - x1) / length) * OUTWARD_PX; const y = y0 + ((y0 - y1) / length) * OUTWARD_PX; context.save(); + if (scene.uprightRad) { + context.translate(x, y); + context.rotate(scene.uprightRad); + context.translate(-x, -y); + } context.font = "bold 12px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts index 63bb9f36..beca4515 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts @@ -12,6 +12,15 @@ * 5°씩이면 한 바퀴에 일흔두 번이라 성가시다. */ const ROTATE_STEP_DEG = 15; +/** **글자를 눈높이로 세울지**(계획서 0-9 ㉚, 2026-09-12 사용자 지시). + * + * 그림만 돌고 숫자는 늘 바로 서게 한다 — 180° 로 돌리면 글자가 뒤집혀 안 읽히기 때문이다. + * 비용은 라벨 하나에 변환 한 번뿐이라 그림을 다시 그리는 값에 묻힌다. + * + * ⚠ **되돌리려면 이 값을 `false` 로만 바꾸면 된다** — 그러면 글자도 그림과 함께 돈다 + * (CAD 도면과 같은 방식). 사용자가 화면을 보고 판단할 수 있게 한 자리에 모아 두었다. */ +export const UPRIGHT_LABELS = true; + export interface MapRotationParams { /** 단추가 들어 있는 모달 — `[data-act="rotate-ccw"]`·`rotate-cw` 를 찾는다. */ overlay: HTMLElement; @@ -30,6 +39,8 @@ export interface MapRotation { rerotate: (px: number, py: number) => [number, number]; /** 화면에서 민 만큼(dx, dy) → 그림 좌표의 만큼. 팬·휠 확대 보정용. */ unrotateDelta: (dx: number, dy: number) => [number, number]; + /** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */ + uprightRad: () => number; } export function createMapRotation(params: MapRotationParams): MapRotation { @@ -61,6 +72,7 @@ export function createMapRotation(params: MapRotationParams): MapRotation { radians: () => radians, unrotate: (px, py) => spin(px, py, -radians), rerotate: (px, py) => spin(px, py, radians), + uprightRad: () => (UPRIGHT_LABELS ? -radians : 0), unrotateDelta: (dx, dy) => { if (!radians) return [dx, dy]; const cos = Math.cos(-radians); diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index ddf5ce79..a02701a2 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -6,16 +6,21 @@ inset: 0; z-index: var(--z-modal, 1000); display: flex; + gap: var(--spacing-12); align-items: center; justify-content: center; + padding: var(--spacing-12); background: rgb(0 0 0 / 55%); } +/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱). + 좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */ .b05-routeedit__box { position: relative; display: flex; + flex: 1 1 auto; flex-direction: column; - width: min(1200px, 94vw); + max-width: 1200px; height: min(820px, 92vh); overflow: hidden; border: 1px solid var(--color-border); @@ -142,6 +147,107 @@ border-top: 2px solid var(--map-route, #f97316); } +/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. */ +.b05-routeedit__side { + display: flex; + flex: none; + flex-direction: column; + gap: var(--spacing-12); + width: 452px; + height: min(820px, 92vh); +} + +@media (width < 1500px) { + /* 자리가 모자라면 오른쪽 칸을 접는다 — 지도가 먼저다. */ + .b05-routeedit__side { + display: none; + } +} + +.b05-routeedit__cross { + display: flex; + flex: 1 1 0; + min-height: 0; + flex-direction: column; + gap: var(--spacing-8); + padding: var(--spacing-12); + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-16, 12px); + background: var(--color-surface-raised); + box-shadow: 0 12px 40px rgb(0 0 0 / 45%); +} + +.b05-routeedit__cross-head { + display: flex; + flex: none; + align-items: baseline; + gap: var(--spacing-8); +} + +.b05-routeedit__cross-station { + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.b05-routeedit__cross-canvas { + flex: 1 1 auto; + min-height: 0; + width: 100%; + border: 1px solid var(--color-border); + border-radius: var(--radius-8, 6px); + background: var(--color-surface); +} + +.b05-routeedit__cross-foot { + flex: none; + color: var(--color-text-secondary); + font-size: var(--text-caption); + line-height: 1.5; +} + +/* ㉓ 거리 재기와 되돌리기 사이 구분선. */ +.b05-routeedit__divider { + width: 1px; + height: 20px; + margin: 0 var(--spacing-4, 4px); + background: var(--color-border); +} + +/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창. 닫으면 잰 것이 지워진다. */ +.b05-routeedit__measure { + position: absolute; + right: var(--spacing-12); + bottom: var(--spacing-12); + z-index: 1; + display: flex; + align-items: flex-start; + gap: var(--spacing-8); + max-width: 52%; + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid color-mix(in srgb, #22c55e 60%, transparent); + border-radius: var(--radius-8, 6px); + background: color-mix(in srgb, var(--color-surface-raised) 82%, transparent); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + color: var(--color-text-body); + font-size: var(--text-caption); +} + +/* 글이 길면 접힌다 — flex 자식은 기본으로 안 줄어들어 왼쪽으로 넘쳐 잘렸다(2026-09-12). */ +.b05-routeedit__measure-text { + min-width: 0; + line-height: 1.5; +} + +.b05-routeedit__measure-close { + flex: none; + border: none; + background: none; + color: var(--color-text-secondary); + cursor: pointer; +} + /* 재계산 중에는 화면 전체를 덮는다 — 결과를 기다릴 수밖에 없는 조작(CLAUDE.md 5장). */ .b05-routeedit__busy { position: absolute; @@ -189,6 +295,15 @@ touch-action: none; } +.b05-routeedit__label-close { + border: none; + background: none; + color: var(--color-text-secondary); + font-size: 13px; + line-height: 1; + cursor: pointer; +} + /* 고정 단추 — 켜지면 색이 찬다. 켠 값은 노드를 옮겨도 안 바뀐다. */ .b05-routeedit__lock { padding: 1px 6px; @@ -266,53 +381,3 @@ color: var(--color-text-secondary); line-height: 1.35; } - -/* ── 측점 횡단 미리보기 창 (계획서 0-9 ⑧) ──────────────────────────────── - 곡선 조작 패널과 같은 까닭으로 `document.body` 에 띄운다 — 모달이 `overflow: hidden` - 이라 안에 두면 가장자리에서 잘린다. 머리를 잡아 옮길 수 있다. */ -.b05-routeedit__cross { - position: fixed; - z-index: calc(var(--z-modal, 1000) + 2); - display: flex; - flex-direction: column; - gap: var(--spacing-8, 8px); - width: 452px; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-8, 6px); - background: var(--color-surface-raised); - box-shadow: 0 8px 28px rgb(0 0 0 / 40%); -} - -.b05-routeedit__cross-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-8, 8px); - cursor: move; - touch-action: none; -} - -.b05-routeedit__cross-close { - padding: 0 6px; - border: 1px solid var(--color-border); - border-radius: var(--radius-4, 4px); - background: transparent; - color: var(--color-text-secondary); - cursor: pointer; -} - -.b05-routeedit__cross-canvas { - display: block; - width: 100%; - height: auto; - border: 1px solid var(--color-border); - border-radius: var(--radius-4, 4px); - background: var(--color-surface); -} - -.b05-routeedit__cross-foot { - color: var(--color-text-secondary); - font-size: var(--text-caption); - line-height: 1.5; -} diff --git a/B06_Section/B06_Section_UI_Page_Persist.ts b/B06_Section/B06_Section_UI_Page_Persist.ts index 41299ad5..6e99d8be 100644 --- a/B06_Section/B06_Section_UI_Page_Persist.ts +++ b/B06_Section/B06_Section_UI_Page_Persist.ts @@ -420,7 +420,15 @@ export function collectSectionEdits(ctx: SectionPersistContext): { /** 세션에 쌓인 조정창·구조물 조작을 정본으로 내보낸다 — [저장]·[확정] 공통 앞단. */ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): Promise { - // 조정창 구간값은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 + // ⚠ 순서는 **B05 [임시저장]과 같아야 한다**(2026-09-12 사용자: 어느 페이지에서 저장해도 + // 결과가 같아야 한다). 관 목록은 B05 가 **전체 스냅샷**으로, B06 이 **바뀐 것만**(추가· + // 삭제·이동·구간값) 담으므로, 스냅샷을 먼저 얹고 그 위에 델타를 적용해야 한다. 반대로 + // 하면 스냅샷이 B06 편집을 통째로 덮는다. + await flushPendingPipes(projectId).catch((error) => { + const detail = error instanceof Error ? ` ${error.message}` : ""; + showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); + }); + // 조정창 구간값·추가·삭제·이동은 세션에만 있다 — 정본 payload를 모으기 전에 내보낸다 // (CLAUDE.md 5장: 영구저장은 [저장]·[확정]에서만). await ctx.flushCulvertOptions(); // B05 3D에서 바꾼 상단측(측구 방향)도 여기서 내보낸다 — 예전에는 B05 [임시저장]에만 @@ -431,12 +439,6 @@ async function flushPendingEdits(ctx: SectionPersistContext, projectId: string): // B05에서 만지고 넘어온 구조물 조작분도 여기서 정본에 남긴다. 실패해도 횡단 // 저장까지 막지는 않는다 — 미저장분은 세션에 남으므로 다시 시도할 수 있다 // (2026-08-29 실측: 타입이 거절되자 sections/save가 아예 나가지 않았다). - // B05 배수유역도에서 고친 관 목록(추가·이동·삭제)도 여기서 정본에 남긴다 — 예전에는 - // B05 [임시저장]에만 실려, B06 에서 저장하면 그 편집이 사라졌다(2026-09-06 대응표). - await flushPendingPipes(projectId).catch((error) => { - const detail = error instanceof Error ? ` ${error.message}` : ""; - showToast(`배수관 저장에 실패했습니다.${detail}`, "error"); - }); await flushPendingStructures(projectId).catch((error) => { const detail = error instanceof Error ? ` ${error.message}` : ""; showToast(`구조물 저장에 실패했습니다.${detail}`, "error"); diff --git a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts index 3a790a29..e7c048dc 100644 --- a/B06_Section/B06_Section_UI_Page_Structures_Panel.ts +++ b/B06_Section/B06_Section_UI_Page_Structures_Panel.ts @@ -24,6 +24,7 @@ import { type StructureInstance, type StructureType, } from "../B05_Profile/B05_Profile_Api_Structures"; +import { readPendingPipes } from "../B05_Profile/B05_Profile_Api_Pipes_Draft"; import { fetchDetailPipePoints } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { readStructurePick, @@ -375,7 +376,10 @@ export function createB06StructuresPanel(deps: B06StructuresPanelDeps): B06Struc structures = readPendingStructures(projectId) ?? stored.structures; section.setStructures(structures); const detail = deps.detail?.() ?? null; - pipeFacilities = pipeResponse.pipe_points.map((pipe) => ({ + // 저장하지 않고 넘어온 관 편집분이 있으면 그것으로 세운다 — 구조물과 같은 규칙이며, + // 이래야 B05 에서 넣고 B06 으로 넘어와도 같은 목록이 보인다(2026-09-12 사용자). + const pendingPipes = readPendingPipes(projectId); + pipeFacilities = (pendingPipes ?? pipeResponse.pipe_points).map((pipe) => ({ chainage_m: pipe.chainage_m, facility: pipe.facility ?? "pipe", start_m: pipe.start_m, diff --git a/ui_template/ui_template_provenance.ts b/ui_template/ui_template_provenance.ts index be53625e..396023ee 100644 --- a/ui_template/ui_template_provenance.ts +++ b/ui_template/ui_template_provenance.ts @@ -52,7 +52,14 @@ const TINT_KEY = "aislo.provenance.tint"; const STYLE_ID = "ui-provenance-style"; const CARD_ID = "ui-provenance-card"; -/** 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. */ +/** + * 칸에 심는 표시 — 열 키와 등급. 표를 그리는 쪽이 칸마다 한 번 부른다. + * + * `tier` 를 주면 **그 칸만 열 등급을 이긴다.** 같은 열이라도 줄마다 성격이 갈리는 + * 자리가 있기 때문이다 — 원가계산서 「금액」은 중간줄(간접노무비 따위)이 `calc` 인데 + * 마지막줄(총원가·도급금액·총계)은 `final` 이다(2026-09-12 데스크탑 보조 B09 배선). + * 칸 등급을 심지 않으면 열 등급이 그대로 선다. + */ export function markProvenanceCell(cell: HTMLElement, columnKey: string, tier?: string): void { cell.dataset.provCol = columnKey; if (tier) cell.dataset.provTier = tier; @@ -137,7 +144,15 @@ function place(element: HTMLElement, x: number, y: number): void { } /** 카드 한 장을 채운다. 값은 **화면에 적힌 글자 그대로** 보인다 — 자리수까지 같은 것이 요점. */ -function fill(target: HTMLElement, column: ProvenanceColumn, value: string, extra: string[]): void { +/** 카드 한 장을 채운다. 배지는 **칸 등급**을 먼저 본다 — 띄는 띄었는데 배지가 다른 말을 + * 하면 읽는 사람이 둘 중 어느 것을 믿을지 모른다. */ +function fill( + target: HTMLElement, + cell: HTMLElement, + column: ProvenanceColumn, + value: string, + extra: string[], +): void { target.replaceChildren(); const head = document.createElement("div"); head.className = "ui-prov-card__head"; @@ -145,8 +160,9 @@ function fill(target: HTMLElement, column: ProvenanceColumn, value: string, extr title.textContent = column.label; const badge = document.createElement("span"); badge.className = "ui-prov-card__badge"; - badge.dataset.provTier = column.tier; - badge.textContent = TIER_LABELS[column.tier] ?? column.tier; + const tier = cell.dataset.provTier || column.tier; + badge.dataset.provTier = tier; + badge.textContent = TIER_LABELS[tier] ?? tier; head.append(title, badge); target.append(head); @@ -171,6 +187,14 @@ export function attachProvenance( ): void { if (!sheet) return; injectProvenanceStyles(); + // 등급을 안 심은 칸은 **여기서 열 등급으로 채운다.** + // 색칠은 CSS 가 `data-prov-tier` 를 보고 하므로, 그 칸은 배지만 띄고 띄는 안 붙어 + // 「색이 안 붙는 칸」이 생긴다. 부르는 쪽이 등급을 빼먹는 것은 흔한 일이라 여기서 맞춘다. + for (const cell of root.querySelectorAll("[data-prov-col]")) { + if (cell.dataset.provTier) continue; + const tier = sheet.columns[cell.dataset.provCol ?? ""]?.tier; + if (tier) cell.dataset.provTier = tier; + } const hide = (): void => { const element = document.getElementById(CARD_ID); if (element) element.style.display = "none"; @@ -183,6 +207,7 @@ export function attachProvenance( const target = card(); fill( target, + cell, column, cell.textContent?.trim() ?? "", resolveExtra?.(cell, cell.dataset.provCol ?? "") ?? [],