feat(E03): 측점 No.+거리 · 위험 띠 확대 · 이동 · 측점 글 · 900 높이 겹침 · 대표 단면 바로 저장 (PLAN 566-2)

- 좌측 목록 · 횡단 카드 제목 = 공용 stationLabel(누가, 측점 목록 추정 간격, 소수 한 자리)
- 평면 위험 띠 = 공용 attachSvgZoomPan(휠 · 가운데 끌기 · 두 번 누름 원배율) · 측점 글 솎음(logic labelStride · planLabelKeys) · 선 굵기 · 글 크기 고정 · 배율은 다시 그려도 유지
- .e03-page 행 셋(auto auto minmax) — 화면 높이 900 에서 띠 · 본문 겹침 고침
- 대표 단면 별 = 누르면 바로 PUT representative · 뒤에서 E05 보고서 갱신 길(GET report · POST refresh) · [대표 단면 저장] 단추 · 캐시 뺌
- 시험 532_3 logic TS 에 솎음 걸음 · 글 열쇠

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:58:44 +09:00
co-authored by Claude Opus 5.5
parent 6d4964ae55
commit 7d5f516b84
7 changed files with 224 additions and 85 deletions
+5 -2
View File
@@ -9,6 +9,7 @@ import { createIconButton } from "@ui/ui_template_button";
import { el } from "@ui/ui_template_elements";
import { createEmpty } from "@ui/ui_template_empty";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
import { e03Table } from "./E03_StabSection_UI_Table";
import {
listRows,
@@ -29,7 +30,9 @@ export interface ListDraw {
sort: SortMode;
ngOnly: boolean;
active: string | null;
/** 사용자 켬/끔 — 저장 전 캐시 포함 · 없으면 서버 값 */
/** 측점 간격(m) — 측점 글 「No.+거리」 의 분모 */
interval: number;
/** 사용자 켬/끔 — 서버 값(별 누름 = 바로 저장) */
representative: (row: StationRow) => { on: boolean; user: boolean | null };
editable: boolean;
}
@@ -81,7 +84,7 @@ export function buildList(
);
verdict.classList.add("e03-verdict", `e03-verdict--${v}`);
const station = el("span", {
text: row.label?.replace(/^STA\./, "").replace(/\.000$/, "") ?? row.key,
text: stationLabel(row.chainage_m, draw.interval, 1),
children: row.walls
? [el("span", { className: "e03-wall-tag", text: "▦", attrs: { title: t("E03_Walls") } })]
: [],
+66 -44
View File
@@ -24,7 +24,8 @@ import { t, type LocaleKey } from "@ui/ui_template_locale";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { buildMissingBanner } from "../E01_StabRegister/E01_StabRegister_UI_Missing";
import { buildList } from "./E03_StabSection_UI_List";
import { buildPlan } from "./E03_StabSection_UI_Plan";
import { buildPlan, type PlanView } from "./E03_StabSection_UI_Plan";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { card, check, emptyBox, muted } from "./E03_StabSection_UI_Parts";
import { buildResult } from "./E03_StabSection_UI_Result";
import {
@@ -84,7 +85,6 @@ async function api<T>(path: string, init?: RequestInit): Promise<T> {
return body;
}
const repKey = (projectId: string): string => `e03_representative_cache:${projectId}`;
const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
export function renderE03StabSection(root: HTMLElement): void {
@@ -111,9 +111,9 @@ export function renderE03StabSection(root: HTMLElement): void {
const slopeRecs = new Map<string, WhatIfRecord>(); // 측점 열쇠 → 마지막 가정
const wallRecs = new Map<string, WallRecord>();
const supportRecs = new Map<string, SupportRecord>();
let repCache: Record<string, boolean | null> = JSON.parse(
sessionStorage.getItem(repKey(pid)) ?? "{}",
);
let interval = 20; // 측점 간격 — 측점 목록에서 추정(요구 390)
const planView: PlanView = {};
let repSaving: Promise<void> = Promise.resolve(); // 별 누름마다 차례로 저장
// ---- 좌측 ----
const condField = createSelectField({
@@ -158,15 +158,10 @@ export function renderE03StabSection(root: HTMLElement): void {
variant: "ghost",
onClick: () => void show3d(),
});
const repButton = createButton({
label: t("E03_RepSave"),
variant: "ghost",
onClick: () => void saveRep(),
});
const controls = el("div", { className: "e03-panel__row" });
controls.append(condField.root, sortField.root);
const actions = el("div", { className: "ui-sidebar-actions" });
actions.append(repButton, view3dButton, analyzeButton);
actions.append(view3dButton, analyzeButton);
panel.append(controls, ngBox, status, listHost, actions);
// ---- 본문 ----
@@ -187,11 +182,8 @@ export function renderE03StabSection(root: HTMLElement): void {
const repOf = (row: StationRow): { on: boolean; user: boolean | null } => {
const server = row.representative ?? { auto: [], user: null, on: false };
if (!(row.key in repCache)) return { on: server.on, user: server.user };
const user = repCache[row.key];
return { on: user === null ? server.auto.length > 0 : user, user };
return { on: server.on, user: server.user };
};
const repDirty = (): boolean => Object.keys(repCache).length > 0;
const currentRow = (): StationRow | undefined => result?.stations.find((r) => r.key === active);
@@ -217,10 +209,8 @@ export function renderE03StabSection(root: HTMLElement): void {
if (!result.fresh) lines.push(t("E03_Stale"));
}
if (result && !result.editable) lines.push(t("E03_ReadOnly"));
if (repDirty()) lines.push(t("E03_RepUnsaved"));
status.textContent = lines.join("\n");
analyzeButton.disabled = !result?.editable;
repButton.disabled = !result?.editable || !repDirty();
view3dButton.disabled = result?.status !== "ok";
};
@@ -235,22 +225,12 @@ export function renderE03StabSection(root: HTMLElement): void {
sort,
ngOnly,
active,
interval,
representative: repOf,
editable: result.editable,
},
(key) => void select(key),
(key) => {
const row = result!.stations.find((r) => r.key === key)!;
const now = repOf(row).on;
const auto = (row.representative?.auto.length ?? 0) > 0;
// 자동과 같아지면 사용자 값 지움(null) — 서버 값과 같으면 캐시에서 뺌
const next = !now === auto ? null : !now;
if (next === (row.representative?.user ?? null)) delete repCache[key];
else repCache[key] = next;
sessionStorage.setItem(repKey(pid), JSON.stringify(repCache));
paintList();
paintStatus();
},
(key) => void toggleRep(key),
),
);
};
@@ -286,7 +266,7 @@ export function renderE03StabSection(root: HTMLElement): void {
});
const title = el("h3", {
className: "e03-card__title",
text: `${view.label ?? view.key} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
text: `${stationLabel(view.chainage_m, interval, 1)} · ${t(`E03_Cond_${condition}` as LocaleKey)}`,
});
const flags = el("div", { className: "e03-panel__row" });
flags.append(
@@ -399,7 +379,16 @@ export function renderE03StabSection(root: HTMLElement): void {
const paintPlan = (): void => {
planHost.replaceChildren(
result ? buildPlan(result.stations, condition, active, (key) => void select(key)) : el("div"),
result
? buildPlan(
result.stations,
condition,
active,
interval,
planView,
(key) => void select(key),
)
: el("div"),
);
};
@@ -438,7 +427,10 @@ export function renderE03StabSection(root: HTMLElement): void {
condition,
);
const keys = result.stations.map((r) => r.key);
repCache = Object.fromEntries(Object.entries(repCache).filter(([k]) => keys.includes(k)));
interval = inferStationInterval(
result.stations.slice().sort((a, b) => a.chainage_m - b.chainage_m),
);
delete planView.zoom; // 다시 읽음 = 띠 원배율
if (!active || !keys.includes(active)) {
// 처음 = 그 조건 가장 낮은 안전율 측점
const worst = result.stations
@@ -488,20 +480,50 @@ export function renderE03StabSection(root: HTMLElement): void {
}
}
async function saveRep(): Promise<void> {
try {
/** 별 누름 = 바로 저장(요구 390) — 자동과 같아지면 사용자 값 지움(null) · 저장 뒤 보고서 갱신 */
function toggleRep(key: string): Promise<void> {
repSaving = repSaving.then(async () => {
const row = result?.stations.find((r) => r.key === key);
if (!row) return;
const server = row.representative ?? { auto: [], user: null, on: false };
const auto = server.auto.length > 0;
const next = !server.on === auto ? null : !server.on;
const overrides: Record<string, boolean | null> = {};
for (const row of result?.stations ?? []) {
const user = row.key in repCache ? repCache[row.key] : (row.representative?.user ?? null);
if (user !== null) overrides[row.key] = user;
for (const r of result?.stations ?? []) {
const user = r.key === key ? next : (r.representative?.user ?? null);
if (user !== null) overrides[r.key] = user;
}
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
repCache = {};
sessionStorage.removeItem(repKey(pid));
showToast(t("E03_RepSaved"), "success");
await load();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
try {
await api(`${base}/representative`, { method: "PUT", body: JSON.stringify({ overrides }) });
row.representative = { ...server, user: next, on: next === null ? auto : next };
paintList();
void refreshReport();
} catch (error) {
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
}
});
return repSaving;
}
/** 보고서 갱신 — E05 [보고서 갱신] 과 같은 길(GET 이 작업본 없으면 처음 만듦 · 뒤에서) */
let reportRun = 0;
async function refreshReport(): Promise<void> {
const run = ++reportRun;
await sleep(800); // 별을 잇달아 누르면 마지막 한 번만
if (run !== reportRun) return;
try {
const state = await api<{ book: unknown; rev: string | null }>(
`/projects/${pid}/stab/report`,
);
if (!state.book || run !== reportRun) return;
const body = await api<{ overwritten?: string[] }>(`/projects/${pid}/stab/report/refresh`, {
method: "POST",
body: JSON.stringify({ rev: state.rev }),
});
const over = body.overwritten ?? [];
if (over.length) showToast(`${t("E05_Overwritten")} ${over.join(" · ")}`, "warning", 8000);
} catch {
/* 보고서는 E05 를 열 때 stale 로 알림 */
}
}
+113 -35
View File
@@ -3,14 +3,20 @@
* E03 위 평면 위험 띠(PLAN 532-3) — 측점 중심을 이은 노선을 그 조건 판정 색으로(이웃 측점 사이
* 가운데서 끊음 · 북쪽 위 · 같은 축척) · 고른 측점 표시 · 누르면 가장 가까운 측점.
* 범례 = 판정마다 아이콘 + 글 + 측점 수. 계산은 logic `bandSegments` · `planFit`.
* 확대 · 이동 = 공용 `attachSvgZoomPan`(휠 · 가운데 끌기 · 두 번 누름 원배율 · 요구 390) · 측점 글 솎음
* = logic `labelStride` · `planLabelKeys` · 배율 · 폭은 바깥 `PlanView` 가 쥠(다시 지어도 살아남음).
* ========================================================================== */
import { createBadge } from "@ui/ui_template_badge";
import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan";
import { stationLabel } from "../common_util/common_util_station";
import { el } from "@ui/ui_template_elements";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import {
bandSegments,
labelStride,
planFit,
planLabelKeys,
verdictOf,
type Condition,
type Point,
@@ -21,11 +27,20 @@ import { svgEl } from "./E03_StabSection_UI_Section";
const H = 96;
const PAD = 10;
const LABEL_GAP = 64; // 측점 글 사이 최소 화면 간격(px)
/** 띠 배율 · 그때 폭 — 폭이 바뀌면 원배율로 */
export interface PlanView {
zoom?: ZoomPanState;
width?: number;
}
export function buildPlan(
rows: StationRow[],
condition: Condition,
active: string | null,
interval: number,
state: PlanView,
onSelect: (key: string) => void,
): HTMLElement {
const box = card("e03-plan");
@@ -49,47 +64,110 @@ export function buildPlan(
const host = el("div");
box.append(host);
const ro = new ResizeObserver(() => draw());
const draw = (): void => {
if (!host.isConnected) return void ro.disconnect(); // 다시 지은 띠 — 옛 것은 손 뗌
const W = Math.max(host.clientWidth, 300);
const segs = bandSegments(rows, condition);
const all: Point[] = segs.flatMap((s) => s.points);
const svg = svgEl("svg", { viewBox: `0 0 ${W} ${H}`, "data-role": "plan" });
if (all.length) {
const fit = planFit(all, W, H, PAD);
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
for (const seg of segs) {
svg.append(
svgEl("polyline", {
points: seg.points.map((p) => scr(p).join(",")).join(" "),
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
"data-verdict": seg.verdict,
}),
);
}
const centers = rows.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null);
const picked = centers.find((r) => r.key === active);
if (picked) {
const [x, y] = scr([picked.center![0]!, picked.center![1]!]);
svg.append(
svgEl("circle", { cx: x, cy: y, r: 6, class: "e03-plan__pick", "data-key": picked.key }),
);
}
svg.addEventListener("click", (ev) => {
const box2 = svg.getBoundingClientRect();
const px = ((ev.clientX - box2.left) / box2.width) * W;
const py = ((ev.clientY - box2.top) / box2.height) * H;
let best: StationRow | null = null;
let dist = Infinity;
for (const r of centers) {
const [x, y] = scr([r.center![0]!, r.center![1]!]);
const d = Math.hypot(x - px, y - py);
if (d < dist) [best, dist] = [r, d];
}
if (best && dist < 30) onSelect(best.key);
});
}
host.replaceChildren(svg);
if (!all.length) return;
const fit = planFit(all, W, H, PAD);
const scr = (p: Point): Point => [fit.k * p[0] + fit.tx, -fit.k * p[1] + fit.ty];
const layer = svgEl("g", { "data-role": "plan-layer" }) as SVGGElement;
for (const seg of segs) {
layer.append(
svgEl("polyline", {
points: seg.points.map((p) => scr(p).join(",")).join(" "),
class: `e03-plan__seg e03-plan__seg--${seg.verdict}`,
"data-verdict": seg.verdict,
}),
);
}
const centers = rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.sort((a, b) => a.chainage_m - b.chainage_m);
const at = (r: StationRow): Point => scr([r.center![0]!, r.center![1]!]);
// 측점 간격 하나의 화면 길이(원배율) — 중심 이은 길이 ÷ 누가 길이 × 간격
const span =
centers.length > 1 ? centers[centers.length - 1].chainage_m - centers[0].chainage_m : 0;
let len = 0;
for (let i = 1; i < centers.length; i += 1) {
const [a, b] = [at(centers[i - 1]), at(centers[i])];
len += Math.hypot(b[0] - a[0], b[1] - a[1]);
}
const intervalPx = span > 0 ? (len / span) * interval : 0;
const maxScale = Math.min(500, Math.max(8, Math.ceil(240 / Math.max(intervalPx, 1e-3))));
const picked = centers.find((r) => r.key === active);
const pick = picked
? svgEl("circle", {
cx: at(picked)[0],
cy: at(picked)[1],
r: 6,
class: "e03-plan__pick",
"data-key": picked.key,
})
: null;
const labels = svgEl("g", { class: "e03-plan__labels" }) as SVGGElement;
layer.append(labels, ...(pick ? [pick] : []));
svg.append(layer);
let stride = 0;
const apply = (s: ZoomPanState): void => {
state.zoom = s;
labels.style.setProperty("--e03-label-k", String(1 / s.scale)); // 글 · 표시는 확대 안 함
pick?.setAttribute("r", String(6 / s.scale));
const next = intervalPx > 0 ? labelStride(intervalPx * s.scale, LABEL_GAP) : 100;
if (next === stride) return;
stride = next;
const keep = new Set(planLabelKeys(centers, interval, stride, active));
labels.replaceChildren(
...centers
.filter((r) => keep.has(r.key))
.map((r) =>
svgEl(
"text",
{
x: at(r)[0],
y: at(r)[1],
dy: "-0.9em",
class: "e03-label e03-plan__label",
"data-key": r.key,
},
stationLabel(r.chainage_m, interval, 1),
),
),
);
};
const initial = state.width === W ? state.zoom : undefined;
state.width = W;
attachSvgZoomPan({
svg,
layer,
widthPx: W,
heightPx: H,
pad: { left: 0, right: 0, top: 0, bottom: 0 },
wheelZoom: true,
maxScale,
initial,
onChange: apply,
});
apply(initial ?? { scale: 1, tx: 0, ty: 0 });
svg.addEventListener("click", (ev) => {
const box2 = svg.getBoundingClientRect();
const px = ((ev.clientX - box2.left) / box2.width) * W;
const py = ((ev.clientY - box2.top) / box2.height) * H;
const s = state.zoom ?? { scale: 1, tx: 0, ty: 0 };
let best: StationRow | null = null;
let dist = Infinity;
for (const r of centers) {
const [x, y] = at(r);
const d = Math.hypot(s.scale * x + s.tx - px, s.scale * y + s.ty - py);
if (d < dist) [best, dist] = [r, d];
}
if (best && dist < 30) onSelect(best.key);
});
};
new ResizeObserver(draw).observe(host);
ro.observe(host);
return box;
}
+8 -1
View File
@@ -78,7 +78,7 @@
/* ---- 틀 ---- */
.e03-page {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
gap: var(--spacing-8);
height: calc(100dvh - var(--app-header-h));
padding: var(--spacing-8) var(--spacing-12);
@@ -207,6 +207,7 @@
.e03-plan__seg {
fill: none;
stroke-width: 7;
vector-effect: non-scaling-stroke;
stroke-linecap: butt;
stroke-linejoin: round;
cursor: pointer;
@@ -228,6 +229,12 @@
fill: var(--e03-plot);
stroke: var(--e03-critical);
stroke-width: 2;
vector-effect: non-scaling-stroke;
}
.e03-plan__label {
text-anchor: middle;
pointer-events: none;
}
.e03-legend {
@@ -190,6 +190,31 @@ export function planFit(
return { k, tx: width / 2 - (k * (x0 + x1)) / 2, ty: height / 2 + (k * (y0 + y1)) / 2 };
}
/** 평면 띠 측점 글 솎음 걸음 — 측점 간격 화면 길이(px) × 걸음이 minPx 넘는 가장 작은 것 */
export const PLAN_LABEL_STRIDES = [1, 2, 5, 10, 25, 50, 100] as const;
export function labelStride(intervalPx: number, minPx: number): number {
return PLAN_LABEL_STRIDES.find((s) => s * intervalPx >= minPx) ?? 100;
}
/** 측점 글 붙일 열쇠 — 본 측점(간격 배수) 중 걸음 배수 · 걸음 1 이면 보조 측점도 · 고른 측점은 늘 */
export function planLabelKeys(
rows: StationRow[],
interval: number,
stride: number,
active: string | null,
): string[] {
const step = interval > 0 ? interval : 1;
return rows
.filter((r) => r.center && r.center[0] !== null && r.center[1] !== null)
.filter((r) => {
if (r.key === active || stride === 1) return true;
const n = Math.round(r.chainage_m / step);
return Math.abs(r.chainage_m - n * step) < 0.05 && n % stride === 0;
})
.map((r) => r.key);
}
/* ---- 안전율 색 ------------------------------------------------------------ */
/** 등안전율 띠 — 안전율 ÷ 기준안전율 경계(빨강 셋 · 회색 하나 · 파랑 셋 · 가운데 = 기준 통과 직후) */
@@ -411,6 +411,9 @@ process.stdout.write(JSON.stringify({
stress: m.sliceStress([input.slice]),
ticks: m.niceTicks(-3, 47, 5),
zAt: [m.zAt([[0, 0], [10, 10]], 2.5), m.zAt([[10, 10], [0, 0]], -5)],
stride: [m.labelStride(100, 64), m.labelStride(40, 64), m.labelStride(10, 64), m.labelStride(0.1, 64)],
labels: [1, 2, 5].map((k) => m.planLabelKeys(input.rows, 20, k, null)),
labelsActive: m.planLabelKeys(input.rows, 20, 5, "e"),
}));
"""
@@ -541,6 +544,10 @@ def test_logic_TS():
)
assert out["ticks"][0] <= -3 and out["ticks"][-1] >= 47 and 0 in out["ticks"]
assert out["zAt"] == [2.5, 0]
# 평면 띠 측점 글 솎음 — 간격 px × 걸음 ≥ 64 · 본 측점 중 걸음 배수 · 고른 측점은 늘
assert out["stride"] == [1, 2, 10, 100]
assert out["labels"] == [["a", "b", "c", "d", "e"], ["a", "c", "e"], ["a"]]
assert out["labelsActive"] == ["a", "e"]
def test_엔진_지문은_코드_내용():
-3
View File
@@ -25,9 +25,6 @@ export const ui_locales_e03 = {
E03_Fresh: ["입력이 같아 다시 셀 것이 없습니다.", "Inputs unchanged — nothing to recompute."],
E03_View3d: ["3D 보기", "3D View"],
E03_View3d_Legend: ["최소 안전율 ÷ 기준안전율", "Min FS ÷ required FS"],
E03_RepSave: ["대표 단면 저장", "Save Representative"],
E03_RepSaved: ["대표 단면을 저장했습니다.", "Representative sections saved."],
E03_RepUnsaved: ["대표 단면 바뀜 — 저장 안 됨", "Representative changes not saved"],
E03_Loading: ["불러오는 중…", "Loading…"],
E03_NotYet: [
"아직 해석하지 않았습니다 — [해석]을 누르세요.",