fix(B05): 계획노선 편집 횡단 판 크기 바뀜 때 다시 지어 배율 1 · 글자 11px 복귀 (44-2 덧)
- 판이 접힌 · 좁은 때 지은 viewBox 가 펼쳐질 때 통째로 늘어나 글자가 커 보이던 길 없앰 - ResizeObserver 로 판 크기가 바뀌면 실제 크기로 다시 지음 · 확대 · 이동 상태 유지 - 최소치(240×150)는 판이 0 일 때만 · 범례 · 좌우 표고 글 11px - 44-2 시나리오 — 접힘 · 좁음 · 넓음 · 줄임 네 갈래 배율 · 글자 높이 잼 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013rJwJMVL4EE31y77TB11AP
This commit is contained in:
@@ -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,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 });
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user