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

This commit is contained in:
2026-10-05 12:28:42 +09:00
6 changed files with 232 additions and 110 deletions
+40 -21
View File
@@ -16,7 +16,7 @@
* `compute_cross_design`(서버), 성토사면 길이는 B06 화면이 쓰는 `fillSlopeLengths`.
* ========================================================================== */
import { attachSvgZoomPan } from "../A00_Common/b_svg_zoom_pan";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { fetchCrossPreview, type CrossPreviewResponse } from "./B05_Profile_Api_Replan";
import {
buildCrossSvg,
@@ -97,10 +97,47 @@ function createPane(title: string, empty: string): CrossPane {
const station = root.querySelector<HTMLElement>(".b05-routeedit__cross-station")!;
const foot = root.querySelector<HTMLElement>(".b05-routeedit__cross-foot")!;
const box = root.querySelector<HTMLElement>(".b05-routeedit__cross-box")!;
/** 그려 둔 횡단 · 그때 판 크기 · 확대·이동 — 판 크기가 바뀌면 이것으로 다시 짓는다. */
let drawn: CrossPreviewResponse | null = null;
let size = { width: 0, height: 0 };
let zoom: ZoomPanState | undefined;
function draw(preview: CrossPreviewResponse): void {
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라가고
// 글자가 늘어나 보이지 않는다. 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깔고,
// 펼쳐지면 아래 ResizeObserver 가 실제 크기로 다시 짓는다.
const rect = box.getBoundingClientRect();
size = { width: Math.round(rect.width), height: Math.round(rect.height) };
const plot = buildCrossSvg(preview, size.width || 240, size.height || 150);
box.replaceChildren(plot.svg);
observer.observe(box); // 창에 붙은 뒤에 지켜본다 — 붙기 전에는 isConnected 가 거짓이다.
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
initial: zoom,
onChange: (state) => (zoom = state),
});
}
const observer = new ResizeObserver(() => {
if (!box.isConnected) return observer.disconnect(); // 편집 창이 닫혔다.
const rect = box.getBoundingClientRect();
const width = Math.round(rect.width);
const height = Math.round(rect.height);
if (!drawn || width === 0 || (width === size.width && height === size.height)) return;
draw(drawn);
});
return {
root,
show(preview, intervalM) {
box.replaceChildren();
drawn = preview;
zoom = undefined;
if (!preview) {
station.textContent = "";
foot.textContent = empty;
@@ -109,30 +146,12 @@ function createPane(title: string, empty: string): CrossPane {
// 측점은 **누가거리가 아니라 측점 표기**로 낸다(계획서 0-9 ㉑) — B05 왼쪽 아래 구조물
// 목록이 쓰는 그 규칙이다. 서버가 주는 `STA.0+100.000` 을 그대로 쓰면 표기가 갈린다.
station.textContent = formatStation(preview.chainage_m, intervalM);
// 판의 실제 크기로 짓는다 — `viewBox` 가 픽셀과 1:1 이라야 휠·팬이 커서를 따라간다.
// 칸이 접혀 있으면(1500px 미만) 0 이 나오므로 최소치를 깐다.
const rect = box.getBoundingClientRect();
const plot = buildCrossSvg(
preview,
Math.max(Math.round(rect.width), 240),
Math.max(Math.round(rect.height), 150),
);
box.append(plot.svg);
// 확대·이동은 **공용 조각**이 맡는다 — B06 횡단 카드와 한 코드다(2026-09-12 지시 ③).
// 판이 둘뿐이라 여기서는 휠을 줌에 묶는다(카드 목록인 B06 은 안 묶는다).
attachSvgZoomPan({
svg: plot.svg,
layer: plot.layer,
widthPx: plot.width,
heightPx: plot.height,
pad: CROSS_PLOT_PAD,
content: plot.content,
wheelZoom: true,
});
draw(preview);
foot.textContent = summarizeCross(preview);
},
wait(text) {
box.replaceChildren();
drawn = null;
foot.textContent = text;
},
};
@@ -235,7 +235,7 @@
.b05-routeedit__cross-key {
fill: var(--color-text-secondary, #94a3b8);
font-size: 5.5px; /* 44-2 — 지금(11px)의 절반 */
font-size: 11px;
}
.b05-routeedit__cross-key.is-design {
@@ -1,22 +1,28 @@
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 접힌 패널은 얇은 띠 */
/* 로직 상세 오른쪽 두 패널 — 흐름도 · 원문 · 공용 접기 카드(접힌 카드 = 제목 행) */
.m01lab-side {
display: flex;
align-items: stretch;
min-width: 0;
}
.m01lab-side__card,
.m01lab-side__slot {
flex: 0 0 40px;
min-width: 0;
}
.m01lab-side__slot.is-open {
flex: 1 1 auto;
/* 카드 제목 행이 접기 단추 — 흐름도 패널 자체 접기 단추 · 테두리는 뺌 */
.m01lab-side .m02-dfp__toggle {
display: none;
}
/* 접힌 흐름도 자리 — 띠만(기다림 글 · 변환 알림은 숨김) */
.m01lab-side__slot:not(.is-open) > :not(.m01lab-flow__panel, .m01lab-src) {
display: none;
.m01lab-side .m02-dfp {
border-left: 0;
background: none;
}
.m01lab-src {
display: flex;
flex-direction: column;
gap: var(--spacing-8);
min-width: 0;
}
.m01lab-src__body {
@@ -1,12 +1,14 @@
/* =============================================================================
* M01_MasterData_UI_LogicLab_Side.ts
* 로직 상세 오른쪽 두 패널 — 「흐름도」(다이어그램 · 상세 내용 = M02_DF_Panel) · 「원문」(품셈 본문 절)
* 둘 중 하나만 열림(하나 열면 다른 하나 접힘) · 둘 다 접기 됨 · 접힌 패널 = 얇은 띠(머리 단추 그대로)
* 공용 접기 카드(`ui-collapsible` · 제목 행 누름 · 캐럿) 두 장 — attachCollapsible 토글 위에 하나만 열림 규칙
* 둘 다 접기 됨 · 접힌 카드 = 제목 행만
* 열린 패널은 계정 취향(`m01-logic-panel`)으로 기억 · 원문은 출처 글로 서버가 찾음(`GET /m01/logic/source`)
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import { attachCollapsible } from "@ui/ui_template_collapsible";
import { el } from "@ui/ui_template_elements";
import { API_BASE_URL } from "@config/config_frontend";
import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state";
import type { SplitHandle } from "./M01_MasterData_UI_LogicLab_Flow";
@@ -14,9 +16,8 @@ import "../M02_MasterTemplete/DataFlow/M02_DF_Panel.css";
import "./M01_MasterData_UI_LogicLab_Side.css";
const TEXT = {
Toggle: ["원문", "Source"],
Open: ["원문 펴기", "Open the source panel"],
Close: ["원문 접기", "Collapse the source panel"],
Flow: ["흐름도", "Flow"],
Source: ["원문", "Source"],
Loading: ["원문 찾는 중", "Looking up the source"],
None: ["원문 연결 없음", "No linked source"],
From: ["출처", "Source"],
@@ -27,8 +28,8 @@ const TEXT = {
const ts = (key: keyof typeof TEXT, v = ""): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{v}", v);
/** 접힌 띠 너비 — M02_DF_Panel 의 STRIP_WIDTH 와 같음(패널 모듈은 늦게 받으므로 따로 둠) */
const STRIP = 40;
/** 둘 다 접힘 — 제목 행 둘이 들어가는 칸 너비 */
const FOLDED = 140;
export type PanelPick = "flow" | "source" | "none";
@@ -86,21 +87,28 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
const flowSlot = el("div", { className: "m01lab-side__slot" });
const body = el("div", { className: "m01lab-src__body" });
const title = el("strong", { className: "m02-dfp__title" });
const toggle = createButton({
label: ts("Toggle"),
size: "sm",
variant: "ghost",
onClick: () => pick(now === "source" ? "none" : "source"),
});
toggle.classList.add("m02-dfp__toggle");
const head = el("header", { className: "m02-dfp__head", children: [toggle, title] });
const open = el("div", { className: "m02-dfp__open", children: [head, body] });
const strip = el("div", { className: "m02-dfp__strip" });
const source = el("aside", { className: "m02-dfp m01lab-src", children: [open, strip] });
const sourceSlot = el("div", { className: "m01lab-side__slot", children: [source] });
split.right.replaceChildren(
el("div", { className: "m01lab-side", children: [flowSlot, sourceSlot] }),
const card = (label: string, inner: HTMLElement): HTMLElement =>
el("section", {
className: "m01lab-side__card ui-collapsible ui-sidebar-section ui-sidebar-card",
children: [el("h3", { className: "ui-collapsible__title", text: label }), inner],
});
const flowCard = card(ts("Flow"), flowSlot);
const sourceCard = card(
ts("Source"),
el("div", { className: "m01lab-src", children: [title, body] }),
);
const side = el("div", {
className: "m01lab-side ui-sidebar-panel",
children: [flowCard, sourceCard],
});
attachCollapsible(side);
// 공용 토글 뒤 — 연 카드만 남기고 다른 카드는 접음(접었으면 둘 다 닫힘)
side.addEventListener("click", (ev) => {
const at = (ev.target as HTMLElement).closest(".ui-collapsible__title")?.parentElement;
if (at !== flowCard && at !== sourceCard) return;
pick(at.classList.contains("is-collapsed") ? "none" : at === flowCard ? "flow" : "source");
});
split.right.replaceChildren(side);
let now = readPick();
let flowPx: number | null = null;
@@ -108,14 +116,22 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
let want = "";
let shown: string | null = null;
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 띠 · 둘 다 접힘 = 띠 둘 */
/** 카드 안팎 여백(좌우 안쪽 + 테두리) — 흐름도 패널 너비에 더함 */
const pad = (): number => {
const s = getComputedStyle(flowCard);
return ["paddingLeft", "paddingRight", "borderLeftWidth", "borderRightWidth"].reduce(
(n, k) => n + (parseFloat(s[k as "paddingLeft"]) || 0),
0,
);
};
/** 칸 너비 — 원문(또는 흐름도가 아직 안 뜸) = 두 칸 비율 · 흐름도 = 패널 너비 + 카드 여백 · 둘 다 접힘 = 제목 행 */
const width = (): void =>
split.setRight(
now === "source" || (now === "flow" && flowPx === null)
? null
: now === "flow"
? (flowPx ?? 0) + STRIP
: STRIP * 2,
? (flowPx ?? 0) + pad()
: FOLDED,
);
const load = (): void => {
@@ -149,21 +165,12 @@ export function buildLogicSide(split: SplitHandle): LogicSide {
});
};
const fold = (on: boolean): void => {
open.hidden = on;
strip.hidden = !on;
(on ? strip : head).prepend(toggle);
toggle.setAttribute("aria-expanded", String(!on));
toggle.title = ts(on ? "Open" : "Close");
};
const pick = (next: PanelPick): void => {
if (next !== now) writeStateRaw("m01-logic-panel", next);
now = next;
flowFold?.(now !== "flow"); // 칸을 줄이기 전에 — 흐름도 패널이 접기 전 너비를 재어 둠
flowSlot.classList.toggle("is-open", now === "flow");
sourceSlot.classList.toggle("is-open", now === "source");
fold(now !== "source");
flowCard.classList.toggle("is-collapsed", now !== "flow");
sourceCard.classList.toggle("is-collapsed", now !== "source");
width();
load();
};
+117 -34
View File
@@ -1,11 +1,16 @@
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기
/* 44-2 — B05 계획노선 편집 · 측점 횡단 글자 크기 · 판 크기 바뀜에도 배율 1
* 시작 화면: B05 종단설계(#/b05-profile) · 환경 검증 프로젝트(OWNERS.md) · 로그인됨 · 계획노선 편집 닫힘
* · 창 너비 1500px 이상(그 아래면 오른쪽 칸이 접힘)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: [계획노선 편집] 열기 → 첫 측점 라벨 누름(앱 모듈의 라벨 상자 자리) → 횡단 판 글자 잼 → [취소]로 닫음
* 기대 수치:
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 5.5px(옛 11px 의 절반)
* 하는 일: [계획노선 편집] 열기 → 오른쪽 칸 크기를 세 갈래로 둔 채 측점 라벨 누름 → 칸을 원래대로 → 잼
* folded = 칸 접힘(display none) 상태로 셈 → 펼침 · narrow = 260×300 으로 셈 → 원래
* wide = 원래 크기로 셈 · shrink = wide 뒤 260×300 으로 줄임 → [취소]로 닫음
* · 탭이 화면에 안 보여 프레임이 없으면(frames = false) ResizeObserver 대역(100ms 재기)을 깔고 돎
* 기대 수치(갈래마다):
* - sx · sy = 화면 크기 ÷ viewBox = 1(±0.02 · 글자가 늘어나 보이지 않음)
* - keys = 원지반 · 기본 계획 횡단 · 「좌 … 표고 …」 셋 모두 fontSize 11px · screenPx(11 × sy) 10~12
* - station.fontSize = title.fontSize(측점 표기 = 제목 「횡단」 크기)
* - 바꾸는 값 없음(취소로 닫음 · 노선 안 고침)
* - 바꾸는 값 없음(칸 크기 임시 style 지움 · 취소로 닫음 · 노선 안 고침)
*/
(async () => {
const w = (ms) => new Promise((r) => setTimeout(r, ms));
@@ -21,13 +26,50 @@
[...document.querySelectorAll("button")].find(
(b) => b.offsetParent && b.textContent.trim() === text,
);
// 화면에 안 보이는 ORCA 탭은 프레임을 안 그려 ResizeObserver 가 안 옴 — 그때만 100ms 마다
// 크기를 재어 같은 방식(처음 한 번 · 바뀔 때마다)으로 부르는 대역을 편집 창 열기 전에 깖
const OrigRO = window.ResizeObserver;
const frames = await Promise.race([
new Promise((r) => requestAnimationFrame(() => r(true))),
w(500).then(() => false),
]);
if (!frames)
window.ResizeObserver = class {
constructor(cb) {
this.cb = cb;
this.t = new Map();
this.iv = setInterval(() => {
for (const [el, last] of this.t) {
const r = el.getBoundingClientRect();
const k = `${r.width}x${r.height}`;
if (k === last) continue;
this.t.set(el, k);
this.cb([{ target: el }], this);
}
}, 100);
}
observe(el) {
if (!this.t.has(el)) this.t.set(el, null);
}
unobserve(el) {
this.t.delete(el);
}
disconnect() {
this.t.clear();
clearInterval(this.iv);
}
};
btn("계획노선 편집")?.click();
const canvas = await until(() =>
[...document.querySelectorAll("canvas")].find(
(c) => c.offsetParent && c.closest("[class*='routeedit']"),
),
);
window.ResizeObserver = OrigRO; // 편집 창(횡단 판)은 다 지어짐
if (!canvas) return JSON.stringify({ ok: false, why: "편집 캔버스 없음" });
const side = document.querySelector(".b05-routeedit__side");
if (!side || !side.offsetParent)
return JSON.stringify({ ok: false, why: "오른쪽 칸 접힘", innerWidth });
// 앱이 실제로 부른 그 모듈 주소(@fs/<폴더>/…) — 같은 주소라야 라벨 상자 WeakMap 을 같이 씀
const url = performance
.getEntriesByType("resource")
@@ -39,34 +81,75 @@
return b.length ? b : null;
});
if (!boxes) return JSON.stringify({ ok: false, why: "측점 라벨 상자 없음" });
const rect = canvas.getBoundingClientRect();
const box = boxes[Math.floor(boxes.length / 2)];
const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true };
canvas.dispatchEvent(new PointerEvent("pointerdown", at));
canvas.dispatchEvent(new PointerEvent("pointerup", at));
const pane = await until(() =>
[...document.querySelectorAll(".b05-routeedit__cross")].find((p) =>
p.querySelector(".b05-routeedit__cross-key"),
),
);
if (!pane) return JSON.stringify({ ok: false, why: "횡단 판 안 뜸", chainage: box.chainage });
const fs = (el) => el && getComputedStyle(el).fontSize;
const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({
text: t.textContent.slice(0, 20),
fontSize: fs(t),
h: +t.getBoundingClientRect().height.toFixed(1),
}));
const title = pane.querySelector(".b05-routeedit__cross-title");
const station = pane.querySelector(".b05-routeedit__cross-station");
const out = {
keys,
title: { text: title.textContent, fontSize: fs(title) },
station: { text: station.textContent, fontSize: fs(station) },
const pane = side.querySelector(".b05-routeedit__cross");
const draw = async () => {
const old = pane.querySelector("svg");
const rect = canvas.getBoundingClientRect();
const box = boxes[Math.floor(boxes.length / 2)];
const at = { clientX: rect.left + box.x, clientY: rect.top + box.y, button: 0, bubbles: true };
canvas.dispatchEvent(new PointerEvent("pointerdown", at));
canvas.dispatchEvent(new PointerEvent("pointerup", at));
return until(() => {
const svg = pane.querySelector("svg");
return svg && svg !== old && svg.querySelector(".b05-routeedit__cross-key");
});
};
out.ok =
keys.length === 3 &&
keys.every((k) => k.fontSize === "5.5px") &&
out.title.fontSize === out.station.fontSize;
btn("취소")?.click();
return JSON.stringify(out);
const fs = (el) => el && getComputedStyle(el).fontSize;
const measure = (tag) => {
const svg = pane.querySelector("svg");
const vb = svg.viewBox.baseVal;
const r = svg.getBoundingClientRect();
const sx = +(r.width / vb.width).toFixed(3);
const sy = +(r.height / vb.height).toFixed(3);
const keys = [...pane.querySelectorAll(".b05-routeedit__cross-key")].map((t) => ({
text: t.textContent.slice(0, 12),
fontSize: fs(t),
screenPx: +(parseFloat(fs(t)) * sy).toFixed(1),
h: +t.getBoundingClientRect().height.toFixed(1),
}));
const title = pane.querySelector(".b05-routeedit__cross-title");
const station = pane.querySelector(".b05-routeedit__cross-station");
const res = {
tag,
viewBox: [vb.width, vb.height],
box: [Math.round(r.width), Math.round(r.height)],
sx,
sy,
keys,
title: fs(title),
station: fs(station),
};
res.ok =
Math.abs(sx - 1) <= 0.02 &&
Math.abs(sy - 1) <= 0.02 &&
keys.length === 3 &&
keys.every((k) => k.fontSize === "11px" && k.screenPx >= 10 && k.screenPx <= 12) &&
res.title === res.station;
return res;
};
const narrow = () => Object.assign(side.style, { width: "260px", height: "300px" });
const reset = () => Object.assign(side.style, { display: "", width: "", height: "" });
const out = [];
try {
side.style.display = "none";
if (!(await draw())) return JSON.stringify({ ok: false, why: "접힘 · 횡단 안 뜸" });
reset();
await w(600);
out.push(measure("folded"));
narrow();
if (!(await draw())) return JSON.stringify({ ok: false, why: "좁음 · 횡단 안 뜸" });
reset();
await w(600);
out.push(measure("narrow"));
if (!(await draw())) return JSON.stringify({ ok: false, why: "넓음 · 횡단 안 뜸" });
await w(300);
out.push(measure("wide"));
narrow();
await w(600);
out.push(measure("shrink"));
} finally {
reset();
btn("취소")?.click();
}
return JSON.stringify({ ok: out.every((o) => o.ok), innerWidth, frames, out });
})();
@@ -4,10 +4,11 @@
* 로직 GC000277(건설품셈 토목 1-1-2 유도선 설치 및 해체)을 엶 · 고치지 않음 · 끝에 패널 취향을 처음 값으로 되돌림.
* 기대 수치:
* order = ["기본정보","시험 계산","단가산출 상세","텍스트 수식"](흐름 로직이면 셋째 · 넷째는 흐름 보기 안)
* flow = {flow:true, source:false, pref:"flow"} · 오른쪽 칸 너비 > 300
* source = {flow:false, source:true, pref:"source"} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」
* none = {flow:false, source:false, pref:"none"} · 오른쪽 칸 너비 = 80(띠 둘)
* back = {flow:true, source:false, pref:"flow"}(흐름도 띠 단추로 다시 폄)
* 두 패널 = 공용 접기 카드(ui-collapsible · 제목 행 누름) · 흐름도 패널 자체 접기 단추는 안 보임(dfpToggle 0)
* flow = {flow:true, source:false, pref:"flow", caret:["▾","▸"]} · 오른쪽 칸 너비 > 300
* source = {flow:false, source:true, pref:"source", caret:["▸","▾"]} · srcHead 에 「1-1-2 유도선」 · srcTable 에 「특별인부」
* none = {flow:false, source:false, pref:"none", caret:["▸","▸"]} · 오른쪽 칸 너비 = 140(제목 행 둘)
* back = {flow:true, source:false, pref:"flow"}(흐름도 제목 행으로 다시 폄)
* noLink = 「원문 연결 없음」 + 「출처 · …」(서버가 빈 목록을 줄 때 · fetch 를 잠시 바꿔 봄)
* restored = 처음 취향 값
*/
@@ -43,17 +44,23 @@
out.order = $$(".m01lab-flow__left .m01lab__box > .m01-logic__section-head h3").map((h) =>
h.innerText.trim(),
);
const slots = () => $$(".m01lab-side__slot");
const cards = () => $$(".m01lab-side__card");
const heads = () => cards().map((c) => $(".ui-collapsible__title", c));
const shown = (n) => !!n && n.getBoundingClientRect().height > 0;
const state = () => ({
flow:
slots()[0].classList.contains("is-open") &&
!$(".m02-dfp", slots()[0]).classList.contains("is-folded"),
source: slots()[1].classList.contains("is-open") && !$(".m02-dfp__open", slots()[1]).hidden,
!cards()[0].classList.contains("is-collapsed") &&
shown($(".m02-dfp__open", cards()[0])) &&
!$(".m02-dfp", cards()[0]).classList.contains("is-folded"),
source: !cards()[1].classList.contains("is-collapsed") && shown($(".m01lab-src", cards()[1])),
pref: localStorage.getItem(PREF),
caret: heads().map((h) => getComputedStyle(h, "::after").content.replace(/"/g, "")),
width: Math.round($(".m01lab-flow__right").getBoundingClientRect().width),
});
const flowToggle = () => $(".m02-dfp__toggle", slots()[0]);
const srcToggle = () => $(".m02-dfp__toggle", slots()[1]);
const flowToggle = () => heads()[0];
const srcToggle = () => heads()[1];
out.titles = heads().map((h) => h.innerText.trim());
out.dfpToggle = $$(".m01lab-side .m02-dfp__toggle").filter(shown).length;
// 처음 = 흐름도 열림으로 맞춤
if (!state().flow) {
if (state().source) srcToggle().click();