Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-17 12:07:25 +09:00
24 changed files with 375 additions and 238 deletions
+1 -1
View File
@@ -89,7 +89,7 @@ export const STATE_REGISTRY = {
"masshaul-visible": {
bucket: "pref",
scope: "global",
version: 5,
version: 6,
legacy: () => "b06:masshaul-visible-v4",
},
"table-open": { bucket: "pref", scope: "global", legacy: () => "b05:profile:table:open" },
+6 -5
View File
@@ -324,9 +324,9 @@ export function createDesignProfile(ctx: DesignProfileContext) {
refresh: () => renderBalance(),
});
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. */
function applyEdits(next: AlignmentEdits): void {
if (!base || !alignment) return;
/** 편집을 적용한다. 법정 위반 정책이 block이면 새 위반이 생기는 편집을 막는다. false = 막힘. */
function applyEdits(next: AlignmentEdits): boolean {
if (!base || !alignment) return false;
const candidate = buildAlignment(base, next);
if (
base.policy.grade_violation_policy === "block" &&
@@ -336,12 +336,13 @@ export function createDesignProfile(ctx: DesignProfileContext) {
`종단기울기 상한 ${base.policy.max_grade_pct.toFixed(1)}%를 넘어 편집을 적용하지 않았습니다.`,
"error",
);
return;
return false;
}
// 최소고 가드 — 편집 경로가 전부 여기로 모인다(2026-09-02 직선화·쉬프트·틸팅·방향키 누락 수정).
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return;
if (blocksMinCover(base, alignment, candidate, enforceMinCover, minCoverTargets)) return false;
store.replace(next);
history.record();
return true;
}
/** 계획선 측점 줄 = 측점 한 벌 — 셈은 `design_profile_base` 몫(계산 묶음 planline 도 같은 기준). */
@@ -59,8 +59,6 @@ export function createProfileOverlays(deps: ProfileOverlaysDeps) {
const column = document.createElement("div");
column.className = "b05-route-profile__column";
column.append(bodyWrap, massHaul.overlay);
// 판의 가로 스크롤을 종단 스크롤러와 양방향 동기화 — 측점 세로선 정렬 유지.
massHaul.attachScrollSync(body);
// 테이블은 종단 칸 바닥 겹침 판(2026-08-05 사용자 지시).
const tableOverlay = createProfileTableOverlay(onChange);
bodyWrap.append(tableOverlay.overlay, tableOverlay.handle);
+44 -13
View File
@@ -4,7 +4,8 @@
*
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · ↶ ↷(Backspace = ↶) =
* 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음.
*
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
@@ -181,8 +182,8 @@ export interface FreeDrawDeps {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
apply: (next: AlignmentEdits) => void;
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */
apply: (next: AlignmentEdits) => boolean | void;
/** 모드를 끈다(적용 뒤 · Esc). */
close: () => void;
/** 단계 · 안내가 바뀜. */
@@ -198,6 +199,11 @@ export interface FreeDraw {
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
/** Enter · Escape · Backspace — 먹었으면 true. */
key: (key: string) => boolean;
/** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */
undo: () => void;
redo: () => void;
canUndo: () => boolean;
canRedo: () => boolean;
marks: () => FreeMarks;
hint: () => string;
}
@@ -209,6 +215,25 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
let anchor: number | null = null;
let points: FreeVertex[] = [];
let target: FreeVertex | null = null;
/** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */
interface Snap {
phase: FreePhase;
pending: number | null;
range: [number, number] | null;
anchor: number | null;
points: FreeVertex[];
}
let past: Snap[] = [];
let future: Snap[] = [];
const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] });
function step(from: Snap[], to: Snap[]): void {
const state = from.pop();
if (!state) return;
to.push(snap());
({ phase, pending, range, anchor, points } = state);
target = null;
deps.changed();
}
function reset(): void {
phase = "range";
@@ -217,6 +242,8 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
anchor = null;
points = [];
target = null;
past = [];
future = [];
}
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
@@ -263,8 +290,9 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
if (!base || end === null || z === null) return;
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
const next = buildFreeEdits(base, deps.edits(), vertices);
// 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3)
if (deps.apply(next) === false) return;
reset();
deps.apply(next);
deps.close();
}
@@ -272,6 +300,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
phase: () => phase,
reset,
click(chainageM, elevationM, stations) {
const before = snap();
if (phase === "range") {
const station = snapStation(stations, chainageM);
if (station === null) return true;
@@ -297,6 +326,10 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
}
points.push(next);
}
if (JSON.stringify(snap()) !== JSON.stringify(before)) {
past.push(before);
future = [];
}
target = null;
deps.changed();
return true;
@@ -316,18 +349,16 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
return true;
}
if (key === "Backspace") {
if (phase !== "drawing") return false;
if (points.length) points.pop();
else {
anchor = null;
phase = "armed";
}
target = null;
deps.changed();
if (!past.length) return false;
step(past, future);
return true;
}
return false;
},
undo: () => step(past, future),
redo: () => step(future, past),
canUndo: () => past.length > 0,
canRedo: () => future.length > 0,
marks() {
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
const dots = path();
@@ -357,7 +388,7 @@ export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
}
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)";
},
};
}
+38 -123
View File
@@ -17,8 +17,8 @@
* ── 배치 (354-1 · 요구 235-7 「유토곡선도 분리」) ─────────────────────
* 종단 열(그래프 · 표) 아래 칸에 따로 선다 — 종단도 · 표 위를 덮지 않고 높이 나눔 · 표 쌓기에도
* 안 낀다. 높이는 판 위 경계 리사이저(`createPanelResizer`)로 조절하고 세션 동안 유지한다.
* 머리줄([창 분할] · 접기 손잡이)은 접어도 남는다. 곡선 SVG는 판 안 자체 가로 스크롤러에
* 들어가며, 종단 스크롤러와 scrollLeft를 양방향 동기화해 측점 세로선이 어긋나지 않게 한다.
* 접으면 손잡이만 남는다(367-2). 곡선은 늘 노선 전체를 한 화면에 맞춰 그린다 — 종단 측점과
* 맞물리는 보기 · 스크롤 동기는 없앰(367-1).
* ========================================================================== */
import { readStateRaw, stateKey, writeStateRaw } from "../A00_Common/b_page_state";
@@ -46,16 +46,13 @@ import { resetBalloonOffsets } from "@util/common_util_mass_haul_balance_view";
import {
createMassHaulChart,
createMassHaulLegend,
createMassHaulWindowState,
MASS_HAUL_MIN_HEIGHT,
scheduleMassHaulSettle,
} from "@util/common_util_mass_haul_view";
import { Y_WINDOW_BAKED_ATTR } from "../B06_Section/B06_Section_UI_Longitudinal";
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
import { createPanelResizer } from "@ui/ui_template_resizer";
import "@util/common_util_mass_haul.css";
import "./B05_Profile_UI_Style_MassHaul.css";
import { attachWheelHorizontalScroll, linkScrollLeft } from "./B05_Profile_UI_Profile_Wheel";
import { frameOf } from "../common_util/common_util_view_window";
/** 2차 패널 펼침 여부 — 세션 동안만 유지(패널 높이·접힘과 같은 수명). */
@@ -76,10 +73,8 @@ const HEIGHT_VAR = "--b05-masshaul-height";
* 표시 상태가 갈리면 "같은 곡선인데 왜 다르게 보이나"가 된다(2026-08-03 사용자 확정).
* 정의처는 B06 `_UI_Section_View`와 이 파일 두 곳뿐이며 값이 반드시 같아야 한다.
*/
/** 처음 열 때는 **곡선만** 보인다 — 토량 분배(평형선·운반 블록)는 범례에서 켠다
* (2026-09-06 사용자 지시). 판을 v4 → v5 로 올려 이미 켜 둔 브라우저도 새 기본값으로
* 시작하게 한다. */
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE];
/** 기본 = 횡단 기준 + 토량 분배 켬(367-1) — 판 v6. [토량 분배] 는 분배 그림 보임/숨김. */
const DEFAULT_VISIBLE: string[] = [...MASS_HAUL_DEFAULT_VISIBLE, MASS_HAUL_BALANCE_KEY];
/** 유토곡선 계산에 필요한 프로젝트 설정 — B05 Page가 `fetchSectionContext()`에서 받아 넘긴다. */
export interface RouteMassHaulContext {
@@ -150,12 +145,8 @@ export interface RouteMassHaulPanel {
overlay: HTMLElement;
isOpen(): boolean;
setContext(next: RouteMassHaulContext | null): void;
/** 종단 가로 스크롤러와 scrollLeft를 양방향 동기화한다(설치 1회). */
attachScrollSync(main: HTMLElement): void;
/** 판 내용을 다시 그린다. 접혀 있으면 머리줄만 남기고 끝낸다. */
/** 판 내용을 다시 그린다. 접혀 있으면 손잡이만 남기고 끝낸다. */
draw(params: RouteMassHaulDrawParams): void;
/** 가로 스크롤로 보이는 구간만 바뀜 — 창을 따를 때만 · 한 틀 한 번 · 다시 셈 없음(354-3). */
drawWindow(params: RouteMassHaulDrawParams): void;
}
function readVisible(): Set<string> {
@@ -199,7 +190,7 @@ export function createRouteMassHaulPanel(
handle.className = "b05-profile__masshaul-handle";
handle.append(handleControl.root);
handleControl.root.setAttribute("aria-label", "유토곡선 패널");
// 머리줄 — 접어도 남아 [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임) · 손잡이가 닿는다(354-1).
// 머리줄 — [창 분할]([attachSubPanelPopouts] 가 맨 앞에 붙임 · 펼친 때만 보임) · 손잡이(접으면 판 위 경계에 매달림 · 367-2).
const head = document.createElement("div");
head.className = "b05-profile__masshaul-head";
head.append(handle);
@@ -248,10 +239,6 @@ export function createRouteMassHaulPanel(
pendingChip.hidden = true;
overlay.append(resizer.root, head, bar, scroll, legendLayer, pendingChip);
// 종단 그래프 영역과 같은 문법 — 세로 휠을 가로 이동으로 돌린다(2026-08-04 사용자 지시).
// scrollLeft 동기화 덕에 위 종단 그래프도 함께 움직인다. Shift+휠은 브라우저 기본 그대로.
attachWheelHorizontalScroll(scroll);
/** 곡선을 그리는가 — 펼침이거나 새 창으로 뗌(뗀 창엔 접기가 없음 · 354-1). */
const shown = (): boolean => open || overlay.classList.contains("is-popout");
@@ -262,7 +249,7 @@ export function createRouteMassHaulPanel(
// 툴팁은 setOpen이 일반 문구로 덮으므로 매번 유토곡선용으로 다시 밝힌다.
handleControl.root.title = next ? "유토곡선 접기" : "유토곡선 펼치기";
handle.classList.toggle("is-open", next);
// 접으면 머리줄만 남는다(배수유역도처럼 손잡이는 늘 닿음)
// 접으면 손잡이만 남는다(배수유역도처럼 손잡이는 늘 닿음)
overlay.classList.toggle("is-collapsed", !next);
onChanged();
}
@@ -279,29 +266,14 @@ export function createRouteMassHaulPanel(
onChanged();
}
// 종단 스크롤러와의 양방향 동기화 — 메아리는 공용 도우미가 걸러 냄(354-3).
// 토량 분배(전체 맞춤) 중에는 끊는다 — 유토곡선이 0 으로 붙으며 낸 scroll 이 종단 그래프 ·
// 표까지 0 으로 되돌렸다(PLAN 79-1). 유토곡선만 스크롤이 없는 상태.
let fitted = false;
let syncedMain: HTMLElement | null = null;
let putScroll: ((to: HTMLElement, left: number) => void) | null = null;
/** 종단과 맞물려 그린 곡선이 놓인 문서 — 처음 · 분배 맞춤 뒤 · 창 옮김 뒤면 종단 자리로 맞춘다. */
let syncedIn: Document | null = null;
function attachScrollSync(main: HTMLElement): void {
if (syncedMain === main) return;
syncedMain = main;
putScroll = linkScrollLeft(main, scroll, () => fitted);
}
/** 곡선을 한 번이라도 그렸는지 — 재계산 대기 중 화면을 비우지 않기 위한 표시. */
let drawnOnce = false;
/** 마지막으로 낸 계열 — 가로 스크롤(보이는 구간만 바뀜)은 다시 셈 없이 이걸로 그린다(354-3). */
/** 마지막으로 낸 계열 — 같은 계열이면 총괄값을 다시 내지 않는다. */
let lastSeries: MassHaulSeries[] | null = null;
function note(message: string): void {
drawnOnce = false;
lastSeries = null;
syncedIn = null;
pendingChip.hidden = true;
bar.replaceChildren();
scroll.replaceChildren();
@@ -312,11 +284,6 @@ export function createRouteMassHaulPanel(
bar.append(empty);
}
// 세로창 버티기·부드러운 이동 상태 — 이 패널이 사는 동안 하나만 둔다(2026-09-04).
const windowState = createMassHaulWindowState();
/** 마지막으로 그린 입력 — 이동이 아직 안 끝났으면 다음 프레임에 이걸로 한 번 더 그린다. */
let lastParams: RouteMassHaulDrawParams | null = null;
/** 이미 낸 계열에서 총괄값만 꺼낸다 — 계산은 하지 않는다. */
function emitSummary(series: MassHaulSeries[]): void {
if (!onSummary) return;
@@ -348,33 +315,16 @@ export function createRouteMassHaulPanel(
);
}
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. `reuse` = 마지막 계열로(다시 셈 없음). */
function draw(params: RouteMassHaulDrawParams, reuse = false): void {
pipelineWork("유토곡선", () => drawNow(params, reuse));
/** 그리기 몫 기록(349-0s) — 묶음 마지막 차례 `work.유토곡선`. */
function draw(params: RouteMassHaulDrawParams): void {
pipelineWork("유토곡선", () => drawNow(params));
}
/** 가로 스크롤로 보이는 구간만 바뀜(354-3) — 곡선이 창을 따를 때만(열림 · 분배 맞춤 아님 · 붙음) ·
* 한 틀 한 번 · 다시 셈 없이 세로 창만 다시 그림. 접힘 · 분배 · 새 창은 스크롤마다 할 일 없음. */
let windowDirty = false;
function drawWindow(params: RouteMassHaulDrawParams): void {
if (!open || fitted || overlay.classList.contains("is-popout") || !lastSeries) return;
lastParams = params;
// 미끄러짐 틀이 이미 잡혀 있으면 그 틀이 이 값으로 그림
if (windowDirty || windowState.frame) return;
windowDirty = true;
frameOf(overlay, () => {
if (windowDirty && lastParams) draw(lastParams, true);
});
}
function drawNow(params: RouteMassHaulDrawParams, reuse: boolean): void {
lastParams = params;
windowDirty = false;
function drawNow(params: RouteMassHaulDrawParams): void {
overlay.classList.toggle("is-collapsed", !open);
// 접혀 있어도 상단줄에는 값이 서야 한다 — 이때는 **횡단 기준만** 적분한다.
// 펼친 경우에는 아래에서 낸 계열로 총괄값을 낸다(계산 한 번).
if (!shown()) {
syncedIn = null;
reportSummary(params);
return;
}
@@ -407,17 +357,13 @@ export function createRouteMassHaulPanel(
// (354-2 · 계산 코드는 공용에 남김).
// 결과를 아껴 두지 **않는다** — 횡단 설계가 제자리에서 갈리므로 객체 동일성으로는
// 바뀐 것을 못 잰다(2026-09-04 캐시를 넣었다가 곡선이 안 따라와 되돌림).
// 스크롤로 창만 바뀐 그리기(`reuse`)는 직전 계열 그대로 — 같은 입력의 두 번째 적분이었다(354-3).
const series: MassHaulSeries[] =
reuse && lastSeries
? lastSeries
: computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
["cross"],
);
const series: MassHaulSeries[] = computeMassHaulSeries(
params.longitudinal,
params.crossSections,
context.conversion,
context.naturalSpoilMinSlope,
["cross"],
);
// 총괄값은 방금 낸 계열에서 꺼낸다 — 접힘 경로처럼 따로 적분하지 않는다.
if (series !== lastSeries) emitSummary(series);
lastSeries = series;
@@ -446,29 +392,16 @@ export function createRouteMassHaulPanel(
(overlay.clientHeight || OVERLAY_DEFAULT_HEIGHT) - bar.offsetHeight - 12;
const chartHeight = Math.max(scrollHeight, MASS_HAUL_MIN_HEIGHT);
// 토량 분배를 켜면 **곡선 전체가 한 화면에** 들어와야 한다(2026-09-06 사용자 지시).
// 평형선·운반 블록은 노선 전체를 놓고 읽는 그림이라, 종단 그래프와의 X 맞춤(가로
// 스크롤)과 보이는 구간만 보는 Y 창을 둘 다 버리고 스크롤러 안쪽 폭·전 구간 Y로 그린다.
// 종단과 맞물릴 필요가 없으므로 반 칸 들여쓰기(originOffset)도 쓰지 않는다 — 그만큼
// 좌우가 비어 곡선이 가운데로 쪼그라들었다(2026-09-06 사용자 보고: 「우측 여유 과다」).
const fitAll = haulPlan !== null;
fitted = fitAll;
const widthPx = fitAll ? scroll.clientWidth || params.widthPx : params.widthPx;
const chartAxis = fitAll
? {
...params.axis,
padLeft: params.axis.axisX ?? params.axis.padLeft,
padRight: MASS_FIT_PAD_RIGHT,
viewRange: undefined,
window: undefined,
hideStations: true,
}
: {
...params.axis,
// 새 창으로 뗀 판은 넓어 메인 보이는 구간 세로면 잘림 — 노선 전체 세로(325-2).
viewRange: overlay.classList.contains("is-popout") ? undefined : params.axis.viewRange,
window: windowState,
};
// 늘 **곡선 전체가 한 화면에**(367-1 · 측점 연계 보기 없앰) — 스크롤러 안쪽 폭 · 전 구간 Y ·
// 측점선 없음. 반 칸 들여쓰기(originOffset)도 안 씀(2026-09-06 「우측 여유 과다」).
const widthPx = scroll.clientWidth || params.widthPx;
const chartAxis = {
...params.axis,
padLeft: params.axis.axisX ?? params.axis.padLeft,
padRight: MASS_FIT_PAD_RIGHT,
viewRange: undefined,
hideStations: true,
};
let massAxis: { padLeft: number; ticks: Array<{ y: number; label: string }> } | null = null;
const chart = createMassHaulChart(
@@ -495,33 +428,17 @@ export function createRouteMassHaulPanel(
wrap.append(chart);
// 앵커는 첫 자식 — SVG 뒤면 흐름 위치가 밀려 스크롤 시 축이 안 보인다(B06과 같은 규칙).
if (massAxis) wrap.prepend(buildStickyYAxis(massAxis, chartHeight));
// 이미 종단과 맞물려 그린 곡선이면 제 자리 그대로 — 종단 값으로 되쓰면 그 사이 유토 쪽 휠이
// 더 간 만큼 뒤로 튐(354-3). 처음 · 분배 맞춤 뒤 · 창 옮김 뒤만 종단 자리에서 시작한다.
const keepLeft = !fitAll && syncedIn === scroll.ownerDocument ? scroll.scrollLeft : null;
scroll.replaceChildren(wrap);
// 전체 맞춤일 때는 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
if (fitAll) scroll.scrollLeft = 0;
else if (keepLeft !== null) scroll.scrollLeft = keepLeft;
else if (syncedMain) putScroll?.(scroll, syncedMain.scrollLeft);
syncedIn = fitAll ? null : scroll.ownerDocument;
// 스크롤할 것이 없으므로 왼쪽 끝에 둔다.
scroll.scrollLeft = 0;
// 범례는 계열 · 켬이 바뀔 때만 — 스크롤 그리기는 그대로 둔다.
if (!reuse || !legendLayer.firstChild)
legendLayer.replaceChildren(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
drawnOnce = true;
// 세로창이 아직 목표까지 안 갔으면 다음 프레임에 한 걸음 더 — 확 튀지 않고 미끄러진다.
scheduleMassHaulSettle(
windowState,
() => {
if (lastParams) draw(lastParams, true);
},
overlay,
legendLayer.replaceChildren(
createMassHaulLegend(series, visible, toggleSeries, () => {
resetBalloonOffsets();
onChanged();
}),
);
drawnOnce = true;
}
return {
@@ -531,8 +448,6 @@ export function createRouteMassHaulPanel(
setContext(next) {
context = next;
},
attachScrollSync,
draw: (params) => draw(params),
drawWindow,
draw,
};
}
@@ -49,7 +49,8 @@ export interface PanelToolsContext {
base: () => AlignmentBase | null;
alignment: () => ProfileAlignment | null;
edits: () => AlignmentEdits;
applyEdits: (next: AlignmentEdits) => void;
/** false = 막힘(기울기 · 흙덮기). */
applyEdits: (next: AlignmentEdits) => boolean;
/** 고른 측점 id(없으면 null). */
selectedStationId: () => string | null;
/** 비정규(구조물) 측점 목록 — 좌우 이동 대상 판정에 쓴다. */
@@ -157,10 +158,11 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
history.record();
},
canResetToSaved: ctx.canRestoreSaved,
onUndo: () => inner.undo(),
onRedo: () => inner.redo(),
canUndo: () => inner.canUndo(),
canRedo: () => inner.canRedo(),
// 자유지정 동안 ↶ ↷ = 자유지정 안 한 걸음(367-3) — 큰 이력은 안 건드림.
onUndo: () => (tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()),
onRedo: () => (tools.mode() === "free" ? (free.redo(), freeView.redraw()) : inner.redo()),
canUndo: () => (tools.mode() === "free" ? free.canUndo() : inner.canUndo()),
canRedo: () => (tools.mode() === "free" ? free.canRedo() : inner.canRedo()),
hasStraightRuns: () => {
const alignment = ctx.alignment();
return alignment ? hasStraightRuns(alignment) : false;
@@ -327,6 +329,13 @@ export function createPanelTools(ctx: PanelToolsContext): PanelTools {
// 한 줄 되돌리기(346-4) — 평면 · 종단 · 횡단 · 관 · 구조물을 만진 차례 거꾸로.
if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {
event.preventDefault();
// 자유지정 동안은 자유지정 안만 — 되돌릴 것 없어도 큰 이력으로 안 감(367-3)
if (tools.mode() === "free") {
if (event.shiftKey || isCtrlKey(event, "y")) free.redo();
else free.undo();
freeView.redraw();
return;
}
flushHold();
if (event.shiftKey || isCtrlKey(event, "y")) history.redo();
else history.undo();
@@ -66,7 +66,6 @@ export interface ProfileRenderContext {
callbacks: RouteProfilePanelCallbacks | undefined;
massHaul: {
draw: (params: RouteMassHaulDrawParams) => void;
drawWindow: (params: RouteMassHaulDrawParams) => void;
};
tableOverlay: {
isOpen: () => boolean;
@@ -337,8 +336,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
});
// 세로 창 갱신기 — 가로로 스크롤할 때마다 패널이 이것을 부른다. 도형은 그대로 두고
// 겹 하나의 변환만 갈아 끼우므로 매 프레임 불러도 된다(2026-09-04 사용자 확정).
// 유토곡선 갱신기 — 아래에서 유토곡선을 그린 뒤 채워진다(그리기 순서상 여기서는 아직 없다).
let massHaulUpdater: ((fromM: number, toM: number) => void) | null = null;
ctx.setElevationWindowUpdater(() => {
const from = Math.max(0, toChainage(body.scrollLeft));
const to = Math.min(maxChainageM, toChainage(body.scrollLeft + body.clientWidth));
@@ -349,7 +346,6 @@ export function renderProfile(ctx: ProfileRenderContext): void {
to,
) ?? null,
);
massHaulUpdater?.(from, to);
return applyElevationWindow(chartWrap, next);
});
@@ -452,15 +448,5 @@ export function renderProfile(ctx: ProfileRenderContext): void {
onSelectStation: ctx.selectStation,
};
massHaul.draw(massHaulParams);
// 유토곡선도 **같은 창**을 본다(2026-09-04 사용자 확정) — 스크롤할 때마다 곡선만 다시
// 그린다. 종단처럼 변환으로 옮기지 않는 이유: 곡선 위에 앉는 말풍선·EP 표·측점 점이
// 세로로 늘어나면 안 되는 것들이라, 그것만 따로 옮기는 값이 곡선 자체를 다시 그리는
// 값보다 크다(요소 수가 종단의 1/3). 열림 · 분배 맞춤 아닐 때만 · 한 틀 한 번 · 다시 셈 없음(354-3).
massHaulUpdater = (fromM, toM) => {
massHaul.drawWindow({
...massHaulParams,
axis: { ...massHaulParams.axis, viewRange: { fromM, toM } },
});
};
}
}
+11 -9
View File
@@ -88,8 +88,9 @@
pointer-events: auto;
}
/* 접음 = 판 높이 0 · 손잡이만 판 위 경계에 매달림(배수유역 접힘 꼴 · 367-2). */
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) {
height: auto;
height: 0;
}
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head) {
@@ -97,10 +98,13 @@
}
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head {
position: relative;
top: 0;
left: 0;
padding: 2px var(--spacing-8);
top: auto;
bottom: 100%;
}
/* [창 분할]은 펼친 때만(367-2) — 새 창으로 뗀 [창 결합]은 그대로. */
.b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout {
display: none;
}
/* 종단 패널을 접으면 판도 함께 숨는다(배수유역도와 같음). */
@@ -129,14 +133,12 @@
background: var(--color-surface);
}
/* 곡선 가로 스크롤러 — 종단 스크롤러와 scrollLeft를 동기화한다(JS). 세로는 잘라낸다.
스크롤바는 항상 표시(scroll) — auto면 생겼다 없어질 때 곡선 높이가 널뛰고,
clientHeight 기반 높이 계산도 함께 흔들린다. */
/* 곡선 칸 — 늘 전체 맞춤이라 스크롤 없음(367-1). */
.b05-profile__masshaul-scroll {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-x: scroll;
overflow-x: hidden;
overflow-y: hidden;
}
+3 -2
View File
@@ -352,8 +352,9 @@
stroke-width: 1.5;
}
/* 지나온 선 · 미리 보기 · 점 = 녹색(367-4) — 범위 띠 · 경계는 보라 그대로. */
.b05-profile-freemark__line {
stroke: var(--color-primary);
stroke: var(--color-success);
stroke-width: 2;
stroke-dasharray: 6 4;
}
@@ -368,7 +369,7 @@
.b05-profile-freemark__dot {
fill: var(--color-surface);
stroke: var(--color-primary);
stroke: var(--color-success);
stroke-width: 1.5;
}
+2 -1
View File
@@ -786,7 +786,8 @@ export function createMassHaulLegend(
reset.textContent = L("B06_MassHaul_Balloon_Reset");
reset.title = L("B06_MassHaul_Balloon_Reset_Tip");
reset.addEventListener("click", onResetBalloons);
legend.append(reset);
// [횡단 기준] 왼쪽(367-1)
legend.prepend(reset);
}
return legend;
}
+72 -3
View File
@@ -26,7 +26,9 @@ function loadTs(file) {
}
const free = loadTs(path.join(ROOT, "A00_Common", "design", "logic", "design_profile_free.ts"));
const align = loadTs(path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"));
const align = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Profile_Alignment.ts"),
);
// 전체 측점 폴리라인(20m 간격 · 0~200m) — 계획고 = 지반고, 마디마다 기울기가 다르다.
const stations = Array.from({ length: 11 }, (_, i) => i * 20);
@@ -124,14 +126,23 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.radiiKeys = Object.keys(next.curve_radii).sort();
const built = align.buildAlignment(base, next);
const pvi = (s) => built.pvi.find((n) => Math.abs(n.chainage_m - s) < 1e-6).elevation_m;
out.pvi = { 40: pvi(40), 60: pvi(60), 80: pvi(80), 100: pvi(100), 120: pvi(120), 140: pvi(140), 160: pvi(160) };
out.pvi = {
40: pvi(40),
60: pvi(60),
80: pvi(80),
100: pvi(100),
120: pvi(120),
140: pvi(140),
160: pvi(160),
};
out.ground = { 40: z0(40), 160: z0(160) };
const g1 = (103.46 - z0(40)) / 40;
const g2 = (101.2 - 103.46) / 40;
out.expectR80 = Number((20 / Math.abs(g2 - g1)).toFixed(6));
out.r80 = next.curve_radii["80.000"];
out.curve80L = built.curves.find((c) => Math.abs(c.chainage_m - 80) < 1e-6)?.l_m ?? null;
out.outsideUntouched = !("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
out.outsideUntouched =
!("20.000" in next.station_offsets) && !("180.000" in next.station_offsets);
}
// ④ Enter = 끝(마지막 점 → 끝 세로선) · 끝 세로선에서 거꾸로 시작.
@@ -172,4 +183,62 @@ out.snapOutside = free.snapStation(stations, 190, [40, 160]);
out.enterOutsideDrawing = draw.key("Enter");
}
// ⑥ 되돌리기 · 다시하기(367-3) — 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · apply 안 불림 · 범위까지 거꾸로.
{
const { draw, log } = harness();
out.undoEmpty = { can: draw.canUndo(), backspace: draw.key("Backspace") };
draw.click(40, 0, stations);
draw.click(40, 0, stations); // 같은 세로선 다시 — 상태 그대로 · 안 쌓임
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.click(120, 103, stations);
const dots = () => draw.marks().dots.map((d) => d.chainage_m);
draw.undo();
out.undo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.redo();
out.redo1 = { dots: dots(), canRedo: draw.canRedo() };
draw.undo();
draw.click(100, 104, stations); // 새 클릭 = future 비움
out.newClick = { dots: dots(), canRedo: draw.canRedo() };
const phases = [];
while (draw.canUndo()) {
draw.undo();
phases.push(`${draw.phase()}:${draw.marks().edges.length}`);
}
out.undoAll = phases;
out.undoApplied = log.applied.length;
out.undoClosed = log.closed;
draw.redo();
draw.redo();
out.redoTwice = { phase: draw.phase(), band: draw.marks().band };
draw.reset();
out.resetClears = { undo: draw.canUndo(), redo: draw.canRedo() };
}
// ⑦ 적용이 막히면(apply = false) 자유지정 열린 채 · 점 그대로.
{
let tries = 0;
const draw = free.createFreeDraw({
base: () => base,
alignment: () => align.buildAlignment(base, align.emptyEdits()),
edits: () => align.emptyEdits(),
apply: () => ((tries += 1), false),
close: () => (tries += 100),
changed: () => undefined,
});
draw.click(40, 0, stations);
draw.click(160, 0, stations);
draw.click(40, 0, stations);
draw.click(80, 102, stations);
draw.key("Enter");
draw.click(160, 0, stations);
out.blocked = {
tries,
phase: draw.phase(),
dots: draw.marks().dots.length,
canUndo: draw.canUndo(),
};
}
process.stdout.write(JSON.stringify(out));
+33 -15
View File
@@ -489,7 +489,8 @@ function makePage() {
return app;
}
function fakeDocument(app) {
const doc = { body: new El("BODY") };
// 374-1 뜨는 자리 = body(모달 포함) · 원 · 배율 글은 html 바로 아래
const doc = { body: app, documentElement: new El("HTML") };
doc.createElement = (tag) => new El(tag.toUpperCase());
doc.getElementById = (id) => (id === "app" ? app : null);
return doc;
@@ -515,10 +516,12 @@ function fakeDocument(app) {
};
const view = m.createLoupeView(win);
view.show(500, 400);
const root = doc.body.children[0];
const label = doc.body.children[1];
const root = doc.documentElement.children[0];
const label = doc.documentElement.children[1];
const stage = root.children[0];
const page = stage.children[0];
const scaler = stage.children[0]; // 이동 = stage · 배율 = scaler
const page = scaler.children[0];
const lens = () => `${stage.style.transform} ${scaler.style.transform}`;
const copy = page.children[0];
const [scroller, offscreen, table, svg, frame, cadBox] = copy.children;
const [canvas, blank, input, check] = scroller.children;
@@ -529,7 +532,8 @@ function fakeDocument(app) {
ariaHidden: root.attrs["aria-hidden"],
hidden: root.hidden,
rootTransform: root.style.transform,
stageTransform: stage.style.transform,
stageTransform: lens(),
stageMoveOnly: stage.style.transform,
pageBox: [page.style.left, page.style.top, page.style.width, page.style.height],
isClone: copy.cloned === true && copy !== app,
canvasDrawn: canvas.drawn.map((d) => d[0]),
@@ -560,19 +564,31 @@ function fakeDocument(app) {
hidden: label.hidden,
};
view.setZoom(3);
r.label.zoom3 = [label.textContent, stage.style.transform, root.style.transform];
r.label.zoom3 = [label.textContent, lens(), root.style.transform];
view.move(500, 790);
r.label.bottom = label.style.transform;
view.setZoom(2);
view.move(500, 400);
// 화면이 바뀜 — 0.5 초 뒤 한 번만 다시 뜸
// 화면이 바뀜 — 멈춘 뒤 0.5 초에 한 번만 다시 뜸
const firstCopy = copy;
observers[0].fn();
observers[0].fn();
win.tick(499);
const sameBefore = stage.children[0].children[0] === firstCopy;
const sameBefore = scaler.children[0].children[0] === firstCopy;
win.tick(1);
const recaptured = stage.children[0].children[0] !== firstCopy && stage.children.length === 1;
const recaptured = scaler.children[0].children[0] !== firstCopy && scaler.children.length === 1;
// 374-1 움직이는 동안 안 뜸 — 바뀜 뒤 움직이면 처음부터 · 멈춘 뒤 0.5 초에 한 번
const moving = scaler.children[0].children[0];
observers[0].fn();
win.tick(300);
view.move(520, 400);
win.tick(300);
view.move(540, 400);
win.tick(499);
const heldWhileMoving = scaler.children[0].children[0] === moving;
win.tick(1);
r.debounce = { heldWhileMoving, afterStop: scaler.children[0].children[0] !== moving };
view.move(500, 400);
view.move(10, 10);
const nearEdge = root.style.transform;
view.hide();
@@ -580,20 +596,21 @@ function fakeDocument(app) {
sameBefore,
recaptured,
nearEdge,
stageChildren: stage.children.length,
stageChildren: scaler.children.length,
hidden: root.hidden,
disconnected: observers[0].watching === null,
scrollListen: win.calls.some((c) => c.type === "scroll"),
};
observers[0].fn();
win.tick(1000);
r.hide.noRecaptureAfterHide = stage.children.length;
r.hide.noRecaptureAfterHide = scaler.children.length;
r.hide.labelHidden = label.hidden;
view.setZoom(4);
view.hide();
view.show(500, 400);
r.hide.reopenLabel = [label.textContent, label.hidden, stage.style.transform];
r.hide.reuseRoot = doc.body.children.length;
r.hide.reopenLabel = [label.textContent, label.hidden, lens()];
r.hide.reuseRoot = doc.documentElement.children.length;
r.hide.bodyUntouched = app.children.length; // 원이 body 에 안 붙음
}
{
// 221-2 스크롤 — 스크롤한 뒤 켬 · 켠 채 스크롤(칸 · 문서)
@@ -606,8 +623,9 @@ function fakeDocument(app) {
disconnect() {}
};
const view = m.createLoupeView(win);
const cloneScroller = () => doc.body.children[0].children[0].children[0].children[0].children[0];
const page = () => doc.body.children[0].children[0].children[0];
const scaler = () => doc.documentElement.children[0].children[0].children[0];
const cloneScroller = () => scaler().children[0].children[0].children[0];
const page = () => scaler().children[0];
view.show(500, 400);
view.hide();
scroller.scrollTop = 0; // 0 에서 켬 → 스크롤 칸으로 잡힘
@@ -23,7 +23,7 @@ const mass = read("B05_Profile", "B05_Profile_UI_Profile_MassHaul.ts");
let stored = null;
const src = [
'const SERIES_BASES = ["cross", "longitudinal"];',
'const DEFAULT_VISIBLE = ["cross"];',
'const DEFAULT_VISIBLE = ["cross", "balance"];', // 367-1 기본 = 횡단 + 분배
block(util, "export function normalizeVisibleBasis("),
block(mass, "function readVisible(): Set<string> {"),
block(util, "export function computeMassHaulSeries("),
@@ -115,6 +115,38 @@ def test_Backspace_와_Esc_취소(out):
assert out["enterOutsideDrawing"] is False
def test_자유지정_되돌리기_다시하기(out):
# 367-3 ⑥ 점 둘 → ↶ → ↷ → 새 클릭이 ↷ 지움 · 큰 이력(apply · close) 안 건드림
assert out["undoEmpty"] == {"can": False, "backspace": False}
assert out["undo1"] == {"dots": [40, 80], "canRedo": True}
assert out["redo1"] == {"dots": [40, 80, 120], "canRedo": False}
assert out["newClick"] == {"dots": [40, 80, 100], "canRedo": False}
# 한 걸음 = 점 · 시작 · 범위 끝 · 범위 첫 · 같은 세로선 다시 누름은 안 쌓임
assert out["undoAll"] == ["drawing:2", "drawing:2", "armed:2", "range:1", "range:0"]
assert out["undoApplied"] == 0 and out["undoClosed"] == 0
assert out["redoTwice"] == {"phase": "armed", "band": [40, 160]}
assert out["resetClears"] == {"undo": False, "redo": False}
def test_적용_막히면_열린_채(out):
# 367-3 ⑦ Enter · 끝 세로선 둘 다 apply 시도(2) · close 없음 · 점 그대로
assert out["blocked"] == {"tries": 2, "phase": "drawing", "dots": 2, "canUndo": True}
def test_되돌리기_배선_자유지정_먼저():
panel = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
assert 'tools.mode() === "free" ? (free.undo(), freeView.redraw()) : inner.undo()' in panel
assert 'tools.mode() === "free" ? free.canUndo() : inner.canUndo()' in panel
keys = panel[panel.index('isCtrlKey(event, "z") || isCtrlKey(event, "y")') :]
free_at = keys.index('if (tools.mode() === "free") {')
assert (
free_at < keys.index("history.undo()")
and "return;" in keys[free_at : keys.index("flushHold();")]
)
profile = _read("A00_Common/design/design_profile.ts")
assert "function applyEdits(next: AlignmentEdits): boolean {" in profile
def test_도구줄_직선화_오른쪽_자유지정():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Tools.ts")
assert '"자유지정"' in tools
@@ -142,6 +174,28 @@ def test_오버레이_점선_빨강_글():
assert ".b05-profile-freemark__line.is-over" in css
def _css_rule(css: str, selector: str) -> str:
at = css.index(selector + " {")
return css[at : css.index("}", at)]
def test_자유지정_녹색_넘음_빨강_띠_보라():
# 367-4 선 · 미리 보기 · 점 = --color-success · 넘음 빨강 · 범위 띠 · 경계 보라 그대로
css = _read("B05_Profile/B05_Profile_UI_Style_Table.css")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__line")
assert "stroke: var(--color-success);" in _css_rule(css, ".b05-profile-freemark__dot")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__line.is-over")
assert "var(--color-danger" in _css_rule(css, ".b05-profile-freemark__grade.is-over")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__band")
assert "--color-primary" in _css_rule(css, ".b05-profile-freemark__edge")
# 밝음 · 어두움(명시 · OS 자동) 모두 success · danger 토큰 있음
theme = _read("ui_template/ui_template_theme.css")
auto = theme[theme.index("@media (prefers-color-scheme: dark)") :]
auto = auto[: auto.index("\n}\n")]
for block in (_css_rule(theme, ":root"), _css_rule(theme, ':root[data-theme="dark"]'), auto):
assert "--color-success:" in block and "--color-danger:" in block
def test_줄_수_늘리지_않음():
for relative in (
"A00_Common/design/design_profile.ts",
+20
View File
@@ -162,6 +162,26 @@ def test_바뀌면_0_5초_뒤_다시_뜸_끄면_복제_지움(r):
assert h["hidden"] and h["disconnected"] and h["scrollListen"] is False
assert h["noRecaptureAfterHide"] == 0
assert h["reuseRoot"] == 2 # 원 · 배율 글 하나씩을 다시 씀
assert h["bodyUntouched"] == 6 # 원 · 글은 html 바로 아래 — body 복제 · 관찰에 안 걸림(374-1)
def test_움직이는_동안_안_뜸_멈춘_뒤_한_번(r):
# 374-1 디바운스 — 바뀜 뒤 계속 움직이면 안 뜸 · 마지막 움직임 0.5 초 뒤 한 번
assert r["debounce"] == {"heldWhileMoving": True, "afterStop": True}
# 이동 = 바깥 판 translate 만(합성층) · 배율 = 안쪽 상자
assert r["capture"]["stageMoveOnly"] == "translate(-820px, -620px)"
css = _read("ui_template/ui_template_loupe.css")
stage = css[css.index(".ui-loupe__stage {") :]
assert "will-change: transform;" in stage[: stage.index("}")]
scale = css[css.index(".ui-loupe__scale {") :]
assert "transform-origin: 0 0;" in scale[: scale.index("}")]
def test_뜨는_자리_화면_전체_모달_포함():
src = _read("ui_template/ui_template_loupe.ts")
assert "sourceOf: () => Element | null = () => win.document.body," in src
assert "doc.documentElement.append(root, label);" in src
assert 'getElementById("app")' not in src
def test_테두리_그림자_토큰():
@@ -11,7 +11,6 @@
import json
import os
import re
import subprocess
from pathlib import Path
@@ -65,7 +64,5 @@ def test_끌기도_유토_판_안_셈():
def test_뗀_유토는_노선_전체_세로():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert re.search(
r'viewRange: overlay\.classList\.contains\("is-popout"\) \? undefined : params\.axis\.viewRange',
mass,
)
# 367-1 붙음 · 뗌 모두 노선 전체 세로
assert "viewRange: undefined," in mass
@@ -1,7 +1,7 @@
# -*- coding: utf-8 -*-
"""224-1c(PLAN 325-3) 유토곡선 · 배수유역 [창 분할] — 종단 [창 분할]과 같은 양식 · 따로 새 창.
- 단추 = 두 판 머리줄 맨 앞(유토는 접어도 남는 머리줄 · 354-1) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 단추 = 두 판 머리줄 맨 앞(유토는 펼친 때만 보임 · 367-2) · 글 [창 분할]/[창 결합] · 툴팁에 판 이름.
- 창 이름 셋 다름(같으면 앞 창 body 를 덮어 조각이 사라짐).
- 뗌 = 새 창 body · 뗀 유토 판 손잡이 · 리사이저는 CSS 로 감춤 · 배수 지도 관찰 rehome.
- 붙임(단추 · 새 창 닫힘 · 페이지 떠남) = 제자리 — 종단 열 끝(종단 칸 아래) · 배수 content 끝.
+3 -1
View File
@@ -48,7 +48,9 @@ def test_Ctrl_Z_는_사진첩_한_줄(out):
def test_종단도_Ctrl_Z_줄이_사진첩_하나():
tools = _read("B05_Profile/B05_Profile_UI_Profile_Panel_Tools.ts")
at = tools.index('if (isCtrlKey(event, "z") || isCtrlKey(event, "y")) {')
assert "undoOwner" not in tools and "history.undo()" in tools[at : at + 240]
assert (
"undoOwner" not in tools and "history.undo()" in tools[at : at + 600]
) # 367-3 자유지정 분기 앞에 섬
history = _read("B05_Profile/B05_Profile_UI_RouteEdit_History.ts")
assert '"keydown"' not in history and "onPageHistory" in history
@@ -3,8 +3,8 @@
- 종단 열(`b05-route-profile__column`) = [종단 칸(bodyWrap), 유토 판] · content 안이라 종단 [창 분할]
(`.is-popout > :not(content)` 숨김)에도 같이 감 · B05 · B06 이 같은 `design_profile` 하나.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 머리줄만(손잡이 · [창 분할] 닿음).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화는 그대로.
- 유토 판은 흐름 칸(겹침 아님) · 자기 리사이저 높이 · 접으면 높이 0 · 손잡이만 매달림 · [창 분할] 숨김(367-2).
- 높이 나눔 · 표 쌓기에서 빠짐(표 bottomOffset 없음) · X 동기화 없음(367-1 늘 전체 맞춤).
"""
import os
@@ -30,7 +30,7 @@ def test_종단_열_아래_자기_칸():
assert 'column.className = "b05-route-profile__column";' in overlays
assert "column.append(bodyWrap, massHaul.overlay);" in overlays
assert "bodyWrap.append(massHaul.overlay" not in overlays
assert "massHaul.attachScrollSync(body);" in overlays
assert "massHaul.attachScrollSync" not in overlays # 367-1 측점 연계 없앰
profile = read("A00_Common/design/design_profile.ts")
assert "content.append(column);" in profile
assert sum(1 for _ in profile.splitlines()) <= 700
@@ -57,6 +57,18 @@ def test_흐름_칸_자기_높이_접으면_머리줄():
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > :not(.b05-profile__masshaul-head)",
)
assert "display: none;" in hide
# 367-2 접음 = 높이 0 · 손잡이 판 위 경계에 매달림 · [창 분할] 숨김(뗀 창 [창 결합] 그대로)
folded = css_block(css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout)")
assert "height: 0;" in folded
head = css_block(
css,
".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) > .b05-profile__masshaul-head",
)
assert "top: auto;" in head and "bottom: 100%;" in head
assert "display: none;" in css_block(
css, ".b05-profile__masshaul-overlay.is-collapsed:not(.is-popout) .b05-subpanel__popout"
)
assert ".is-popout .b05-subpanel__popout" not in css
assert "display: none;" in css_block(
css, ".b05-route-profile.is-collapsed .b05-profile__masshaul-overlay"
)
@@ -37,7 +37,7 @@ def test_저장된_종단_범례는_횡단으로(r):
assert r["longitudinal"] == ["cross"]
assert r["longitudinalBalance"] == ["balance", "cross"]
assert r["cross"] == ["cross"]
assert r["empty"] == ["cross"] and r["broken"] == ["cross"]
assert r["empty"] == ["balance", "cross"] and r["broken"] == ["balance", "cross"]
def test_기준_횡단만_계산(r):
+20 -20
View File
@@ -4,14 +4,13 @@
- 메아리: scroll 은 다음 틀에 옴 → 동기 플래그로 못 막아 한 틀 전 값을 되씀 → 뒤로 튐 →
휠 이동이 접힘. 공용 `linkScrollLeft`(Wheel.ts)가 써 넣은 값으로 온 scroll 을 무시.
가짜 창(다음 틀 scroll) 재현 — 옛 잇기는 뒤로 튐 · 멈춤, 새 잇기는 4 경우 × 휠 쪽 둘 모두 튐 0.
- 유토 스크롤 그리기 = 열림 · 분배 맞춤 아님 · 붙음일 때만 · 한 틀 한 번 · 다시 셈 없음.
- 유토곡선 = 늘 전체 맞춤 · 종단과 안 이음(367-1) — 휠 · 잇기 경우는 공용 도우미 시험으로 남김.
`helper_354_3_scroll_echo.cjs` 가 진짜 휠 · 잇기 TS 를 가짜 창 위에서 돌림.
"""
import json
import os
import re
import subprocess
from pathlib import Path
@@ -64,24 +63,25 @@ def test_새_잇기는_뒤로_튐_0_끝까지(r, case):
assert got["mass"] == (0 if balance else 1200)
def test_유토_표_같은_도우미():
for name in ("MassHaul", "TableOverlay"):
src = (B05 / f"B05_Profile_UI_Profile_{name}.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in src
assert "syncing" not in src
def test_유토_스크롤_그리기는_창_따를_때만_한_틀_한_번():
def test_표만_잇기_유토는_안_이음():
# 367-1 유토곡선 = 늘 전체 맞춤 · 종단 스크롤과 안 이음
table = (B05 / "B05_Profile_UI_Profile_TableOverlay.ts").read_text(encoding="utf-8")
assert "linkScrollLeft(main, scroll" in table
assert "syncing" not in table
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
body = mass[mass.index("function drawWindow(") :]
body = body[: body.index("\n }\n")]
assert '!open || fitted || overlay.classList.contains("is-popout") || !lastSeries' in body
assert "windowState.frame" in body and "frameOf(overlay" in body
assert "draw(lastParams, true)" in body
# 다시 셈 없음 · 총괄값 · 범례는 계열이 바뀔 때만
assert re.search(r"reuse && lastSeries\s*\? lastSeries", mass)
for gone in ("linkScrollLeft", "attachWheelHorizontalScroll", "attachScrollSync", "drawWindow"):
assert gone not in mass, gone
def test_유토는_늘_전체_맞춤_스크롤마다_안_그림():
mass = (B05 / "B05_Profile_UI_Profile_MassHaul.ts").read_text(encoding="utf-8")
assert "hideStations: true," in mass
assert "viewRange: undefined," in mass
assert "scroll.scrollLeft = 0;" in mass
assert "createMassHaulWindowState" not in mass and "scheduleMassHaulSettle" not in mass
assert "if (series !== lastSeries) emitSummary(series);" in mass
# 다시 그려도 제 자리 — 종단 값으로 되쓰는 것은 처음 · 맞춤 뒤 · 창 옮김 뒤만
assert "scroll.scrollLeft = syncedMain.scrollLeft" not in mass
render = (B05 / "B05_Profile_UI_Profile_Render.ts").read_text(encoding="utf-8")
assert "massHaul.drawWindow({" in render
assert "drawWindow" not in render and "massHaulUpdater" not in render
css = (B05 / "B05_Profile_UI_Style_MassHaul.css").read_text(encoding="utf-8")
block = css[css.index(".b05-profile__masshaul-scroll {") :]
assert "overflow-x: hidden;" in block[: block.index("}")]
+10 -1
View File
@@ -24,13 +24,20 @@
display: none;
}
/* 복제를 놓는 판 — 화면(뷰포트) 크기 · fixed 자식의 기준 상자 */
/* 복제를 놓는 판 — 화면(뷰포트) 크기 · 움직임은 translate 만(합성층 · 374-1) */
.ui-loupe__stage {
position: absolute;
left: 0;
top: 0;
width: 100vw;
height: 100dvh;
will-change: transform;
}
/* 배율 상자 — 배율 바꿀 때만 scale · fixed 자식의 기준 상자 */
.ui-loupe__scale {
position: absolute;
inset: 0;
transform-origin: 0 0;
}
@@ -55,6 +62,8 @@
border-radius: 9px;
background: var(--color-bg);
color: var(--color-accent);
/* html 바로 아래라 body 글꼴을 안 물려받음 */
font-family: var(--font-body);
font-size: 11px;
line-height: 18px;
box-shadow: 0 0 0 1px var(--color-accent);
+21 -12
View File
@@ -5,9 +5,10 @@
* 켜는 법 둘 — 오른쪽 단추를 0.4 초 넘게 누르고 있는 동안 · Z 키를 누르고 있는 동안 · 떼면 꺼짐.
* 켜 있는 동안 휠 = 배율(220-1 · 1.5 ~ 6배 · 한 칸 0.25 · 켤 때마다 2배) · 화면 스크롤 · 그래프 휠 줌은 막음.
* 길게 누른 우클릭은 뗀 뒤 메뉴(우리 메뉴 · 브라우저 메뉴)를 안 띄움 · 짧게 누르면 지금 우클릭 그대로.
* 그리기 — 켤 때 `#app` 을 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고, 움직일 때 transform 만 바꿈.
* 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 0.5 초 뒤 다시 뜸 · 끄면 복제를 지움.
* 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 0.5 초 뒤 다시 뜸(비워 둔 화면 밖 칸을 채움).
* 그리기 — 켤 때 지금 보이는 화면 전체(`body` · 모달 포함 · 374-1)를 한 번 떠서(DOM 복제 + 2D canvas 그림 복사 · SVG 그대로) 원 안에 놓고,
* 움직일 때 바깥 판(합성층) translate 만 바꿈 · 배율은 안쪽 상자 scale. 원 · 배율 글은 `html` 바로 아래(복제 · 관찰에 안 걸림).
* 화면 밖 상자는 속을 비우고 크기만 남김 · 켜 있는 동안 화면이 바뀌면 멈춘 뒤 0.5 초에 한 번 다시 뜸(움직이는 동안 안 뜸) · 끄면 복제를 지움.
* 스크롤(221-2) — 뜰 때 스크롤 칸마다 scrollTop · Left 를 복제에 옮김 · 켜진 동안 어느 칸이든 스크롤하면 그 화면 틀(rAF 한 번)에 위치를 맞추고 멈춘 뒤 0.5 초에 다시 뜸(비워 둔 화면 밖 칸을 채움).
* WebGL canvas 는 그림이 남아 있을 때만 옮겨짐.
* CAD 영역(cad_host 상자 · iframe · 221-1) — 커서가 그 위면 켜지 않고 · 켜진 채 들어가면 숨김(나오면 다시) · 휠도 CAD 로 · 복제는 빈칸.
* ========================================================================== */
@@ -28,7 +29,7 @@ export const LOUPE_ZOOM_STEP = 0.25;
const LOUPE_LABEL_H = 18;
/** 오른쪽 단추를 이만큼(ms) 넘게 누르면 켬 */
export const LOUPE_HOLD_MS = 400;
/** 켜 있는 동안 화면이 바뀌면 이만큼(ms) 뒤 다시 뜸 */
/** 켜 있는 동안 화면이 바뀌면 멈춘 뒤 이만큼(ms) 지나 다시 뜸 */
export const LOUPE_REFRESH_MS = 500;
/** 화면 끝과 원 사이 여백(px) — 테두리가 안 잘리게 */
export const LOUPE_EDGE = 4;
@@ -242,11 +243,12 @@ export function initLoupe(
/** 원 · 복제 · 다시 뜨기를 맡음 — `sourceOf()` 가 뜰 영역 */
export function createLoupeView(
win: Window,
sourceOf: () => Element | null = () => win.document.getElementById("app"),
sourceOf: () => Element | null = () => win.document.body,
): LoupeView {
const doc = win.document;
let root: HTMLElement | null = null;
let stage: HTMLElement | null = null;
let stage: HTMLElement | null = null; // 이동만(합성층)
let scaler: HTMLElement | null = null; // 배율만 · 복제를 담음
let label: HTMLElement | null = null;
let zoom = LOUPE_ZOOM;
let at: [number, number] = [0, 0]; // 마지막 커서 — 배율만 바뀔 때 다시 놓음
@@ -263,23 +265,28 @@ export function createLoupeView(
root.style.width = root.style.height = `${LOUPE_DIAMETER}px`;
stage = doc.createElement("div");
stage.className = "ui-loupe__stage";
scaler = doc.createElement("div");
scaler.className = "ui-loupe__scale";
stage.append(scaler);
const cross = doc.createElement("div");
cross.className = "ui-loupe__cross";
root.append(stage, cross);
label = doc.createElement("div");
label.className = "ui-loupe__zoom";
label.setAttribute("aria-hidden", "true");
doc.body.append(root, label);
// body 밖 — body 를 뜨고 지켜보므로 원이 자기 복제 · 자기 움직임 관찰에 안 걸림
doc.documentElement.append(root, label);
};
const capture = (): void => {
const source = sourceOf();
const t0 = win.performance?.now() ?? 0;
stage!.replaceChildren();
if (source) sync = captureInto(source, stage!, win);
scaler!.replaceChildren();
if (source) sync = captureInto(source, scaler!, win);
root!.dataset.captureMs = String(Math.round((win.performance?.now() ?? 0) - t0));
};
/** 다시 뜨기 미룸 — 바뀜 · 스크롤 · 움직임마다 처음부터(멈춘 뒤 한 번 · 374-1) */
const refresh = (): void => {
if (refreshTimer) return;
win.clearTimeout(refreshTimer);
refreshTimer = win.setTimeout(() => {
refreshTimer = 0;
if (observer) capture();
@@ -316,16 +323,18 @@ export function createLoupeView(
move(x, y) {
if (!root || !stage || !label) return;
at = [x, y];
if (refreshTimer) refresh(); // 움직이는 동안 안 뜸
const { left, top } = loupeOrigin(x, y, win.innerWidth, win.innerHeight);
const r = LOUPE_DIAMETER / 2;
root.style.transform = `translate(${left}px, ${top}px)`;
stage.style.transform = `translate(${r - zoom * x}px, ${r - zoom * y}px) scale(${zoom})`;
stage.style.transform = `translate(${r - zoom * x}px, ${r - zoom * y}px)`;
const labelTop = Math.min(top + LOUPE_DIAMETER + 4, win.innerHeight - LOUPE_LABEL_H - 4);
label.style.transform = `translate(${left + r}px, ${labelTop}px) translateX(-50%)`;
},
setZoom(z) {
zoom = z;
if (label) label.textContent = `×${z}`;
if (scaler) scaler.style.transform = `scale(${z})`;
view.move(...at);
},
hide() {
@@ -336,7 +345,7 @@ export function createLoupeView(
win.cancelAnimationFrame(syncFrame);
syncFrame = 0;
win.removeEventListener("scroll", onScroll, true);
stage?.replaceChildren(); // 복제를 지움
scaler?.replaceChildren(); // 복제를 지움
if (root) root.hidden = label!.hidden = true;
},
};
+3
View File
@@ -370,6 +370,9 @@
--color-primary-text: #16121f;
--color-accent: var(--color-lavender-wash);
--color-focus-ring: var(--color-lavender-wash);
--color-success: #3fbb6f;
--color-danger: #e8695a;
--panel-handle-border: #e0b64a;
--map-route-locked: #f472b6;
}