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:
@@ -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") } })]
|
||||
: [],
|
||||
|
||||
@@ -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 로 알림 */
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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_엔진_지문은_코드_내용():
|
||||
|
||||
@@ -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: [
|
||||
"아직 해석하지 않았습니다 — [해석]을 누르세요.",
|
||||
|
||||
Reference in New Issue
Block a user