Merge remote-tracking branches 'origin/sub_laptop_1' and 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-10-17 22:21:29 +09:00
5 changed files with 80 additions and 10 deletions
@@ -51,6 +51,8 @@ const MARK_HALF_PX = 5;
const MARK_WIDTH_PX = 8;
/** 고른 마커 바깥 테 — 법선과 같은 하늘색(391). */
const PICKED_RING = "#0ea5e9";
/** 법선 좌우 길이(m) — `_Hover` 법선과 같은 값(391-1) · 정보 창을 그 밖으로. */
const NORMAL_HALF_M = 20;
/** 종단 보기가 건네는 것 — 알약 목록 · 종류 · 측점 간격 · 고름 · 옮김(알약과 같은 길). */
export interface PlanMarkSource {
@@ -84,15 +86,22 @@ interface PlanMark {
name: string;
}
/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로. */
/** 옮기는 중(끌기 · 방향키 누름) — 놓을 때 · 뗄 때 `to` 로 한 번 옮김. 창(캔버스)마다 따로.
* `done` = 옮겼고 알약 목록이 아직 안 따라옴(관 목록은 늦게 옴) — 따라올 때까지 그 자리 · 그 고름 그대로. */
interface Moving {
id: string;
name: string;
from: number;
to: number;
totalM: number;
done?: true;
}
const movings = new WeakMap<HTMLCanvasElement, Moving | null>();
/** 끄는 중 · 누르는 중인 것(옮긴 뒤 기다리는 것은 아님). */
const activeMoving = (canvas: HTMLCanvasElement): Moving | null => {
const moving = movings.get(canvas);
return moving && !moving.done ? moving : null;
};
const balloons = new WeakMap<HTMLCanvasElement, { key: string; element: HTMLElement }>();
const lengthOf = (line: ReadonlyArray<Vertex>): number =>
@@ -112,7 +121,12 @@ function planMarks(
const list = laneStructures(source.marks());
const types = new Map(source.types().map((type) => [type.type_id, type]));
const texts = structurePillTexts(list, types);
const moving = movings.get(canvas) ?? null;
const held = movings.get(canvas) ?? null;
const lagging = list.some(
(structure) => structure.structure_id === held?.id && structureAnchorM(structure) !== held?.to,
);
if (held?.done && !lagging) movings.set(canvas, null); // 목록이 따라옴(같은 id 가 없어도 끝)
const moving = held?.done && !lagging ? null : held;
const marks = list
.filter((structure) => structure.structure_id)
.map((structure): PlanMark => {
@@ -210,6 +224,7 @@ function showBalloon(
const next = balloonElement(lines, mark.type);
next.classList.add("is-center");
next.style.pointerEvents = "none";
next.style.zIndex = "1"; // 오른쪽 횡단 판(z 2) · 펼친 판 아래로 — 지도 위에만
if (element) element.replaceWith(next);
else canvas.parentElement.append(next);
element = next;
@@ -226,8 +241,12 @@ function showBalloon(
element.style.left = `${x}px`;
element.style.top = `${y}px`;
element.style.bottom = "auto";
// 법선(좌우 20 m) 끝 밖으로 띄움 — 크게 확대해도 60 px 넘게는 안 떨어짐
const gap = Math.min(NORMAL_HALF_M * scene.pxPerMeter, 60) + 10;
element.style.transform =
y < element.offsetHeight + 24 ? "translate(-50%, 14px)" : "translate(-50%, calc(-100% - 14px))";
y < element.offsetHeight + gap + 10
? `translate(-50%, ${gap}px)`
: `translate(-50%, calc(-100% - ${gap}px))`;
}
export interface StructureMarkInput {
@@ -280,9 +299,9 @@ export function bindStructureMarks(
};
/** 놓음 · 뗌 — 옮겼으면 알약 끌기와 같은 길로 한 번. */
const finish = (): boolean => {
const moving = movings.get(canvas);
const moving = activeMoving(canvas);
if (!moving) return false;
movings.set(canvas, null);
movings.set(canvas, moving.to !== moving.from ? { ...moving, done: true } : null);
if (moving.to !== moving.from) {
sources.get(draftProjectId())?.move(moving.id, moving.to);
say(moving, true);
@@ -336,7 +355,7 @@ export function bindStructureMarks(
const target = event.target as HTMLElement | null;
if (target?.closest?.("input, select, textarea, [contenteditable='true']")) return;
if (event.repeat || holdKey || dragging() || params.measureMode()) return;
if (params.state().picked >= 0 || movings.get(canvas)) return; // 교각점 방향키 몫
if (params.state().picked >= 0 || activeMoving(canvas)) return; // 교각점 방향키 몫
const found = planMarks(canvas);
const mark = found?.marks.find((entry) => entry.id === found.picked);
if (!mark) return;
@@ -367,7 +386,7 @@ export function bindStructureMarks(
return true;
},
move(px, py) {
const moving = movings.get(canvas);
const moving = activeMoving(canvas);
if (!moving || holdKey) return false;
const hit = routePointAtScreen(lineNow(), params.toScreen, px, py, Infinity);
const near = hit ? markChainage(hit.chainageM, moving.totalM) : moving.to;
+1 -1
View File
@@ -120,7 +120,7 @@ const apply = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Ap
}),
changed: () => changed,
terrain: () => "sheet",
stationSnap: () => true,
snap: () => ({ stationSnap: true, stationSnapStep: 20 }), // 390-5 — 켬끔 · 간격 한 벌
initial: { current: () => ({ mode: "follow" }), keep: () => (kept += 1) },
onApplied: () => (rebuilt += 1),
});
@@ -134,6 +134,7 @@ const types = [
{ type_id: "box", name: "BOX", style: { color: "#e11d48" } },
{ type_id: "pipe", name: "배수관", style: { color: "#2563eb" } },
];
let lag = false;
const selects = [];
const moves = [];
let redrawn = 0;
@@ -142,20 +143,35 @@ marks.setPlanMarkSource("p1", {
types: () => types,
intervalM: () => 20,
select: (id) => selects.push(id),
move: (id, to) => moves.push([id, to]),
// 옮김 = 알약 목록이 그 자리로(`lag` 면 늦게 옴 — 관 목록)
move: (id, to) => {
moves.push([id, to]);
if (!lag) structures.find((s) => s.structure_id === id).chainage_m = to;
},
});
const canvas = { isConnected: true, parentElement: parent };
let line = EAST;
let picked = -1;
const status = { textContent: "" };
const scene = () => ({ toScreen, view: { width: 400, height: 300 }, rotationRad: 0 });
const scene = () => ({
toScreen,
pxPerMeter: 2,
view: { width: 400, height: 300 },
rotationRad: 0,
});
const style = { getPropertyValue: () => "" };
const draw = () => {
const fake = fakeContext(canvas);
marks.drawStructureMarks(fake.ctx, scene(), line, style);
return fake.log;
};
/** 한 걸음 판정 뒤 — 그려서 기다림을 풀고 처음 자리로. */
const settle = () => {
draw();
structures[0].chainage_m = 40;
structures[1].chainage_m = 80.17;
};
const input = marks.bindStructureMarks(
{
canvas,
@@ -213,16 +229,19 @@ out.dragStatus = status.textContent;
out.movesBeforeRelease = moves.length;
input.release();
out.dragMove = moves[moves.length - 1];
settle();
out.dragDoneStatus = status.textContent;
// 노선 밖 — 끝(200) · 시점(0)에 붙음
input.press(80, 0);
input.move(9999, 0);
input.release();
out.pastEnd = moves[moves.length - 1];
settle();
input.press(80, 0);
input.move(-500, 0);
input.release();
out.beforeStart = moves[moves.length - 1];
settle();
// 옛 소수 관(80.17) — 눌렀다 반올림 자리에서 떼면 안 옮김 · 1 m 끌면 정수
const count = moves.length;
input.press(160.34, 0);
@@ -233,18 +252,22 @@ input.press(160.34, 0);
input.move(162.2, 0);
input.release();
out.oldDecimalMoved = moves[moves.length - 1];
settle();
// ④ 방향키 — 고른 마커(A) · 교각점 안 고름 = 노선 따라 1 m · 뗄 때 한 번
selection = { chainageM: 40, structureId: "A" };
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyRight = moves[moves.length - 1];
settle();
key("keydown", "ArrowLeft");
key("keyup", "ArrowLeft");
out.keyLeft = moves[moves.length - 1];
settle();
key("keydown", "ArrowUp"); // 노선과 직각 = → · ↑ 는 +
key("keyup", "ArrowUp");
out.keyUpPerpendicular = moves[moves.length - 1];
settle();
// 서→동이 아니라 동→서 노선 — 오른쪽(동) = 뒤로
line = [
[200, 0],
@@ -253,7 +276,27 @@ line = [
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.keyRightWestward = moves[moves.length - 1];
settle();
line = EAST;
// 관 목록이 늦게 옴 — 연달아 두 번 눌러도 두 걸음(옮긴 자리 · 고름을 목록이 따라올 때까지 쥠)
lag = true;
selection = { chainageM: 80.17, structureId: null };
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
selection = { chainageM: 81, structureId: null }; // 관 고름이 새 자리로(followMovedPipe)
const heldLog = draw();
out.lagHeldAt = heldLog.filter((s) => s.width === 15).map((s) => s.at);
key("keydown", "ArrowRight");
key("keyup", "ArrowRight");
out.lagTwoSteps = moves.slice(-2);
lag = false;
structures[1].chainage_m = 82; // 목록이 따라옴
selection = { chainageM: 82, structureId: null };
out.lagCaught = draw()
.filter((s) => s.width === 15)
.map((s) => s.at);
settle();
selection = { chainageM: 40, structureId: "A" };
// 교각점 고름 = 교각점 방향키 몫(마커 안 옮김)
picked = 2;
const before = moves.length;
@@ -49,6 +49,7 @@ def test_초안은_계약_RouteDraft_꼴(out):
"curveLock",
"curveArc",
"stationSnap",
"stationSnapStep",
"terrain",
}
n = len(draft["nodes"])
@@ -57,6 +58,7 @@ def test_초안은_계약_RouteDraft_꼴(out):
)
assert draft["curveOn"] == [True, False, True] and draft["curveRadius"] == [None, 25, None]
assert draft["stationSnap"] is True and draft["terrain"] == "sheet"
assert draft["stationSnapStep"] == 20 # 390-5 간격도 함께 실림
assert out["body"]["initial"] == {"mode": "follow"}
assert "balanced_profile" not in out["body"] # 손으로 고친 노선 = 계획고 옮김만(349-0v)
assert "as_initial" not in out["body"] # 손으로 고친 노선 = 초기값 그대로
@@ -102,6 +102,12 @@ def test_방향키_노선_따라_1m(got) -> None:
assert got["keyUpPerpendicular"] == ["A", 41] # 직각 = → · ↑ 가 +
assert got["keyRightWestward"] == ["A", 39] # 노선이 서쪽으로 가면 → = 뒤로
assert got["keyWithNodePicked"] == 0 # 교각점 고름 = 교각점 방향키 몫
def test_관_목록이_늦게_와도_연달아_두_걸음(got) -> None:
assert got["lagHeldAt"] == [[162, 0]] # 옮긴 자리 · 고름 그대로(목록은 아직 80.17)
assert got["lagTwoSteps"] == [["pipe-80.17", 81], ["pipe-80.17", 82]]
assert got["lagCaught"] == [[164, 0]] # 목록이 따라오면 목록 자리
assert got["keyNoPick"] == 0