Merge remote-tracking branches 'origin/sub_laptop_5', 'origin/sub_laptop_6' and 'origin/sub_laptop_7' into sub_laptop_3
This commit is contained in:
@@ -62,6 +62,8 @@ export const STATE_REGISTRY = {
|
||||
"m02-combo-panel-width": { bucket: "pref", scope: "global" },
|
||||
/** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */
|
||||
"routeedit-view": { bucket: "pref", scope: "project" },
|
||||
/** B05 계획노선 편집 창 — 시점 · 종점 · 교각점 고정. [저장] 대상이 아니라 draft 에 안 둠 */
|
||||
"routeedit-locks": { bucket: "pref", scope: "project" },
|
||||
"masshaul-open": {
|
||||
bucket: "pref",
|
||||
scope: "global",
|
||||
|
||||
@@ -52,6 +52,12 @@ import {
|
||||
viewPxPerMeter,
|
||||
} from "./B05_Profile_UI_RouteEdit_View";
|
||||
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
|
||||
import {
|
||||
lockColumn,
|
||||
readRouteLocks,
|
||||
routeLocksOf,
|
||||
writeRouteLocks,
|
||||
} from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
|
||||
import {
|
||||
applyArcLocks,
|
||||
@@ -118,8 +124,9 @@ export async function openRouteEditModal(
|
||||
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
|
||||
let curveLock: CurveLock[] = [];
|
||||
let curveArc: Array<number | null> = [];
|
||||
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
|
||||
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
|
||||
/** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점
|
||||
* (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다.
|
||||
* 고정 한 벌의 저장 · 읽기는 `_Lock` 몫. */
|
||||
let apexLock: boolean[] = [];
|
||||
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
|
||||
let picked = -1;
|
||||
@@ -275,6 +282,7 @@ export async function openRouteEditModal(
|
||||
nodeInfo,
|
||||
curveInfo,
|
||||
curveOn,
|
||||
locked: apexLock,
|
||||
picked,
|
||||
stationIntervalM,
|
||||
});
|
||||
@@ -322,7 +330,7 @@ export async function openRouteEditModal(
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
apexLock,
|
||||
apexLock: apexLock.slice(1, -1), // 시점 · 종점은 기본 고정이라 「고정 N곳」에 안 센다.
|
||||
curveCount: curveInfo.length,
|
||||
violationCount: nodeInfo.filter((node) => node.violations.length).length,
|
||||
minRadiusM,
|
||||
@@ -359,6 +367,7 @@ export async function openRouteEditModal(
|
||||
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
|
||||
function applyEdit(message: string, record = true): void {
|
||||
markEdited();
|
||||
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
|
||||
syncCurveBar();
|
||||
status.textContent = `${routeHead()} — ${message} ${curveHint()}`;
|
||||
draw();
|
||||
@@ -368,19 +377,34 @@ export async function openRouteEditModal(
|
||||
|
||||
/** 지금 편집값을 사진 한 벌로 담는다 — 되돌리기가 쌓아 두는 것. */
|
||||
function snapshotNow(): RouteEditSnapshot {
|
||||
return { planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked };
|
||||
return {
|
||||
planned,
|
||||
curveOn,
|
||||
curveRadius,
|
||||
curveLock,
|
||||
curveArc,
|
||||
apexLock,
|
||||
picked,
|
||||
rec: initial.tag(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
|
||||
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
|
||||
// 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로.
|
||||
const locks = record ? routeLocksOf(planned, apexLock) : null;
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
|
||||
if (locks) apexLock = lockColumn(planned, locks);
|
||||
applyEdit(message, record);
|
||||
}
|
||||
|
||||
const historyControls = bindHistoryControls({
|
||||
overlay,
|
||||
getHistory: () => history,
|
||||
restore: (snapshot, message) => restoreSnapshot(snapshot, message, false),
|
||||
restore: (snapshot, message) => {
|
||||
initial.restoreTag(snapshot.rec); // 「현재 추천」도 그 걸음 것으로 — 노선과 짝.
|
||||
restoreSnapshot(snapshot, message, false);
|
||||
},
|
||||
});
|
||||
|
||||
// ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ──
|
||||
@@ -443,6 +467,8 @@ export async function openRouteEditModal(
|
||||
},
|
||||
remove: (index) => {
|
||||
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
|
||||
if (apexLock[index])
|
||||
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
|
||||
spliceNode(snapshotNow(), index);
|
||||
picked = -1;
|
||||
applyEdit("노드를 지웠습니다(직선 삭제).");
|
||||
@@ -490,13 +516,14 @@ export async function openRouteEditModal(
|
||||
limitArcM = plan.limit_curve_length_m ?? 0;
|
||||
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
|
||||
const flat = flattenServerPlan(nodes, plan.curves ?? []);
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
|
||||
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
|
||||
apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다.
|
||||
({ nodes: nodeInfo, curves: curveInfo } = flat);
|
||||
picked = -1;
|
||||
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
|
||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||
history = createRouteEditHistory(snapshotNow());
|
||||
historyControls.sync();
|
||||
initial.ready(snapshotNow(), plan.edited, plan.current_initial ?? null);
|
||||
syncCurveBar();
|
||||
if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]);
|
||||
meta = drainage.meta;
|
||||
|
||||
@@ -100,11 +100,12 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
);
|
||||
},
|
||||
onApexLock: (locked) => {
|
||||
const { picked, apexLock } = params.state();
|
||||
const { picked, apexLock, planned } = params.state();
|
||||
if (picked < 0) return;
|
||||
apexLock[picked] = locked;
|
||||
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
|
||||
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
|
||||
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
|
||||
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
|
||||
},
|
||||
onCurveOn: (on) => {
|
||||
const { picked, curveOn } = params.state();
|
||||
@@ -114,7 +115,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
},
|
||||
});
|
||||
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
|
||||
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
|
||||
function sync(): void {
|
||||
const {
|
||||
picked,
|
||||
@@ -128,16 +129,43 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
limitRadiusM,
|
||||
limitArcM,
|
||||
} = params.state();
|
||||
if (!(picked > 0 && picked < planned.length - 1)) {
|
||||
if (!(picked >= 0 && picked < planned.length)) {
|
||||
label.hide();
|
||||
return;
|
||||
}
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
const bounds = {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
};
|
||||
const last = planned.length - 1;
|
||||
if (picked === 0 || picked === last) {
|
||||
const [ex, ey] = params.toScreen(planned[picked]);
|
||||
const [nx, ny] = params.toScreen(planned[picked === 0 ? 1 : last - 1]);
|
||||
label.show({
|
||||
seat: picked,
|
||||
endpoint: picked === 0 ? "start" : "end",
|
||||
at: [ex + rect.left, ey + rect.top],
|
||||
bounds,
|
||||
// 노선에 직각으로 비켜 붙인다 — 노선 바깥쪽에는 시점 · 종점 이름표가 있다.
|
||||
centerDirection: [-(ny - ey), nx - ex],
|
||||
curveOn: false,
|
||||
radiusShown: null,
|
||||
arcLengthShown: null,
|
||||
lock: null,
|
||||
apexLocked: apexLock[picked] === true,
|
||||
innerAngleDeg: null,
|
||||
curveOptional: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
|
||||
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
|
||||
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
|
||||
const optional = curveOptionalAt(innerAngle);
|
||||
const deflection = deflectionRad(innerAngle);
|
||||
const rect = params.canvas.getBoundingClientRect();
|
||||
const [screenX, screenY] = params.toScreen(planned[picked]);
|
||||
label.show({
|
||||
seat: picked,
|
||||
@@ -145,12 +173,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
|
||||
at: [screenX + rect.left, screenY + rect.top],
|
||||
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
|
||||
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
|
||||
bounds: {
|
||||
left: rect.left + 8,
|
||||
top: rect.top + 8,
|
||||
right: rect.right - 8,
|
||||
bottom: rect.bottom - 8,
|
||||
},
|
||||
bounds,
|
||||
centerDirection: pickedCurve
|
||||
? centerDirectionOf(
|
||||
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface RouteEditSnapshot {
|
||||
/** 교각점(꺾임점) 자리를 못 박았나 — 켜진 자리는 끌어도 안 움직인다(2026-09-12 지시 ①). */
|
||||
apexLock: boolean[];
|
||||
picked: number;
|
||||
/** 이 걸음의 「현재 추천」 — `_Initial` 이 적고 되돌릴 때 되살린다(뜻은 그쪽만 안다). 노선과 짝. */
|
||||
rec?: unknown;
|
||||
}
|
||||
|
||||
/** 쌓아 둘 사진 수 상한 — 넘으면 오래된 것부터 버린다. */
|
||||
@@ -54,6 +56,7 @@ function clone(snapshot: RouteEditSnapshot): RouteEditSnapshot {
|
||||
curveArc: [...snapshot.curveArc],
|
||||
apexLock: [...snapshot.apexLock],
|
||||
picked: snapshot.picked,
|
||||
rec: snapshot.rec,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,9 @@ export interface InitialPanel {
|
||||
weights: RouteInitialWeights | null;
|
||||
criteria: RouteInitialCriteria | null;
|
||||
};
|
||||
/** 지금 추천 — 편집 걸음(사진)에 실어 둔다. 되돌리기 · 다시하기가 `restoreTag` 로 되살린다. */
|
||||
tag: () => InitialChoice | null;
|
||||
restoreTag: (tag: unknown) => void;
|
||||
/** [취소] — 이 창에서 바꾼 추천을 창을 열 때 것으로 되돌린다. */
|
||||
cancel: () => void;
|
||||
/** [확인] 뒤 결과 캐시를 비웠다 — 추천은 새 노선의 출발점이라 다시 적는다. */
|
||||
@@ -199,6 +202,13 @@ export function createInitialPanel(params: InitialPanelParams): InitialPanel {
|
||||
const criteria = choice ? choice.criteriaOverrides : base.criteria;
|
||||
return { mode: now.logic, weights: now.weights, criteria };
|
||||
},
|
||||
tag: () => choice,
|
||||
restoreTag(tag) {
|
||||
if (tag === undefined) return; // 추천을 안 실은 옛 걸음 — 그대로 둔다.
|
||||
choice = tag as InitialChoice | null;
|
||||
writeInitialChoice(projectId, choice);
|
||||
render();
|
||||
},
|
||||
cancel: () => writeInitialChoice(projectId, openingChoice),
|
||||
keep: () => writeInitialChoice(projectId, choice),
|
||||
};
|
||||
|
||||
@@ -57,6 +57,8 @@ const COMPASS_STEPS = 16;
|
||||
export interface CurveLabelState {
|
||||
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
|
||||
seat: number;
|
||||
/** 시점 · 종점을 골랐나 — 곡선 칸을 숨기고 **고정 단추만** 낸다(계획서 22-5). */
|
||||
endpoint?: "start" | "end" | null;
|
||||
/** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */
|
||||
at: [number, number];
|
||||
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
|
||||
@@ -135,9 +137,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
|
||||
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
|
||||
</label>
|
||||
<div class="b05-routeedit__curve-field">교각점
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
|
||||
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다">고정</button>
|
||||
<div class="b05-routeedit__curve-field"><span class="b05-routeedit__lock-name">교각점</span>
|
||||
<button type="button" class="b05-routeedit__lock" data-act="lock-apex">고정</button>
|
||||
</div>
|
||||
<span class="b05-routeedit__curve-info"></span>`;
|
||||
document.body.append(root);
|
||||
@@ -151,6 +152,9 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
|
||||
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
|
||||
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
|
||||
const lockName = root.querySelector<HTMLElement>(".b05-routeedit__lock-name")!;
|
||||
/** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
|
||||
const curveOnly = [toggle, radius.closest("label")!, arc.closest("label")!, info];
|
||||
|
||||
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
|
||||
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
|
||||
@@ -280,7 +284,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
|
||||
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
|
||||
arc.min = limitArc > 0 ? String(limitArc) : "1";
|
||||
root.hidden = false;
|
||||
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
|
||||
const end = state.endpoint ?? null;
|
||||
const name = end === "start" ? "시점" : end === "end" ? "종점" : "교각점";
|
||||
curveOnly.forEach((element) => (element.hidden = end !== null));
|
||||
seatText.textContent = end ? name : `${state.seat + 1}번째 꺾임점`;
|
||||
lockName.textContent = name;
|
||||
lockApex.title = end
|
||||
? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다`
|
||||
: "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다";
|
||||
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
|
||||
radius.disabled = !state.curveOn;
|
||||
arc.disabled = !state.curveOn;
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/* =============================================================================
|
||||
* B05_Profile_UI_RouteEdit_Lock.ts
|
||||
* 계획노선 편집 창의 **고정 한 벌** — 시점 · 종점과 교각점 자리(계획서 22-4 · 22-5).
|
||||
*
|
||||
* 모달은 노드마다 한 칸씩 `apexLock` 을 쥔다 — 0번 = 시점, 끝 칸 = 종점, 사이 = 교각점.
|
||||
* 시점 · 종점은 **기본 고정**이다. 켜진 자리는 고르기만 되고 안 끌린다(`_Pointer`).
|
||||
*
|
||||
* 창을 닫아도([취소] · [확인] 포함) 고정은 남는다 — 화면 상태 보관소의 pref 통(프로젝트 칸)에
|
||||
* 바로 적고, 다시 열 때 서버 노드에 **좌표로** 다시 짚는다(노드 번호는 편집마다 밀린다).
|
||||
* draft 통에 두지 않는다 — [저장] 대상이 아닌데 미저장 표시가 켜지고 [저장] 뒤 지워진다.
|
||||
* 초기노선 계산(POST /route/initial)에 넘길 때도 `readRouteLocks` 한 곳만 읽는다.
|
||||
* DOM 을 안 만진다 — 시험이 Node 로 돈다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { readState, writeState } from "../A00_Common/b_page_state";
|
||||
|
||||
type Vertex = [number, number];
|
||||
|
||||
/** 고정 한 벌. `apex` 는 고정한 교각점 좌표(사업지 좌표계 m). */
|
||||
export interface RouteLocks {
|
||||
start: boolean;
|
||||
end: boolean;
|
||||
apex: Vertex[];
|
||||
}
|
||||
|
||||
/** 같은 교각점으로 볼 거리(m) — 서버가 되돌려 준 좌표의 끝자리 어긋남만 흡수한다. */
|
||||
const SAME_POINT_M = 0.05;
|
||||
|
||||
const isVertex = (value: unknown): value is Vertex =>
|
||||
Array.isArray(value) && value.length >= 2 && value.every((n) => Number.isFinite(n));
|
||||
|
||||
/** 저장된 고정 — 없거나 깨졌으면 시점 · 종점만 고정. */
|
||||
export function readRouteLocks(projectId: string): RouteLocks {
|
||||
const saved = readState<Partial<RouteLocks>>("routeedit-locks", projectId);
|
||||
return {
|
||||
start: saved?.start !== false,
|
||||
end: saved?.end !== false,
|
||||
apex: Array.isArray(saved?.apex) ? saved.apex.filter(isVertex) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** 노드 칸 → 고정 한 벌. */
|
||||
export function routeLocksOf(
|
||||
planned: ReadonlyArray<Vertex>,
|
||||
locked: ReadonlyArray<boolean>,
|
||||
): RouteLocks {
|
||||
const last = planned.length - 1;
|
||||
return {
|
||||
start: locked[0] === true,
|
||||
end: locked[last] === true,
|
||||
apex: planned
|
||||
.filter((_, index) => index > 0 && index < last && locked[index] === true)
|
||||
.map(([x, y]) => [x, y]),
|
||||
};
|
||||
}
|
||||
|
||||
export function writeRouteLocks(
|
||||
projectId: string,
|
||||
planned: ReadonlyArray<Vertex>,
|
||||
locked: ReadonlyArray<boolean>,
|
||||
): void {
|
||||
if (planned.length < 2) return; // 노선을 못 읽은 채 닫으면 적어 둔 고정을 지우지 않는다.
|
||||
writeState("routeedit-locks", routeLocksOf(planned, locked), projectId);
|
||||
}
|
||||
|
||||
/** 고정 한 벌 → 노드 칸. 교각점은 좌표가 가까운 자리만 켠다. */
|
||||
export function lockColumn(planned: ReadonlyArray<Vertex>, locks: RouteLocks): boolean[] {
|
||||
const last = planned.length - 1;
|
||||
return planned.map(([x, y], index) =>
|
||||
index === 0
|
||||
? locks.start
|
||||
: index === last
|
||||
? locks.end
|
||||
: locks.apex.some(([ax, ay]) => Math.hypot(ax - x, ay - y) <= SAME_POINT_M),
|
||||
);
|
||||
}
|
||||
|
||||
/** 곧은 선(노드 `index` → `index + 1`)은 **양 끝이 모두** 고정일 때 고정. */
|
||||
export const straightLocked = (locked: ReadonlyArray<boolean>, index: number): boolean =>
|
||||
locked[index] === true && locked[index + 1] === true;
|
||||
|
||||
/** 호(교각점 `index`)는 **그 교각점과 앞뒤 점이 모두** 고정일 때 고정. */
|
||||
export const arcLocked = (locked: ReadonlyArray<boolean>, index: number): boolean =>
|
||||
locked[index - 1] === true && locked[index] === true && locked[index + 1] === true;
|
||||
@@ -17,10 +17,12 @@ import {
|
||||
type Vertex,
|
||||
} from "./B05_Profile_UI_RouteEdit_Curve";
|
||||
import {
|
||||
arcAtScreen,
|
||||
handleAtScreen,
|
||||
nodeAtScreen,
|
||||
segmentAtScreen,
|
||||
stationAtScreen,
|
||||
stationLabelBoxes,
|
||||
} from "./B05_Profile_UI_RouteEdit_Input";
|
||||
import {
|
||||
curveShortfalls,
|
||||
@@ -186,6 +188,7 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
px,
|
||||
py,
|
||||
STATION_HIT_PX,
|
||||
stationLabelBoxes(canvas),
|
||||
);
|
||||
if (chainage === null) return false;
|
||||
void params.crossPreview.open(chainage);
|
||||
@@ -193,6 +196,19 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
|
||||
})()
|
||||
) {
|
||||
/* 횡단 창이 떴다 — 더 집지 않는다. */
|
||||
} else if (
|
||||
// 호(곡선 부분)를 누르면 그 곡선을 고른다 — 교각점을 누른 것과 같다(끌기만 없다). 점·눈금 다음.
|
||||
((): boolean => {
|
||||
const node = arcAtScreen(state.curveInfo, toScreen, px, py);
|
||||
if (node < 0) return false;
|
||||
params.setPicked(node);
|
||||
params.measure.clear();
|
||||
params.syncCurveBar();
|
||||
params.draw();
|
||||
return true;
|
||||
})()
|
||||
) {
|
||||
/* 곡선을 골랐다 — 등고선은 안 집는다. */
|
||||
} else if (state.contours) {
|
||||
// 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦).
|
||||
// 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다.
|
||||
|
||||
@@ -30,9 +30,12 @@ import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
|
||||
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
|
||||
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
|
||||
import { stationLabel as formatStation } from "../common_util/common_util_station";
|
||||
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
|
||||
|
||||
/** 노드 반지름(px). */
|
||||
const NODE_R = 4;
|
||||
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */
|
||||
const LOCKED_WIDTH = 3;
|
||||
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
|
||||
*
|
||||
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
|
||||
@@ -85,6 +88,8 @@ export interface RouteEditScene {
|
||||
nodeInfo: ReadonlyArray<EditedNode>;
|
||||
curveInfo: ReadonlyArray<EditedCurve>;
|
||||
curveOn: ReadonlyArray<boolean>;
|
||||
/** 자리를 못 박은 노드 — 0번 = 시점, 끝 칸 = 종점(계획서 22-4 · 22-5). */
|
||||
locked: ReadonlyArray<boolean>;
|
||||
/** 지금 고른 꺾임점. 없으면 -1. */
|
||||
picked: number;
|
||||
/** 규칙 측점 간격(m). */
|
||||
@@ -206,6 +211,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
style.getPropertyValue("--map-route") || "#f97316",
|
||||
2.4,
|
||||
);
|
||||
const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777";
|
||||
strokeLockedRoute(context, scene, line, lockedColor);
|
||||
|
||||
context.save();
|
||||
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
|
||||
@@ -215,9 +222,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
|
||||
const [x, y] = toScreen(vertex);
|
||||
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
|
||||
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
|
||||
// 고정한 점은 고정 색 — 기준 미달(붉음)이 먼저다.
|
||||
context.fillStyle = bad
|
||||
? style.getPropertyValue("--color-danger") || "#dc2626"
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
: scene.locked[index]
|
||||
? lockedColor
|
||||
: style.getPropertyValue("--map-route") || "#f97316";
|
||||
context.beginPath();
|
||||
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
|
||||
context.fill();
|
||||
@@ -511,6 +521,58 @@ function endLabel(
|
||||
context.restore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 고정한 선 · 호를 고정 색으로 덧그린다(계획서 22-4). 곧은 선은 양 끝 노드가, 호는 그 교각점과
|
||||
* 앞뒤 노드가 모두 고정일 때다. 곧은 선은 앞뒤 곡선의 접선점 사이, 호는 그려진 노선에서
|
||||
* 접선점 사이 정점을 잘라 쓴다 — 노드를 끄는 동안에도 네트워크 없이 따라간다.
|
||||
*/
|
||||
function strokeLockedRoute(
|
||||
context: CanvasRenderingContext2D,
|
||||
scene: RouteEditScene,
|
||||
line: ReadonlyArray<Vertex>,
|
||||
color: string,
|
||||
): void {
|
||||
const { planned, locked } = scene;
|
||||
if (!locked.some(Boolean)) return;
|
||||
const curveAt = new Map(
|
||||
scene.curveInfo
|
||||
.filter((curve) => scene.curveOn[curve.node_first] !== false)
|
||||
.map((curve) => [curve.node_first, curve]),
|
||||
);
|
||||
const pieces: Vertex[][] = [];
|
||||
for (let index = 0; index + 1 < planned.length; index += 1) {
|
||||
if (!straightLocked(locked, index)) continue;
|
||||
pieces.push([
|
||||
curveAt.get(index)?.end ?? planned[index],
|
||||
curveAt.get(index + 1)?.start ?? planned[index + 1],
|
||||
]);
|
||||
}
|
||||
curveAt.forEach((curve, index) => {
|
||||
if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end));
|
||||
});
|
||||
pieces.forEach((piece) =>
|
||||
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH),
|
||||
);
|
||||
}
|
||||
|
||||
/** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */
|
||||
function sliceBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
|
||||
const nearest = (point: Vertex): number => {
|
||||
let best = 0;
|
||||
line.forEach((vertex, index) => {
|
||||
if (
|
||||
Math.hypot(vertex[0] - point[0], vertex[1] - point[1]) <
|
||||
Math.hypot(line[best][0] - point[0], line[best][1] - point[1])
|
||||
) {
|
||||
best = index;
|
||||
}
|
||||
});
|
||||
return best;
|
||||
};
|
||||
const [low, high] = [nearest(from), nearest(to)].sort((a, b) => a - b);
|
||||
return line.slice(low, high + 1);
|
||||
}
|
||||
|
||||
function strokePolyline(
|
||||
context: CanvasRenderingContext2D,
|
||||
toScreen: (vertex: Vertex) => [number, number],
|
||||
|
||||
@@ -328,6 +328,11 @@
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
/* 시점 · 종점을 고르면 곡선 칸을 숨긴다 — 줄마다 `display: flex` 라 `hidden` 이 안 먹는다. */
|
||||
.b05-routeedit__label [hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */
|
||||
.b05-routeedit__label-head {
|
||||
display: flex;
|
||||
|
||||
@@ -238,6 +238,23 @@ const mapLines = {
|
||||
oneDot: model.mapPaths([{ kind: "preview", points: [[5, 5]] }], 460, 340),
|
||||
};
|
||||
|
||||
// ⑦ 되돌리기 · 다시하기가 걸음마다 실은 「현재 추천」을 되살린다(노선과 짝).
|
||||
const history = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_History.ts"));
|
||||
const step = (x, rec) => ({
|
||||
planned: [[x, 0], [x + 1, 1]],
|
||||
curveOn: [true, true],
|
||||
curveRadius: [null, null],
|
||||
curveLock: [null, null],
|
||||
curveArc: [null, null],
|
||||
apexLock: [false, false],
|
||||
picked: -1,
|
||||
rec,
|
||||
});
|
||||
const trail = history.createRouteEditHistory(step(0, { logic: "follow" }));
|
||||
trail.commit(step(1, { logic: "earthwork" }));
|
||||
trail.commit(step(2, null));
|
||||
const historyRecs = [trail.undo(), trail.undo(), trail.redo(), trail.redo()].map((one) => one.rec);
|
||||
|
||||
// ④ 캐시 왕복 — 결과 통 프로젝트 칸. 꼴이 틀린 값은 안 읽는다.
|
||||
model.writeInitialChoice("p-1", plain);
|
||||
const readBack = model.readInitialChoice("p-1");
|
||||
@@ -278,5 +295,6 @@ process.stdout.write(
|
||||
sameAsServer,
|
||||
initialRoute,
|
||||
mapLines,
|
||||
historyRecs,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/* 계획노선 편집 창 **고정 한 벌**(계획서 22-4 · 22-5) 단위검증 헬퍼 —
|
||||
* `B05_Profile_UI_RouteEdit_Lock.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다.
|
||||
*
|
||||
* ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다.
|
||||
* 브라우저 전용인 저장소·팔레트는 가짜로 채운다 — 셈만 본다.
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
|
||||
|
||||
const ROOT = path.join(__dirname, "..", "..");
|
||||
const cache = new Map();
|
||||
|
||||
// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리.
|
||||
function memoryStorage() {
|
||||
const map = new Map();
|
||||
return {
|
||||
getItem: (key) => (map.has(key) ? map.get(key) : null),
|
||||
setItem: (key, value) => map.set(key, String(value)),
|
||||
removeItem: (key) => map.delete(key),
|
||||
key: (index) => [...map.keys()][index] ?? null,
|
||||
get length() {
|
||||
return map.size;
|
||||
},
|
||||
_map: map,
|
||||
};
|
||||
}
|
||||
const local = memoryStorage();
|
||||
globalThis.window = { localStorage: local, sessionStorage: memoryStorage() };
|
||||
globalThis.fetch = () => Promise.resolve({ ok: false });
|
||||
|
||||
const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } };
|
||||
|
||||
function loadTs(file) {
|
||||
const full = path.resolve(file);
|
||||
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) => {
|
||||
if (STUBS[request]) return STUBS[request];
|
||||
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
|
||||
return require(request);
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
|
||||
cache.set(full, box.exports);
|
||||
return box.exports;
|
||||
}
|
||||
|
||||
const lock = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Lock.ts"));
|
||||
|
||||
const planned = [
|
||||
[0, 0],
|
||||
[100, 0],
|
||||
[150, 80],
|
||||
[260, 90],
|
||||
];
|
||||
|
||||
// ① 저장된 것이 없으면 시점 · 종점만 고정.
|
||||
const fresh = lock.readRouteLocks("p-1");
|
||||
const freshColumn = lock.lockColumn(planned, fresh);
|
||||
|
||||
// ② 시점을 풀고 교각점 둘을 고정해 적는다 → 같은 프로젝트로 다시 읽는다.
|
||||
const edited = [false, true, true, true];
|
||||
lock.writeRouteLocks("p-1", planned, edited);
|
||||
const readBack = lock.readRouteLocks("p-1");
|
||||
const otherProject = lock.readRouteLocks("p-2");
|
||||
const storedKeys = [...local._map.keys()];
|
||||
|
||||
// ③ 서버가 끝자리만 조금 다른 좌표로 돌려줘도 같은 교각점 · 멀리 옮긴 점은 풀림.
|
||||
const nudged = [
|
||||
[0, 0],
|
||||
[100.03, -0.02],
|
||||
[151, 80],
|
||||
[260, 90],
|
||||
];
|
||||
const nudgedColumn = lock.lockColumn(nudged, readBack);
|
||||
|
||||
// ④ 선 · 호 고정 판정 — 곧은 선은 양 끝, 호는 교각점과 앞뒤.
|
||||
const straight = [0, 1, 2].map((index) => lock.straightLocked(edited, index));
|
||||
const arc = [1, 2].map((index) => lock.arcLocked(edited, index));
|
||||
const arcAll = [1, 2].map((index) => lock.arcLocked([true, true, true, true], index));
|
||||
|
||||
// ⑤ 노선을 못 읽은(노드 1개 이하) 채 닫으면 적어 둔 고정을 안 지운다 · 깨진 값은 기본값.
|
||||
lock.writeRouteLocks("p-1", [], []);
|
||||
const keptAfterEmpty = lock.readRouteLocks("p-1");
|
||||
local.setItem("aislo:pref:routeedit-locks:p-3", "{\"start\":false,\"apex\":[[1,\"x\"],[2,3]]}");
|
||||
const broken = lock.readRouteLocks("p-3");
|
||||
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
fresh,
|
||||
freshColumn,
|
||||
readBack,
|
||||
otherProject,
|
||||
storedKeys,
|
||||
nudgedColumn,
|
||||
straight,
|
||||
arc,
|
||||
arcAll,
|
||||
keptAfterEmpty,
|
||||
broken,
|
||||
}),
|
||||
);
|
||||
@@ -182,3 +182,13 @@ def test_도구_막대는_노선_추천_하나만():
|
||||
).read()
|
||||
for name in ("follow", "earthwork", "gentle", "weighted"):
|
||||
assert f'"{name}"' in recommend
|
||||
|
||||
|
||||
def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results):
|
||||
"""걸음마다 「현재 추천」이 노선과 짝 — 추천 없음(null)도 그대로 되돌아온다."""
|
||||
assert results["historyRecs"] == [
|
||||
{"logic": "earthwork"},
|
||||
{"logic": "follow"},
|
||||
{"logic": "earthwork"},
|
||||
None,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""계획노선 편집 창 — **고정 한 벌**(계획서 22-4 · 22-5) 회귀검증.
|
||||
|
||||
시점 · 종점은 기본 고정 · 교각점 고정은 좌표로 다시 짚음 · 곧은 선은 양 끝 · 호는 교각점과
|
||||
앞뒤 점이 모두 고정일 때 고정. 자리는 화면 상태 보관소 pref 통의 프로젝트 칸.
|
||||
|
||||
Node 헬퍼(`helper_b05_routeedit_lock.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
|
||||
import pytest
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def results():
|
||||
proc = subprocess.run(
|
||||
["node", os.path.join(HERE, "helper_b05_routeedit_lock.cjs")],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
timeout=60,
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr
|
||||
return json.loads(proc.stdout)
|
||||
|
||||
|
||||
def test_저장된_것이_없으면_시점_종점만_고정(results):
|
||||
assert results["fresh"] == {"start": True, "end": True, "apex": []}
|
||||
assert results["freshColumn"] == [True, False, False, True]
|
||||
|
||||
|
||||
def test_저장소_왕복은_프로젝트마다(results):
|
||||
assert results["readBack"] == {"start": False, "end": True, "apex": [[100, 0], [150, 80]]}
|
||||
assert results["otherProject"] == {"start": True, "end": True, "apex": []}
|
||||
assert results["storedKeys"] == ["aislo:pref:routeedit-locks:p-1"]
|
||||
|
||||
|
||||
def test_교각점은_좌표로_다시_짚음(results):
|
||||
# 0.05m 안은 같은 점 · 1m 옮긴 점은 풀림 · 시점 · 종점은 자리와 무관하게 역할로.
|
||||
assert results["nudgedColumn"] == [False, True, False, True]
|
||||
|
||||
|
||||
def test_선과_호_고정_판정(results):
|
||||
assert results["straight"] == [False, True, True]
|
||||
assert results["arc"] == [False, True]
|
||||
assert results["arcAll"] == [True, True]
|
||||
|
||||
|
||||
def test_빈_노선은_안_지우고_깨진_값은_기본값(results):
|
||||
assert results["keptAfterEmpty"] == results["readBack"]
|
||||
assert results["broken"] == {"start": False, "end": True, "apex": [[2, 3]]}
|
||||
@@ -220,6 +220,7 @@
|
||||
* 배경이 위성사진이라 라이트/다크 공통으로 같은 색을 쓴다.
|
||||
* ------------------------------------------------------------------------ */
|
||||
--map-route: #f97316; /* 계획선 (B04 지도·B05 배수유역도 공통) */
|
||||
--map-route-locked: #db2777; /* 계획노선 편집 — 고정한 선 · 호 · 점 (주황 · 초록 · 연보라 · 하늘색과 구별) */
|
||||
--map-cadastral: #f97316;
|
||||
--map-sigungu: #7c3aed;
|
||||
--map-eupmyeondong: #22c55e;
|
||||
@@ -320,6 +321,7 @@
|
||||
--color-warning: #e0b64a;
|
||||
--color-danger: #e8695a;
|
||||
--panel-handle-border: #e0b64a;
|
||||
--map-route-locked: #f472b6;
|
||||
|
||||
--shadow-sm: rgba(0, 0, 0, 0.4) 0px 2px 4px 0px, rgba(0, 0, 0, 0.2) 0px 0px 0px 1px;
|
||||
--shadow-lg: rgba(0, 0, 0, 0.5) 0px 32px 24px -12px, rgba(0, 0, 0, 0.3) 0px 3px 3px 0px;
|
||||
@@ -357,6 +359,7 @@
|
||||
--color-accent: var(--color-lavender-wash);
|
||||
--color-focus-ring: var(--color-lavender-wash);
|
||||
--panel-handle-border: #e0b64a;
|
||||
--map-route-locked: #f472b6;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user