Merge remote-tracking branch 'origin/dev' into sub_laptop_8

This commit is contained in:
2026-10-11 17:32:34 +09:00
6 changed files with 277 additions and 4 deletions
+2
View File
@@ -37,6 +37,7 @@ import {
} from "../../B06_Section/B06_Section_UI_Page_Persist";
import { readStructurePick } from "../../B05_Profile/B05_Profile_UI_Structure_Pick_Session";
import { NO_SELECTION, PIPE_MATCH_M, selectDesign } from "./design_selection";
import { paintMapPick } from "./design_pick_profile";
import { readPendingPipes } from "../../B05_Profile/B05_Profile_Api_Pipes_Draft";
import { applyStructurePick } from "../../B06_Section/B06_Section_UI_Page_Structure_Pick";
import { bindCrossPick } from "../../B06_Section/B06_Section_UI_Page_Pick_Sync";
@@ -505,6 +506,7 @@ export async function mountCrossView(
appliedPick = null;
restoreStructurePick();
},
paintMap: (selection) => shared.profile?.side && paintMapPick(shared.profile.side, selection),
});
/** 고름을 다 풂(54-38) — 강조 모두 끄고 넘김 되얹기 표시 · 선택 칸 비움(풀기 한 길 · 233-2). */
function releaseCross(): void {
+10 -3
View File
@@ -8,9 +8,11 @@
* 횡단 보기 안 누름(카드 · 목록 · 알약 · 형상)은 그 자리에서 그리고 값을 적으므로(from "b06") 되받지 않음.
* 풀기(Esc · 카드 다시 누름 · 목록 다시 누름 · 보기 오감)는 `release` 한 길 — 카드 · 폼 · 범위 테두리 ·
* 형상까지 모두 끔(카드만 풀고 `--span` 이 남던 구멍 · a4).
* 한 벌 배수유역도(관 마커 · 유역 · 측점 마름모)는 낸 쪽과 무관하게 늘 값대로 칠함 — 횡단 보기 안
* 누름(카드 · 목록)도 지도에 이어짐(270 · 종단 보기 `paintMapPick` 과 같은 규칙).
* ========================================================================== */
import { subscribeSelection } from "../A00_Common/design/design_selection";
import { subscribeSelection, type DesignSelection } from "../A00_Common/design/design_selection";
import { bindEscapeRelease } from "../A00_Common/design/design_pick_escape";
import type { CrossSection } from "./B06_Section_Api_Fetch";
import type { StationControls } from "./B06_Section_UI_Page_Station_Controls";
@@ -27,6 +29,8 @@ export interface CrossPickDeps {
};
/** 지금 값으로 카드 · 형상 · 폼을 다시 세움(넘김 적용 길). */
reapply: () => void;
/** 배수유역도를 값으로 칠함(알림 없이 · 270). */
paintMap?: (selection: DesignSelection | null) => void;
}
let releasing = false;
@@ -65,7 +69,8 @@ function releaseAll(deps: CrossPickDeps): void {
}
/** 횡단 보기를 고름 한 칸에 묶음 — 보일 때 · 다른 곳이 바꾼 값만 받음 · 보기를 떠나면 끊음.
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). */
* `release` = 강조 모두 끔(값은 부르는 쪽이 비움). 지도는 보일 때 모든 값을 칠함 — 보기 전환 때는
* 종단 보기가 같은 값으로 칠해 둠 · 재계산 뒤는 횡단 보기 종단도 알림(`onPipesChanged`)이 다시 얹음. */
export function bindCrossPick(
projectId: string | null,
host: { visible: () => boolean; onDispose: (fn: () => void) => void },
@@ -73,7 +78,9 @@ export function bindCrossPick(
): { release: () => void } {
host.onDispose(
subscribeSelection(projectId, (selection, from) => {
if (!host.visible() || from === "b06") return;
if (!host.visible()) return;
deps.paintMap?.(selection);
if (from === "b06") return;
if (selection.chainageM === null) releaseCrossHighlights(deps);
else deps.reapply();
}),
@@ -15,7 +15,14 @@ import {
type DesignPageShared,
} from "../A00_Common/design/design_shared";
import { readState } from "../A00_Common/b_page_state";
import { coversChainage, PIPE_MATCH_M } from "../A00_Common/design/design_selection";
import {
coversChainage,
NO_SELECTION,
PIPE_MATCH_M,
readSelection,
selectDesign,
} from "../A00_Common/design/design_selection";
import { paintMapPick } from "../A00_Common/design/design_pick_profile";
import { fetchLatestRoute, type RouteLatestResponse } from "../B05_Profile/B05_Profile_Api_Fetch";
import type {
CrossSection,
@@ -158,6 +165,15 @@ export function createSectionProfile(
return stationId;
};
/** 배수유역도에서 고른 관 자리 → 고름 한 칸(null = 풂) — 횡단 보기 구독이 카드 · 폼 · 알약 · 표 열을
* 그 값으로 세움(종단 보기 `pickFromMap` 과 같은 몫 · 270). */
const pickFromMap = (chainageM: number | null): void =>
selectDesign(
shared.projectId,
chainageM === null ? NO_SELECTION : { chainageM, structureId: null },
{ from: "map" },
);
const syncMarks = (): void => {
if (!profile) return;
profile.setStructureTypes([...types]);
@@ -196,6 +212,13 @@ export function createSectionProfile(
onStructureTypeAdd: (chainage, typeId) => host.edit()?.addStructureType(chainage, typeId),
onIrregularSelect: (station) =>
host.selectCard(nearestCard(station.chainage_m)?.station_id ?? null),
onBasinSelected: (chainageM) => pickFromMap(chainageM),
onPipeSelected: (chainageM) => pickFromMap(chainageM),
// 배수 재계산이 지운 지도 강조를 값대로 다시 얹음(종단 보기 restorePick 과 같은 몫 · 270)
onPipesChanged: () => {
if (shared.view() === "cross" && shared.profile?.side)
paintMapPick(shared.profile.side, readSelection(shared.projectId));
},
// 알약을 누르면 그 자리에 가장 가까운 측점 카드를 고른다 — 목록 클릭과 같은 규칙.
// 구조물 알약은 그 구조물을 폼에 올린다 — 카드가 측점으로 풀면 같은 측점 관이 먼저다(52-15).
onStructureSelect: (structureId) => {
+41
View File
@@ -0,0 +1,41 @@
# 2026-10-11 이관 — 262~270 (요구 164 · 165)
## 262. 거리이정표 창 제목 · 지도 시점 표지 (요구 164-1)
- [x] 262-1 (sub1) 상자 창 제목 앞 「거래처 — · 기준점 — · 공사지 —」 지움 — 거래처 = 그 이름 · 기준점 = 그 이름 · 시점 창 = 「시점 · 중점」(요소 추가 · 구간 창 그대로) — 검증완료(test_262_1 · test_254_3 통과 · typecheck 통과 · 화면 검증 = 사용자)
- [x] 262-2 (sub1) 거래처 · 기준점 창 소재지 지도에 시점 표지(고정 · 움직이지 않음) — 시점이 정해져 있을 때 · 소재지 없으면 시점 중심으로 엶 — 검증완료(test_262_1 통과 · typecheck 통과 · 화면 검증 = 사용자)
## 263. 운반 단가 모달 = 고른 항목 입력 + 거리 자동 (요구 164-1)
정함(브레인) = 사용자 말 「수기 전체 입력 아님 · 고른 단가산출 · 구조물 조합 · 사전조합의 입력에 이정표 거리만 자동 · 8-2-8 은 이미 있음」 → 사이클 손 칸 없앰
- [x] 263-1 (sub2) 왼쪽 = 고른 항목 입력만(사이클시간 입력 · 8-2-8 표 고르기 칸 지움) · 거리 칸(운반거리 · Cm · 사이클시간 · t2) = 이정표에서 「거리에서」 · 8-2-8 사이클 = 기본값으로 셈(계산 절차에만 보임) · [변경] = 한 목록 고르기(단가산출 · 사전조합 · 구조물 조합 · 호표) · [다른 종류] 없앰 — 검증완료(test_263_1 5 · typecheck · 실제 GET 검증_sub2 GF000284 운반거리 12 km 79,349 원 → 32 km 202,624 원)
- [x] 263-2 (sub2) 사전조합 · 구조물 조합 = 담은 로직 입력 보임 + 거리 칸 채움 → 행별 사본(거리마다 호표) · [저장] 때 서버가 거리로 다시 채움 — 검증완료(test_263_2 7 · 같은 값 사본 하나 · 다른 거리 둘 · 거리 바뀐 뒤 새 사본 · 옛 사본 prune · 옛 이름 읽힘)
## 264. B09 공기 옵션 컨테이너 (요구 164-2 · draft 안)
정함(브레인) = 공기 초안 아티팩트 「내가 정한 것」 12 가지 → 사용자 옵션 · 기본값 = 지금 값 · 기후 고르기 = 참고 설계서 셋(거창 25 · 울진 24 · 울진 소광 25)
- [x] 264-1 (sub8) B09 왼쪽 패널 「공기 옵션」 컨테이너(draft 등록 · draft 지우면 사라짐) — 착공일 · 기후 자료 · 쉬는 날 · 공휴일 넣기 · 일 작업시간 · 투입 기본 · 준비 · 정리 · 기타 · 반영률 기본 · 주 장비 기준 · 중복일수 끝수 · 마지막 달 비작업
- 검증완료(typecheck 0 · test_264_2 훅 시험 — DRAFT.panels 기본 빈 목록 = draft 없으면 카드 없음 · 화면 확인은 사용자)
- [x] 264-2 (sub8) 셈 짝(Py · TS) · 다듬기 · 거울 시험에 새 옵션
- 검증완료(test_252_1 8 · test_264_2 16 통과 — 기본값 거창 95 일 · 136 일 그대로 · 옵션 11 가지 각각 값 바뀜 · 거울 TS = Py)
## 265. B06 상세 CAD 칸 — 접기 · 차례 · 구조물 글 · 관 길이 정수 (요구 164-3 · 5 · 6)
- [x] 265-1 (sub6) 차례 = 그림 → 표 → CAD 칸 · CAD 칸 접기/펴기 · 열 때 기본 = 구조물 측점 폄 · 일반 측점 접힘 — 검증완료(test_265 차례 · 기본 접힘 판정 구조물 측점 5건 · 일반 측점 0건 · typecheck)
- [x] 265-2 (sub6) 미리 넣는 글 = 그 측점 구조물 전부 — 구조물마다 지정된 구조물 조합의 일위대가 이름 + 옵션 값 · 사용자 예시 글 베낀 꼴 버림 · 배관 = 원형 마크 · 짝(B07 Py) 같이 — 검증완료(test_265 · test_260 거울 TS = Py · 구조물 다섯 줄 · 미지정 · 배관 본체 줄 없음 · B07 빌드)
- [x] 265-3 (sub6) 원형 마크 관 길이 = 사사오입 정수(`pipeLengthWhole` · 225-1) — 검증완료(test_265 거울 8.5 → L=9m · 8.4 → L=8m · TS = Py)
## 266. B06 상세 처음 배율 = 횡단 보기 반폭 (요구 164-4)
- [x] 266-1 (sub7) 상세 창 처음 가로 범위 = 좌측 「횡단 보기 설정」 반폭(그 측점 개별 표시 반폭이 있으면 그것 · 카드와 같게)
- 검증완료(test_266 · 164 · 173 등 84건 · typecheck · 반폭 15 → 처음 −15~+15 · 개별 10 → −10~+10 · ⤢ · 더블클릭 같은 범위)
## 267. B06 횡단도 관매설 글 크기 (요구 164-7)
- [x] 267-1 (sub5) 노폭 위 회색 「관매설 …」(세월교 정보 글 포함) 글자 크기 = 「노폭 …」 글자 크기 · 색 그대로 · 줄 겹침 없음 — 검증완료(test_267_1 · 225_1 — 관매설 · 세월교 글 11px = 노폭 11px · 줄 간격 13px · 5fdfc64d1)
## 268. B07 횡단도 노란 측구 · 관 색 (요구 165-1 · 2)
- [x] 268-1 (sub3) 측구 노란 덧그림 하나 더 — 실제 화면에서 확정 뒤 그 덧그림만 지움 — 검증완료(test_268 4 · 212 · 137 — 갈아 끼우는 측점의 서버 노란 측구 걷음 · 장 엔티티 노란 측구 0 · d496517ce)
- [x] 268-2 (sub3) 관 · 박스암거 색 = 다른 선과 또렷이 다른 색(횡단도) — 검증완료(관 · 박스암거 = 청록 #22d3ee · 선 13개 · B07_DesignDetail_Cross_Overlay.ts · d496517ce)
## 269. 좌측 패널 컨테이너 공용 양식 (요구 165-3)
- [x] 269-1 (sub4) B07 좌측 패널 폭 · 단추 · 입력칸 = B05/B06 「구조물 배치」 공용 양식 — 검증완료(test_269 9 · createPanelCard · .ui-panel-form 칸 26px · B07 묶음 카드 공용)
- [x] 269-2 (sub4) B06 다른 컨테이너도 같은 양식 — 검증완료(B06 횡단 보기 · 표준 횡단면 칸 공용 양식)
- [x] 269-3 (sub4) B05 이상 좌측 패널 점검 · 맞춤(아래 단추 묶음 예외) — 검증완료(B05 · B08 · B09 카드 공용 · 점검 빠진 곳 0 · 아래 단추 묶음 그대로 · 9e3c87edd)
## 270. B05/B06 하이라이트 연동 되살림 (요구 165-4 · 브레인 직접)
- [x] 270-1 (브레인) 배수유역도 · 표 열 하이라이트 잃은 까닭 확인(노면공 범위 값 충돌 · 어제 저녁 분석) → 고침 · 구조물 목록 · 횡단도 · 3D 측점 연동 그대로 · 브라우저 검증 — 검증완료(까닭 = 횡단 보기가 한 벌 배수유역도를 고름 한 칸에 안 이음 · B05 는 정상 · test_270_1 6 + 강조 안전망 85 통과 · ORCA B06: 관 · 세월교 고름 → 유역 · 관 마커 · 유역 누름 → 카드 · 표 열 · 목록 · 알약 · 다시 누름 = 모두 풂 · 노면공 범위 끼어듦 없음 · 재계산 뒤 유지 · e09c6863f)
+118
View File
@@ -0,0 +1,118 @@
/* 270-1 횡단 보기 배수유역도 = 고름 한 칸에서 파생 — 진짜 TS 를 Node 로 돌림.
* `bindCrossPick`(B06_Section_UI_Page_Pick_Sync) + 진짜 `paintMapPick`(design_pick_profile) + 지도 흉내.
* 표준출력 JSON 한 벌 · 짝 = test_270_1_cross_map.py.
*/
const fs = require("fs");
const path = require("path");
const ROOT = path.join(__dirname, "..", "..");
const NM = path.join(ROOT, "config", "node_modules");
const ts = require(path.join(NM, "typescript"));
function loadTs(file, stubs = {}) {
const js = ts.transpileModule(fs.readFileSync(path.join(ROOT, file), "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
const localRequire = (request) => {
if (stubs[request]) return stubs[request];
throw new Error(`stub 없음: ${request}`);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
return box.exports;
}
globalThis.window = { addEventListener() {}, removeEventListener() {} };
globalThis.document = { addEventListener() {}, removeEventListener() {} };
const store = new Map();
const DSel = loadTs("A00_Common/design/design_selection.ts", {
"../b_page_state": {
readState: (key, pid) => store.get(`${key}:${pid}`) ?? null,
writeState: (key, value, pid) => store.set(`${key}:${pid}`, value),
},
});
const ESC = loadTs("A00_Common/design/design_pick_escape.ts", { "./design_selection": DSel });
const IRR = {
irregularStationId: (id) => `irregular:${id}`,
isPipeStation: (s) => s.origin === "pipe",
};
const SY = loadTs("B05_Profile/B05_Profile_UI_Selection.ts", {
"../A00_Common/design/design_selection": DSel,
"./B05_Profile_UI_IrregularStations": IRR,
});
const PP = loadTs("A00_Common/design/design_pick_profile.ts", {
"./design_selection": DSel,
"./design_pick_escape": ESC,
"../../B05_Profile/B05_Profile_UI_Selection": SY,
"../../B05_Profile/B05_Profile_UI_IrregularStations": IRR,
});
const SYNC = loadTs("B06_Section/B06_Section_UI_Page_Pick_Sync.ts", {
"../A00_Common/design/design_selection": DSel,
"../A00_Common/design/design_pick_escape": ESC,
});
const PID = "p270";
/* 지도 흉내 — 관 셋(95.38 · 195.3 · 292.22) · 고른 관 = 찾는 폭 안 관(배수유역도 `selectPipeAtChainage` 꼴) */
const PIPES = [95.38, 195.3, 292.22];
const map = {
calls: [],
pipe: null,
station: null,
selectPipeAtChainage(at, notify = true) {
this.calls.push(["pipe", at, notify]);
this.pipe =
at === null ? null : (PIPES.find((c) => Math.abs(c - at) < DSel.PIPE_MATCH_M) ?? null);
},
markStation(at) {
this.calls.push(["mark", at]);
this.station = at;
},
};
let visible = true;
let reapplied = 0;
const disposers = [];
const host = { visible: () => visible, onDispose: (fn) => disposers.push(fn) };
const deps = {
sections: () => [],
controls: {
revetOffset: { highlightFor: () => false, selectedFor: () => false, select() {} },
ford: { selectedFor: () => false, select() {} },
box: { selectedFor: () => false, select() {} },
},
releaseForm() {},
view: { focusStation() {}, refreshCards() {}, selectMark() {} },
reapply: () => reapplied++,
paintMap: (selection) => PP.paintMapPick(map, selection),
};
const pick = SYNC.bindCrossPick(PID, host, deps);
const snap = () => ({ pipe: map.pipe, station: map.station, reapplied, calls: map.calls.length });
const out = {};
// ① 횡단 보기 안 누름(from b06) — 지도는 칠하고 넘김 적용은 안 함
DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b06" });
out.b06Pipe = { ...snap(), notify: map.calls[0]?.[2] };
// ② 일반 측점 카드(관 없음) — 옛 관 강조가 풀리고 마름모만
DSel.selectDesign(PID, { chainageM: 80, structureId: null }, { from: "b06" });
out.b06Card = snap();
// ③ 지도에서 고름(from map) — 지도 칠함 + 넘김 적용(카드 · 폼 · 알약 · 표 열)
DSel.selectDesign(PID, { chainageM: 292.22, structureId: null }, { from: "map" });
out.fromMap = snap();
// ④ 노면공(범위 · 기준 30) — 관 없음 · 넘김 적용
DSel.selectDesign(PID, { chainageM: 30, structureId: "pave" }, { from: "b05" });
out.pave = snap();
// ⑤ 풀기(from b06) — 지도 풂
DSel.selectDesign(PID, DSel.NO_SELECTION, { from: "b06" });
out.release = snap();
// ⑥ 숨은 보기 — 안 칠함 · 안 적용
visible = false;
const before = map.calls.length;
DSel.selectDesign(PID, { chainageM: 195.3, structureId: null }, { from: "map" });
out.hidden = { calls: map.calls.length - before, reapplied };
// ⑦ 다시 보임 뒤 다른 곳(종단 보기)이 바꾼 값 = 칠함 + 넘김 적용
visible = true;
DSel.selectDesign(PID, { chainageM: 95.38, structureId: null }, { from: "b05" });
out.show = snap();
pick.release(); // 풀기 길은 그대로(값은 부르는 쪽이 비움)
out.releaseKeepsMap = map.pipe;
process.stdout.write(JSON.stringify(out));
+82
View File
@@ -0,0 +1,82 @@
# -*- coding: utf-8 -*-
"""270-1 횡단 보기 배수유역도 = 고름 한 칸에서 파생(요구 165-4).
까닭: 한 벌 배수유역도를 종단 보기 구독(`bindProfilePick`)만 칠해(보일 때만) 횡단 보기에서 고른 관 ·
세월교의 유역 · 관 마커가 안 켜지고 종단 보기에서 칠한 옛 강조가 남음 · 지도에서 유역을 눌러도 횡단 보기
알림(`onBasinSelected` · `onPipeSelected`)이 없어 카드 · 표 열 · 목록이 안 따라옴.
고침: 횡단 보기 구독이 낸 쪽과 무관하게 지도를 값으로 칠함(`paintMapPick` · 알림 없이) · 지도 고름 = 고름 한 칸
(from map → 넘김 적용 길) · 배수 재계산 · 보기 전환 뒤 다시 얹음.
진짜 TS 를 `helper_270_1_cross_map.cjs` 가 Node 로 돌린다.
"""
from __future__ import annotations
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
pytestmark = pytest.mark.skipif(
not os.path.isdir(os.path.join(ROOT, "config", "node_modules")),
reason="프론트엔드 의존성 미설치",
)
def _read(*parts: str) -> str:
with open(os.path.join(ROOT, *parts), encoding="utf-8") as handle:
return handle.read()
@pytest.fixture(scope="module")
def out() -> dict:
proc = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", os.path.join(HERE, "helper_270_1_cross_map.cjs")],
capture_output=True,
text=True,
encoding="utf-8",
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_횡단_안_누름도_지도_칠함(out):
# 목록 · 카드로 관을 고르면(from b06) 지도 관 · 마름모가 켜지고 넘김 적용은 안 돎 · 알림 없이 칠함
assert out["b06Pipe"]["pipe"] == 95.38 and out["b06Pipe"]["station"] == 95.38
assert out["b06Pipe"]["reapplied"] == 0 and out["b06Pipe"]["notify"] is False
def test_관_없는_카드는_옛_관_강조_풂(out):
assert out["b06Card"]["pipe"] is None and out["b06Card"]["station"] == 80
def test_지도_고름은_넘김_적용(out):
# 지도(from map)에서 고르면 카드 · 폼 · 알약 · 표 열을 세우는 넘김 적용이 한 번 돎
assert out["fromMap"]["pipe"] == 292.22 and out["fromMap"]["reapplied"] == 1
def test_노면공은_관_강조_없음(out):
# 노면공(범위) 고름 = 관 없음 → 유역 · 관 마커 끔(노면공 범위가 관을 덮어 켜지 않음)
assert out["pave"]["pipe"] is None and out["pave"]["station"] == 30
def test_풀기_숨은_보기_다시_보임(out):
assert out["release"]["pipe"] is None and out["release"]["station"] is None
assert out["hidden"] == {"calls": 0, "reapplied": 2} # 숨은 횡단 보기는 종단 보기 몫
assert out["show"]["pipe"] == 95.38 and out["show"]["reapplied"] == 3
assert out["releaseKeepsMap"] == 95.38 # release 는 횡단 강조만 — 지도는 값이 비면 구독이 풂
def test_횡단_보기_이음():
view = _read("B06_Section", "B06_Section_UI_Section_View_Profile.ts")
assert "onBasinSelected: (chainageM) => pickFromMap(chainageM)" in view
assert "onPipeSelected: (chainageM) => pickFromMap(chainageM)" in view
assert '{ from: "map" }' in view and "NO_SELECTION" in view
# 배수 재계산 뒤 다시 얹음 — 횡단 보기가 보일 때만
assert 'shared.view() === "cross"' in view and "paintMapPick(shared.profile.side" in view
cross = _read("A00_Common", "design", "design_view_cross.ts")
assert "paintMap: (selection) =>" in cross and "paintMapPick(shared.profile.side" in cross