Merge remote-tracking branch 'origin/sub_laptop_5' into sub_laptop_6

This commit is contained in:
2026-10-16 20:50:09 +09:00
20 changed files with 308 additions and 169 deletions
+11 -1
View File
@@ -525,6 +525,16 @@ html.is-bm-route .app-burger {
box-shadow: var(--shadow-lg);
}
html.is-bm-route[data-touch="on"] .app-burger { min-height: 44px; }
/* 좌측 패널이 펼쳐진 동안 ☰ 는 패널 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 그대로) —
패널을 접으면 떠 있는 단추(위 규칙)로 돌아감(352-4) */
html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger {
border-color: transparent;
background: transparent;
box-shadow: none;
}
html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger:hover {
background: color-mix(in srgb, var(--color-text) 10%, transparent);
}
html.is-bm-route .app-header.is-drawer-open .app-drawer {
left: 0;
right: auto;
@@ -535,7 +545,7 @@ html.is-bm-route .app-drawer__brand {
min-height: 44px;
padding-left: calc(var(--app-burger-inset) - var(--spacing-16));
}
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-start; }
html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }
`;
export function injectShellStyles(): void {
+16 -3
View File
@@ -23,16 +23,29 @@ import type { RouteDraft } from "../A00_Common/design/logic/design_pipeline_type
/** 편집 창 분석 지형 — las = 확정 지표면(라이다) · sheet = 수치지형도 서피스. */
export type AnalysisTerrain = "las" | "sheet";
/** 고른 분석 지형 — 화면 상태 보관소 pref 통(노선 편집 고정과 같은 자리). 없으면 `fallback`
* (모달이 열 때 설계 지형으로 채움). [확인] 때 이 값이 설계 지형이 됨(PLAN 133-2). */
/** 편집기가 열 때 알리는 지형 사정(352-3) — 저장하지 않음. 고를 수 있나(라이다 · 수치지형도 둘) · 설계 지형. */
const terrainContext = new Map<string, { choosable: boolean; design: AnalysisTerrain }>();
export const setTerrainContext = (
projectId: string,
choosable: boolean,
design: AnalysisTerrain,
): void => void terrainContext.set(projectId, { choosable, design });
/** 지금 쓰는 분석 지형 — 하나뿐인 프로젝트는 그것 · 둘이면 사용자가 고른 값(화면 상태 보관소 pref 통 ·
* 노선 편집 고정과 같은 자리), 고른 적 없으면 데이터대로(= 설계 지형 · 라이다가 있으면 라이다).
* 고른 값은 [저장] 때 설계 지형이 됨(PLAN 133-2). */
export const analysisTerrain = (
projectId: string,
fallback: AnalysisTerrain = "las",
): AnalysisTerrain => {
const context = terrainContext.get(projectId);
if (context && !context.choosable) return context.design;
const saved = readState<string>("routeedit-terrain", projectId);
return saved === "sheet" || saved === "las" ? saved : fallback;
return saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback);
};
/** 사용자가 고를 때만 기억 — 열 때 기본값은 쓰지 않음. */
export const setAnalysisTerrain = (projectId: string, terrain: AnalysisTerrain): void =>
writeState("routeedit-terrain", terrain, projectId);
+12 -15
View File
@@ -34,6 +34,7 @@ import {
fetchRoutePlan,
fetchTerrainSources,
setAnalysisTerrain,
setTerrainContext,
type AnalysisTerrain,
} from "./B05_Profile_Api_Replan";
import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply";
@@ -184,7 +185,7 @@ export async function mountRouteEditor(
let designTerrain: AnalysisTerrain = "las";
/** [확인]에 실을 분석 지형 — 고르기가 안 보이면 null(설계 지형 그대로). */
const pickedTerrain = (): AnalysisTerrain | null =>
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId, designTerrain);
terrainSelect.closest("label")!.hidden ? null : analysisTerrain(projectId);
/** 등고선 말고 함께 깔 도엽 레이어(하천중심선). */
let otherSheets: PreparedLayer[] = [];
/** 고른 등고선 가닥 — 없으면 -1(계획서 0-9 ⑦). */
@@ -284,13 +285,6 @@ export async function mountRouteEditor(
planMinRadiusM: () => minRadiusM,
apply: (snapshot, message) => restoreSnapshot(snapshot, message, true),
});
// [저장 전으로] — 마지막 저장(페이지를 세운 때) 노선 · 그 걸음의 「현재 추천」으로. 되돌리기로 다시 살림.
overlay.querySelector('[data-act="cancel"]')!.addEventListener("click", () => {
const snapshot = history?.reset();
if (!snapshot) return showToast("반영 뒤 고친 노선이 없습니다.", "info");
initial.restoreTag(snapshot.rec);
restoreSnapshot(snapshot, "반영 전 노선으로 되돌렸습니다.", false);
});
function resize(): void {
if (closed || !overlay.isConnected) return;
@@ -631,21 +625,21 @@ export async function mountRouteEditor(
syncCurveBar();
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
meta = drainage.meta;
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 기본 라이다.
// 고른 값이 없으면 지금 설계 지형(133-2) · 못 고르는 프로젝트는 설계 지형 그대로.
// 분석 지형 고르기(PLAN 133-1) — 라이다 · 수치지형도 둘 다 있을 때만 보임 · 열 때 안 씀(352-3).
// 고른 적 없으면 데이터대로(설계 지형 · 라이다가 있으면 라이다) · 사용자가 바꿀 때만 기억.
designTerrain = sources?.design ?? "las";
const choosable = !!sources?.sources.includes("las") && !!sources?.sources.includes("sheet");
terrainSelect.closest("label")!.hidden = !choosable;
setAnalysisTerrain(
projectId,
choosable ? analysisTerrain(projectId, designTerrain) : designTerrain,
);
setTerrainContext(projectId, choosable, designTerrain);
terrainSelect.value = analysisTerrain(projectId);
// 등고선은 따로 고른다(LAS 우선 · 수치지형도를 고르면 도엽). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다.
// 확정 모델 등고선은 설계 지형이 라이다일 때만 라이다 것 — 수치지형도 설계면 도엽 선(글도 도엽).
const loadBackground = () =>
loadRouteEditBackground(projectId, drainage, {
surfaceModelId:
analysisTerrain(projectId) === "sheet" ? null : (options.surfaceModelId ?? null),
analysisTerrain(projectId) === "las" && designTerrain === "las"
? (options.surfaceModelId ?? null)
: null,
intervalM: options.contourIntervalM ?? 1,
smooth: options.smooth ?? false,
});
@@ -672,6 +666,9 @@ export async function mountRouteEditor(
let terrainTicket = 0;
terrainSelect.addEventListener("change", () => {
setAnalysisTerrain(projectId, terrainSelect.value === "sheet" ? "sheet" : "las");
// 계산 묶음에 알림 — 노선이 그대로여도 지형이 설계와 다르면 초안으로 다시 셈(352-3).
const dirty = history?.isDirty() || pickedTerrain() !== designTerrain;
options.draft?.edited(dirty ? draftLink.draftOfSnapshot(snapshotNow(), stationSnap) : null);
const mine = ++terrainTicket;
void loadBackground().then((background) => {
if (closed || mine !== terrainTicket) return;
+25 -29
View File
@@ -8,13 +8,13 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
* `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 생김새만 있고
* 동작은 없다 — 배선은 본체와 각 조각(`_Apply`·`_Rotate`·`_History`…)이 한다.
*
* **배치**(2026-09-12 사용자 지시 ⑩~⑬·⑱) — 아래 정보행을 없애고 지도 위 오버레이로 옮겼다.
* 단추는 제목행 오른쪽, 조작 설명은 지도 왼쪽 위 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 메인 창은 왼쪽으로 밀고 오른쪽 세로 칸에 횡단 두 판이 앉는다.
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 닫기 단추는 걷었다 · [취소] → [저장 전으로] · [확인] · [노선 반영]은 없앰(346-3 — 노선은 페이지 [저장]이
* 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
* [저장]이 맨 앞에서 보냄) · 오른쪽 횡단 칸은 접는 칸.
* 머리 `tools` 자리에 부른 쪽 단추([3D])가 앉는다. [노선 추천] · 현재 추천 · [초기화]는 좌측
* 「노선 추천」 칸으로 옮겼다(345-4 · `_Panel_Recommend`).
* ========================================================================== */
@@ -57,12 +57,11 @@ export function createRouteEditChrome(
const overlay = document.createElement("div");
overlay.className = "b05-routeedit";
overlay.innerHTML = `
<div class="b05-routeedit__box" aria-label="계획노선 편집">
<div class="b05-routeedit__head">
<h3>계획노선</h3>
<span class="b05-routeedit__actions">
<div class="b05-routeedit__canvas-wrap" aria-label="계획노선 편집">
<canvas class="b05-routeedit__canvas"></canvas>
<div class="b05-route__view-controls b05-routeedit__tools">
<span data-slot="tools"></span>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<i class="b05-route__view-separator" aria-hidden="true"></i>
<label class="b05-routeedit__terrain" hidden
title="노선 편집 분석(등고선 · 지반고 · 기울기 · 횡단 미리보기 · 추천)에 쓸 지형 — 확정 지표면 · 종횡단은 안 바뀝니다">분석 지형
<select class="ui-select ui-select--sm" data-act="terrain">
@@ -70,29 +69,24 @@ export function createRouteEditChrome(
<option value="sheet">수치지형도</option>
</select>
</label>
<button type="button" class="ui-btn ui-btn--ghost" data-act="fit"
<button type="button" class="ui-btn ui-btn--glass" data-act="fit"
title="등고 영역(등고선 범위 · 노선)을 평면 가운데 알맞게 맞춥니다 (회전은 풂)">맞춤</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
<button type="button" class="ui-btn ui-btn--glass" data-act="contour-minor"
aria-pressed="false" disabled
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="measure"
<button type="button" class="ui-btn ui-btn--glass" data-act="measure"
title="노선 위 두 점을 눌러 거리·기울기를 잽니다 (Shift+클릭도 같음)">거리 재기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="station-snap"
<button type="button" class="ui-btn ui-btn--glass" data-act="station-snap"
aria-pressed="false"
title="곡선(호) 안 측점 하나를 곡선 중점(MC)으로 옮깁니다 — [저장] 때 저장">측점 스냅</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="footprint"
<button type="button" class="ui-btn ui-btn--glass" data-act="footprint"
title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 파랑)을 평면에 겹칩니다">발자국</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="undo"
title="되돌리기 (Ctrl+Z)" disabled>↶ 되돌리기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="redo"
title="다시하기 (Ctrl+Y)" disabled>↷ 다시하기</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="cancel"
title="마지막 [저장] 뒤 고친 노선을 버리고 그때 노선으로 되돌립니다 — 되돌리기로 다시 살릴 수 있습니다">저장 전으로</button>
</span>
</div>
<div class="b05-routeedit__canvas-wrap">
<canvas class="b05-routeedit__canvas"></canvas>
<i class="b05-route__view-separator" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--glass" data-act="undo"
title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>↶</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="redo"
title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>↷</button>
</div>
<div class="b05-routeedit__hint">
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__hint-toggle"
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?</button>
@@ -123,15 +117,16 @@ export function createRouteEditChrome(
<button type="button" class="b05-routeedit__info-toggle" data-act="info-toggle"
aria-expanded="true" title="상태 · 범례 글판 접기 · 펴기">▾</button>
</div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
</div>
<div class="b05-routeedit__busy" hidden><span></span></div>
<aside class="b05-routeedit__side-wrap">
<button type="button" class="ui-btn ui-btn--ghost b05-routeedit__side-toggle"
<button type="button" class="ui-btn ui-btn--glass b05-routeedit__side-toggle"
data-act="side-toggle" aria-expanded="true" title="오른쪽 횡단 미리보기 칸 접기 · 펴기">횡단</button>
<div class="b05-routeedit__side"></div>
</aside>`;
parent.append(overlay);
// 부른 쪽 단추([3D])도 같은 유리 단추로 — 지도 위에 떠 있는 도구줄.
tools.forEach((tool) => tool.classList.replace("ui-btn--ghost", "ui-btn--glass"));
overlay.querySelector('[data-slot="tools"]')!.replaceWith(...tools);
// 횡단 미리보기 = 접는 칸 — 좁은 화면은 접힌 채 시작(지도가 먼저다).
@@ -139,6 +134,7 @@ export function createRouteEditChrome(
const sideToggle = overlay.querySelector<HTMLButtonElement>('[data-act="side-toggle"]')!;
const setSide = (open: boolean): void => {
sideWrap.classList.toggle("is-collapsed", !open);
overlay.classList.toggle("is-side-open", open); // 회전 단추 · 잰 값 창이 판을 비킴
sideToggle.setAttribute("aria-expanded", String(open));
};
setSide(window.innerWidth >= 1500);
@@ -1,14 +1,14 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Draft.ts
* 노선 편집기 ↔ 계산 묶음 다리(PLAN 346-1) — 노선을 한 번 고칠 때마다(놓기 · 더함 · 지움 · 곡선 ·
* 되돌리기 · 다시하기 · [저장 전으로] · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
* 되돌리기 · 다시하기 · 추천 [적용]) 노선 초안을 넘김. 끄는 동안은 넘기지 않음
* (사진첩에 쌓이는 때 = 손을 놓은 때).
*
* 편집기는 사진첩(`_History`)을 `watch` 로 감싸기만 함 — 고친 줄 하나. 저장 안 한 초안은
* 다리가 캐시 칸(`route-draft`)에 두고, 편집기가 자료를 읽은 뒤 `restore` 로 한 번 되살림
* (새로고침 · 보기 전환에도 남음). 계산 · 가게는 `A00_Common/design/design_pipeline_page.ts` 몫.
*
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 · [저장 전으로] 뒤 사진첩에 한 걸음 ·
* 되돌리기 줄(346-4) — 페이지 사진첩(`_Profile_History`)이 있으면 쌓기 ·
* [되돌리기] · [다시하기]는 사진첩이 함(평면 · 종단 · 횡단 · 관 · 구조물 한 줄) · 사진첩이 되쓴 노선
* 초안은 `fromPage` 로 편집기 새 걸음이 됨. 사진첩이 없으면 편집기 사진 그대로.
* ========================================================================== */
@@ -99,11 +99,6 @@ export function watch(
album.redo();
return null;
},
reset: () => {
const snapshot = tell(history.reset());
if (snapshot) pageHistory()?.record();
return snapshot;
},
canUndo: () => pageHistory()?.canUndo() ?? history.canUndo(),
canRedo: () => pageHistory()?.canRedo() ?? history.canRedo(),
fromPage,
@@ -11,7 +11,7 @@
*
* 되돌리기 줄은 페이지 사진첩 하나(`_Profile_History` · 346-4) — 계산 묶음 다리가 있으면(`_Draft.watch`)
* [되돌리기] · [다시하기] · Ctrl+Z · Y 는 사진첩이 받고, 여기 사진은 고정 · 추천 꼬리표 · 고른 점을
* 되살리는 기록(`match`)과 [저장 전으로](`reset`) · 고친 것 있음(`isDirty`)에만 씀.
* 되살리는 기록(`match`) · 고친 것 있음(`isDirty`)에만 씀.
* ========================================================================== */
import { onPageHistory } from "./B05_Profile_UI_Profile_History";
@@ -44,8 +44,6 @@ export interface RouteEditHistory {
undo: () => RouteEditSnapshot | null;
/** 한 걸음 앞으로. 없으면 null. */
redo: () => RouteEditSnapshot | null;
/** 창을 연 상태로. 이미 그 상태면 null. */
reset: () => RouteEditSnapshot | null;
canUndo: () => boolean;
canRedo: () => boolean;
/** 창을 연 뒤로 고친 것이 있나 — [초기화]를 켤지 정한다. */
@@ -115,15 +113,6 @@ export function createRouteEditHistory(initial: RouteEditSnapshot): RouteEditHis
at += 1;
return clone(steps[at]);
},
reset() {
// 이미 연 상태 그대로면 할 일이 없다 — 눌러도 걸음만 늘어난다.
if (sameRoute(steps[at], steps[0])) return null;
// 초기화도 **되돌릴 수 있어야** 한다 — 첫 사진을 새 걸음으로 쌓는다.
steps.length = at + 1;
steps.push(clone(steps[0]));
at = steps.length - 1;
return clone(steps[at]);
},
canUndo: () => at > 0,
canRedo: () => at < steps.length - 1,
isDirty: () => !sameRoute(steps[at], steps[0]),
@@ -197,7 +197,7 @@ export function createCurveLabel(
lockApex.dataset.act = "lock-apex";
root.querySelector('[data-slot="lock-apex"]')!.replaceWith(lockApex);
/** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
/** 끝점에서는 숨길 곡선 칸 — 곡선 삭제 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
const curveOnly = [toggle, radiusField.wrapper, arcField.wrapper, info];
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
@@ -327,7 +327,7 @@ export function createCurveLabel(
lockApex.title = end
? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다`
: "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다";
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
toggle.textContent = state.curveOn ? "곡선 삭제" : "곡선 추가";
radius.disabled = !state.curveOn;
arc.disabled = !state.curveOn;
lockRadius.disabled = !state.curveOn;
+51 -66
View File
@@ -1,59 +1,37 @@
/* 계획노선 편집(평면) — 종단 보기 가운데 위 본문(옛 3D 자리 · 345-3). 덮개 · 전체 화면은 걷음 —
재계산 대기 막만 화면을 덮는다(노선 재탐색 · CLAUDE.md 5장). 색은 전부 테마 토큰을 쓴다. */
/* 평면 = 가운데 칸 전체(352-1) — 머리 줄 · 테두리 없음. 도구줄은 3D 와 같은 떠 있는 유리 단추
(`.b05-route__view-controls` · ☰ 비킴 `--wf-burger-pad`) · 횡단 판은 오른쪽을 덮는 판. */
.b05-routeedit {
--b05-side-open: 0px; /* 횡단 판이 펴져 지도를 덮는 폭 */
--b05-side-top: 64px; /* 접힌 [횡단] 단추가 위 오른쪽을 덮는 폭 */
position: relative;
display: flex;
flex: 1 1 auto;
gap: var(--spacing-8);
min-height: 0;
padding: var(--spacing-8);
}
/* 메인 창은 **왼쪽**, 횡단 두 판은 오른쪽 세로 칸(2026-09-12 사용자 지시 ⑱).
좁은 화면에서는 오른쪽 칸이 접히고 메인이 폭을 다 가진다. */
.b05-routeedit__box {
position: relative;
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-width: 0;
height: 100%;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
background: var(--color-surface-raised);
.b05-routeedit.is-side-open {
--b05-side-open: 460px;
--b05-side-top: 460px;
}
/* 머리 = 도구줄 — 왼쪽 이름 · 오른쪽 단추 묶음. */
.b05-routeedit__head {
display: flex;
flex: none;
align-items: center;
gap: var(--spacing-12);
padding: var(--spacing-8) var(--spacing-12);
border-bottom: 1px solid var(--color-border);
}
.b05-routeedit__head > h3 {
margin: 0;
font-size: var(--text-body);
}
/* 제목행 — 왼쪽에 이름, **오른쪽 끝에 단추 묶음**(2026-09-12 사용자 지시 ⑪).
단추는 공용 규격(`.ui-btn`)을 그대로 쓴다 — 모달이 따로 만든 크기를 걷어냈다. */
.b05-routeedit__actions {
display: flex;
flex: 1 1 auto;
/* 도구줄 — 3D 도구줄 규격 그대로 · 오른쪽 회전 단추 · 횡단 판을 비켜 줄바꿈. */
.b05-routeedit__tools {
right: calc(var(--spacing-12) + var(--b05-side-top) + 104px);
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--spacing-8);
pointer-events: none; /* 빈 자리는 지도 조작을 통과 */
}
.b05-routeedit__tools > * {
pointer-events: auto;
}
.b05-routeedit__canvas-wrap {
position: relative;
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow: hidden;
background: var(--color-surface);
@@ -86,10 +64,10 @@
z-index: 1;
}
/* 조작 설명 — 지도 왼쪽 위 · 기본 접힘 · [?] 로 펼침(345-5 덧 3) · 펼치면 **2열**(사용자 지시 ⑩). */
/* 조작 설명 — 도구줄 아래 왼쪽 · 기본 접힘 · [?] 로 펼침(345-5 덧 3 · 352-1) · 펼치면 **2열**(사용자 지시 ⑩). */
.b05-routeedit__hint {
top: var(--spacing-12);
left: var(--spacing-12);
top: calc(var(--spacing-16) + 34px + var(--spacing-8));
left: var(--spacing-16);
display: flex;
align-items: flex-start;
gap: var(--spacing-8);
@@ -173,11 +151,11 @@
color: var(--color-text-body);
}
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯). 여기만 클릭을 받는다. */
/* 회전 단추 — 지도 오른쪽 위(사용자 지시 ⑯) · 횡단 판 왼쪽에 앉음(352-1). 여기만 클릭을 받는다. */
.b05-routeedit__spin {
position: absolute;
top: var(--spacing-12);
right: var(--spacing-12);
top: var(--spacing-16);
right: calc(var(--spacing-12) + var(--b05-side-top));
z-index: 1;
display: flex;
gap: var(--spacing-8);
@@ -211,18 +189,34 @@
border-top: 2px solid var(--map-route, #f97316);
}
/* 오른쪽 세로 칸 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다. 접는 칸(345-3) —
좁은 화면은 접힌 채 시작(`_Chrome`) · [횡단] 단추로 펴고 접는다. */
/* 오른쪽 덮는 판 — 위아래 반씩 나눠 **지금 횡단**과 **이전 횡단**이 앉는다(352-1). 지도 위에 얹고
아래 종단 판(z 3)이 그 위를 덮는다. 접는 칸(345-3) — 좁은 화면은 접힌 채 시작(`_Chrome`) ·
[횡단] 단추로 펴고 접는다. 빈 자리는 지도 조작을 통과. */
.b05-routeedit__side-wrap {
position: absolute;
z-index: 2;
top: var(--spacing-16);
right: var(--spacing-12);
bottom: var(--spacing-12);
display: flex;
flex: none;
flex-direction: column;
gap: var(--spacing-8);
min-height: 0;
pointer-events: none;
}
.b05-routeedit__side-wrap.is-collapsed {
bottom: auto;
}
.b05-routeedit__side-wrap > * {
pointer-events: auto;
}
.b05-routeedit__side-toggle {
align-self: flex-end;
min-width: 56px;
height: 34px;
}
.b05-routeedit__side {
@@ -323,21 +317,20 @@
line-height: 1.5;
}
/* ㉓ 거리 재기와 되돌리기 사이 구분선. */
.b05-routeedit__divider {
width: 1px;
height: 20px;
margin: 0 var(--spacing-4, 4px);
background: var(--color-border);
}
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임. */
/* 분석 지형 고르기(PLAN 133-1) — 라이다 있는 프로젝트만 보임 · 글에 유리 바탕(352-1). */
.b05-routeedit__terrain {
display: inline-flex;
align-items: center;
gap: var(--spacing-4, 4px);
height: 34px;
padding: 0 var(--spacing-8);
border: 1px solid color-mix(in srgb, var(--color-border) 65%, transparent);
border-radius: var(--radius-8, 6px);
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
font-size: 12px;
color: var(--color-text-muted, inherit);
color: var(--color-text-body);
}
.b05-routeedit__terrain[hidden] {
@@ -347,7 +340,7 @@
/* ㉔ 잰 값 — 지도 오른쪽 아래 작은 창(뜬 종단 판이 덮으면 가려진 채 · 349-0). 닫으면 잰 것이 지워진다. */
.b05-routeedit__measure {
position: absolute;
right: var(--spacing-12);
right: calc(var(--spacing-12) + var(--b05-side-open));
bottom: var(--spacing-12);
z-index: 1;
display: flex;
@@ -461,11 +454,3 @@
color: var(--color-text-secondary);
line-height: 1.35;
}
/* 「거리 재기」가 켜진 동안 — 단추가 눌린 꼴로 남는다(2026-09-12 사용자 지시 ⑥).
`is-active` 는 예전에도 붙고 있었으나 꼴이 없어 겉모습이 그대로였다. */
.b05-routeedit__actions .ui-btn.is-active {
border-color: transparent;
background: var(--color-primary, #7c3aed);
color: #fff;
}
@@ -243,7 +243,7 @@ async function run() {
// ③ 편집기 다리 — 사진첩 감싸기 · 초안 되살림
{
const told = [];
let dirty = true;
const dirty = true;
const fake = {
commit: () => undefined,
undo: () => ({
@@ -259,15 +259,6 @@ async function run() {
picked: -1,
}),
redo: () => null,
reset: () => ({
planned: [[0, 0]],
curveOn: [true],
curveRadius: [null],
curveLock: [null],
curveArc: [null],
apexLock: [false],
picked: -1,
}),
canUndo: () => true,
canRedo: () => false,
isDirty: () => dirty,
@@ -287,8 +278,6 @@ async function run() {
});
watched.undo();
watched.redo();
dirty = false;
watched.reset();
const restored = [];
draftLink.restore(bridge, (snap, message, record) =>
restored.push([snap.planned.length, record]),
@@ -151,11 +151,6 @@ const snap = (nodes, lock = false) => ({
out.redo3 = state();
out.editorAfterRedo = editor.slice(out.editorAfterUndo.length);
out.profileReads = profileReads;
// [반영 전으로] — 사진첩 한 걸음 · 되돌리면 옮긴 노선
history.reset();
out.reset = state();
pageAlbum.undo();
out.resetUndo = state();
controls.dispose();
process.stdout.write(JSON.stringify(out));
})();
+61
View File
@@ -0,0 +1,61 @@
/* PLAN 352-3 단위검증 헬퍼 — 분석 지형 기본값(고른 적 없으면 데이터대로) · 사용자가 바꿀 때만 기억.
* 진짜 `B05_Profile_Api_Replan.ts` 를 트랜스파일해 Node 로 돌림. 화면 상태 보관소만 가짜(쓰기 횟수 셈).
* 결과 = 표준출력 JSON 한 벌. 짝 = `test_352_3_analysis_terrain_default.py`. */
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const store = new Map();
let writes = 0;
const STUBS = {
config_frontend: { API_BASE_URL: "" },
b_page_state: {
readState: (key, scope) => store.get(`${key}|${scope}`) ?? null,
writeState: (key, value, scope) => (writes++, store.set(`${key}|${scope}`, value)),
},
common_util_surface_sampler: { heightAt: () => 0 },
B05_Profile_Api_TerrainGrid: { loadDesignTerrainGrid: async () => null },
};
const cache = new Map();
function loadTs(full) {
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
const key = Object.keys(STUBS).find((name) => request.endsWith(name));
if (key) return STUBS[key];
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
const api = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_Api_Replan.ts"));
const out = {};
// ① 사정을 모를 때 — 라이다
out.unknown = api.analysisTerrain("p0");
// ② 둘 다 있고 고른 적 없음 — 데이터대로(설계 라이다 → 라이다) · 쓰기 0
api.setTerrainContext("p1", true, "las");
out.bothNoPick = api.analysisTerrain("p1");
out.writesAfterOpen = writes;
// ③ 설계가 수치지형도인 프로젝트 · 고른 적 없음 — 설계대로(고르기 글 = 실제 쓰는 지형)
api.setTerrainContext("p2", true, "sheet");
out.sheetDesignNoPick = api.analysisTerrain("p2");
// ④ 사용자가 바꿈 — 그때만 기억 · 다음 접근(사정 다시 알림)에도 남음
api.setAnalysisTerrain("p1", "sheet");
out.afterPick = api.analysisTerrain("p1");
out.writesAfterPick = writes;
api.setTerrainContext("p1", true, "las");
out.nextAccess = api.analysisTerrain("p1");
// ⑤ 하나뿐인 프로젝트 — 옛 값이 남아 있어도 있는 것만 씀
store.set("routeedit-terrain|p3", "las");
api.setTerrainContext("p3", false, "sheet");
out.sheetOnlyStale = api.analysisTerrain("p3");
store.set("routeedit-terrain|p4", "sheet");
api.setTerrainContext("p4", false, "las");
out.lasOnlyStale = api.analysisTerrain("p4");
process.stdout.write(JSON.stringify(out));
@@ -166,7 +166,7 @@ def test_화면_고르기_배선():
assert '<option value="las">라이다</option>' in chrome
assert '<option value="sheet">수치지형도</option>' in chrome
assert 'sources?.sources.includes("las") && !!sources?.sources.includes("sheet")' in main
assert 'saved === "sheet" || saved === "las" ? saved : fallback' in api
assert 'saved === "sheet" || saved === "las" ? saved : (context?.design ?? fallback)' in api
# 모달 분석 셋(종단 띠는 345-5 에서 없앰)이 고른 지형을 실어 보냄 · [확인] 재계산은 안 실음
assert api.count("terrain: analysisTerrain(projectId)") == 2
assert "terrain: analysisTerrain(projectId)" in initial_api
@@ -294,4 +294,6 @@ def test_화면_확인이_고른_지형을_실음():
# 346-3 — 노선 초안(draft.terrain)에 실림 · [저장]이 보냄
assert "terrain: params.terrain() })" in apply and "terrain: settings.terrain," in apply
assert "pickedTerrain() !== designTerrain" in main # 지형만 바꿔도 [확인]이 다시 셈
assert "analysisTerrain(projectId, designTerrain)" in main # 고른 값 없으면 설계 지형
assert (
"setTerrainContext(projectId, choosable, designTerrain)" in main
) # 고른 값 없으면 설계 지형
+1 -1
View File
@@ -173,7 +173,7 @@ def test_늦은_응답_버림(r):
def test_편집기_다리(r):
d = r["draftLink"]
assert d["told"] == [3, 2, None] # 쌓기 · 되돌리기 · 열 때로(null) — 다시하기 없음은 안 알림
assert d["told"] == [3, 2] # 쌓기 · 되돌리기 — 다시하기 없음은 안 알림
assert d["restored"] == [
[r["n"], True]
] # 저장 안 한 초안 = 새 걸음으로 되살림 · 없으면 안 부름
-5
View File
@@ -54,11 +54,6 @@ def test_다시하기는_앞으로(out) -> None:
assert out["profileReads"] == 6 # 종단도도 걸음마다 다시 읽음
def test_반영_전으로도_한_걸음(out) -> None:
assert out["reset"]["route"] is None and out["reset"]["cross"] # 노선만 열 때로
assert out["resetUndo"]["route"] == 70
def test_단축키는_사진첩_한_곳():
tools = (ROOT / "B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts").read_text(encoding="utf-8")
assert "undoOwner" not in tools
@@ -0,0 +1,54 @@
"""352-3 분석 지형 기본값 — 고른 적 없으면 데이터대로 · 열 때 안 씀 · 사용자가 바꿀 때만 기억 · 바꾸면 묶음에 알림.
./venv/Scripts/python.exe -m pytest resources/tester/test_352_3_analysis_terrain_default.py -q
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
HERE = Path(__file__).resolve().parent
ROOT = HERE.parents[1]
@pytest.fixture(scope="module")
def out() -> dict:
run = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_352_3_terrain.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
)
assert run.returncode == 0, run.stderr
return json.loads(run.stdout)
def test_고른_적_없으면_데이터대로_열_때_안_씀(out) -> None:
assert out["unknown"] == "las"
assert out["bothNoPick"] == "las" # 라이다가 있는 프로젝트 = 라이다
assert out["writesAfterOpen"] == 0 # 열 때 pref 에 쓰지 않음
assert out["sheetDesignNoPick"] == "sheet" # 설계 지형대로 — 고르기 글 = 실제 쓰는 지형
def test_사용자가_바꿀_때만_기억(out) -> None:
assert out["afterPick"] == "sheet" and out["writesAfterPick"] == 1
assert out["nextAccess"] == "sheet" # 다음 접근에도
def test_하나뿐이면_있는_것만(out) -> None:
assert out["sheetOnlyStale"] == "sheet"
assert out["lasOnlyStale"] == "las"
def test_편집기_배선() -> None:
main = (ROOT / "B05_Profile/B05_Profile_UI_RouteEdit.ts").read_text(encoding="utf-8")
assert main.count("setAnalysisTerrain(") == 1 # 바꿀 때 한 곳뿐 — 열 때 쓰기 없음
handler = main.split('terrainSelect.addEventListener("change"', 1)[1][:900]
assert "setAnalysisTerrain(projectId" in handler
assert "options.draft?.edited(" in handler # 바꾸면 계산 묶음에 알림
# 설계 지형이 수치지형도면 확정 모델(수치지형도 서피스) 등고선을 「LAS」로 쓰지 않음
assert 'analysisTerrain(projectId) === "las" && designTerrain === "las"' in main
@@ -1,7 +1,7 @@
"""계획노선 편집 겉틀 — 345-3 부터 모달이 아니라 종단 보기 가운데 위 본문(PLAN 127-1 의 공용 모달 조각은 걷음).
· 덮개(`ui-modal__backdrop`) · 전체 화면 · 닫기 단추 없음 · 부모를 받아 붙음(`document.body` 아님)
· [취소] → [반영 전으로](이력 처음으로) · [확인] → [노선 반영] · 머리 끝 `tools` 자리([3D])
· [저장 전으로] 없음(352-2) · ↶ ↷ 는 기호 단추 · 머리 끝 `tools` 자리([3D])
· 오른쪽 횡단 미리보기 = 접는 칸 · 재계산 대기 막은 화면 전체(fixed) · R 라벨은 칸 안 절대 위치
· 바깥 누름 · Esc 로 안 닫힘(모달 아님)
"""
@@ -33,13 +33,16 @@ def test_겉틀은_본문_칸():
assert 'overlay.className = "b05-routeedit";' in chrome
assert "b05-routeedit__close" not in chrome and 'data-slot="close"' not in chrome
assert '"tools"' in chrome and "replaceWith(...tools)" in chrome
# 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · [취소] = [저장 전으로]
assert ">저장 전으로</button>" in chrome and 'data-act="apply"' not in chrome
assert "노선 반영</button>" not in chrome
# 346-3 — [노선 반영] 없앰(노선은 페이지 [저장]이 맨 앞에서 보냄) · 352-2 — [저장 전으로] 없앰
assert 'data-act="apply"' not in chrome and 'data-act="cancel"' not in chrome
assert ">저장 전으로</button>" not in chrome and "노선 반영</button>" not in chrome
assert ">취소</button>" not in chrome and ">확인</button>" not in chrome
# 352-2 — 되돌리기 · 다시하기 = 글 없이 기호 + aria-label · title(단축키)
assert 'aria-label="되돌리기" disabled>↶</button>' in chrome
assert 'aria-label="다시하기" disabled>↷</button>' in chrome
assert 'title="되돌리기 (Ctrl+Z)"' in chrome and 'title="다시하기 (Ctrl+Y)"' in chrome
# 시험 · 배선이 집는 자리는 남김
for hook in ('data-act="cancel"', "b05-routeedit__measure-close"):
assert hook in chrome
assert "b05-routeedit__measure-close" in chrome
assert 'data-act="side-toggle"' in chrome and "is-collapsed" in chrome
@@ -58,6 +61,36 @@ def test_CSS_덮개_걷음_대기막은_전체():
assert "@media (width < 1500px)" not in css # 접기는 단추로(처음만 폭으로)
def test_352_1_머리줄_상자_없음_도구는_뜬_단추_횡단은_우측_덮개():
chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
for gone in ("b05-routeedit__box", "b05-routeedit__head", "b05-routeedit__actions", "<h3>"):
assert gone not in chrome
# 도구줄 = 3D 와 같은 떠 있는 유리 단추 · 구분선도 3D 것
assert 'class="b05-route__view-controls b05-routeedit__tools"' in chrome
assert "ui-btn--ghost" not in chrome.split("<aside", 1)[0]
# 도구줄은 지도 칸 안 · [?] 는 도구줄 뒤
wrap = chrome.index("b05-routeedit__canvas-wrap")
assert wrap < chrome.index("b05-routeedit__tools") < chrome.index("b05-routeedit__hint")
assert chrome.index("b05-routeedit__side-wrap") > chrome.index("b05-routeedit__busy")
css = _read("B05_Profile_UI_Style_RouteEdit.css")
for gone in (".b05-routeedit__box", ".b05-routeedit__head", ".b05-routeedit__actions"):
assert gone not in css
root = _block(css, ".b05-routeedit")
assert "padding" not in root and "gap" not in root
side = _block(css, ".b05-routeedit__side-wrap")
assert "position: absolute;" in side and "right: var(--spacing-12);" in side
z = int(re.search(r"z-index: (\d+);", side).group(1))
assert z < 3 # 아래 종단 판(z 3)이 위
style = _read("B05_Profile_UI_Style.css")
assert "z-index: 3;" in _block(style, ".b05-route-profile")
# 회전 단추 · 잰 값 창은 펴진 횡단 판을 비킴
assert "var(--b05-side-top)" in _block(css, ".b05-routeedit__spin")
assert "var(--b05-side-open)" in _block(css, ".b05-routeedit__measure")
assert "is-side-open" in chrome
glass = _block(css, ".b05-routeedit__terrain")
assert "backdrop-filter: blur(6px);" in glass and "background:" in glass
def test_페이지_수명_닫기_없음():
main = _read("B05_Profile_UI_RouteEdit.ts")
assert "openModal" not in main and "Escape" not in main
@@ -67,10 +100,13 @@ def test_페이지_수명_닫기_없음():
assert "new ResizeObserver(" in main
assert "host.onDispose(dispose);" in main
assert "historyControls.listen(host.visible());" in main
assert "history?.reset();" in main # [반영 전으로]
assert "history?.reset()" not in main # 352-2 — [저장 전으로] 지움
hist = _read("B05_Profile_UI_RouteEdit_History.ts")
assert "reset" not in hist.split("export interface RouteEditHistory", 1)[1].split("}", 1)[0]
assert len(main.splitlines()) <= 700
label = _read("B05_Profile_UI_RouteEdit_Label.ts")
assert "document.body.append" not in label and "container.append(root);" in label
assert '"곡선 삭제" : "곡선 추가"' in label # 352-5 — 버튼 이름은 명사형
assert "container: overlay," in main
apply = _read("B05_Profile_UI_RouteEdit_Apply.ts")
assert "close" not in apply.split("export function bindRouteApply", 1)[1]
@@ -210,9 +210,9 @@ def test_노선_추천_다듬기_23_3():
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
# 345-4 — [노선 추천]은 좌측 칸 · 머리 구분선은 [3D](tools) 뒤 · 되돌리기 앞 둘 그대로
tools = chrome.index('data-slot="tools"')
divider = chrome.index("b05-routeedit__divider", tools)
divider = chrome.index("b05-route__view-separator", tools) # 352-1 — 3D 도구줄 구분선 그대로
assert tools < divider < chrome.index('data-act="contour-minor"')
assert chrome.count("b05-routeedit__divider") == 2
assert chrome.count("b05-route__view-separator") == 2
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared
+21
View File
@@ -6,6 +6,8 @@
③ 휴대폰 · 공개 쪽(A01) = 위 줄 그대로(본문 위 끝 64) · ☰ 는 위 줄 오른쪽
④ 데스크탑(1440 · 마우스) · 로그인 뒤 B 쪽 = 위 줄 걷힘 · ☰ 왼쪽 위 36×36 · 진행 판 없음 · 위 줄 항목이 모두 서랍에 · Esc 로 닫힘
⑤ 데스크탑 · 공개 쪽(A01) = 위 줄 그대로 · ☰ 없음
⑥ (352-4) 좌측 패널이 펼쳐진 동안 ☰ 는 제목 줄 앞 칸에 든 모양(테두리 · 바탕 · 그림자 없음 · 자리 8,8 그대로)
· 접으면 떠 있는 단추 · 서랍 첫 줄 로고 우측 · 프로젝트 이름 우측 끝 — 글로 봄(서버 없이)
"""
import json
@@ -131,3 +133,22 @@ def test_drawer_holds_steps_and_header_folds():
assert desk.evaluate(OUTLET_TOP) == 64
assert not desk.evaluate("document.querySelector('.app-burger').getClientRects().length")
browser.close()
def test_352_4_burger_in_panel_title_row_text():
root = Path(__file__).resolve().parents[2]
shell = (root / "A00_Common/app_shell.ts").read_text(encoding="utf-8")
open_panel = (
"html.is-bm-route:has(.ui-workflow-overlay__panel--title:not(.is-collapsed)) .app-burger {"
)
assert open_panel in shell # 패널 펼침 = 제목 줄에 든 모양
body = shell.split(open_panel, 1)[1].split("}", 1)[0]
for line in ("border-color: transparent;", "background: transparent;", "box-shadow: none;"):
assert line in body
# 자리(8,8 · 36)는 그대로 — 접으면 아래 기본 규칙(떠 있는 단추)으로 돌아감
base = shell.split("html.is-bm-route .app-burger {", 1)[1].split("}", 1)[0]
assert "position: fixed;" in base and "left: var(--spacing-8);" in base
assert "html.is-bm-route .app-drawer__brand .app-brand { align-self: flex-end; }" in shell
overlay = (root / "ui_template/ui_template_overlay.css").read_text(encoding="utf-8")
project = overlay.split(".ui-workflow-overlay__project {", 1)[1].split("}", 1)[0]
assert "margin-left: auto;" in project
+1
View File
@@ -131,6 +131,7 @@
.ui-workflow-overlay__project {
flex: 0 1 auto;
margin-left: auto;
max-width: 60%;
min-width: 0;
overflow: hidden;