feat(E01·E04·E06·E07): 측점 글 = No.+거리 소수 한 자리 · 간격 = 측점 목록 추정 (PLAN 566-4)

- 공용 stationLabel · inferStationInterval 로 한 벌(예 61+14.5) · 서버 label(STA. 꼴) 안 읽음 · 화면만
- E07 — 측점 표 · 지도 측점 글 · 셀 툴팁 · 지도 글 솎음이 20 m 고정 대신 추정 간격
- E04 — 구간 목록 · 위치(가까운 측점) · 지도 측점 글 · 툴팁 · 솎음 간격
- E06 — 위험 띠 · 측점 표 · 상세 제목 · 스테레오넷 제목 · 띠 양 끝 글
- E01 빠진 측점 띠 — interval 인자(기본 20 m · E02 · E03 이 측점 간격을 넘기면 그 값)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 20:07:47 +09:00
co-authored by Claude Opus 5.5
parent 95e352205d
commit 53c30db57b
7 changed files with 39 additions and 22 deletions
@@ -8,15 +8,17 @@
import { createBanner } from "@ui/ui_template_banner";
import { el } from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { stationLabel } from "../common_util/common_util_station";
/** 빠진 측점이 없으면 빈 칸(띠 없음) */
export function buildMissingBanner(missing: number[] | undefined): HTMLElement {
/** 빠진 측점이 없으면 빈 칸(띠 없음) · 측점 글 = No.+거리 — interval = 측점 간격(부르는 쪽이
* `inferStationInterval(측점 목록)` 으로 넘김 · 없으면 20 m) */
export function buildMissingBanner(missing: number[] | undefined, interval = 20): HTMLElement {
const list = missing ?? [];
if (!list.length) return el("div");
const box = createBanner({
tone: "warning",
text: t("E_Missing_Head").replace("{n}", String(list.length)),
details: el("p", { text: list.map((m) => `${Math.round(m * 100) / 100} m`).join(" · ") }),
details: el("p", { text: list.map((m) => stationLabel(m, interval, 1)).join(" · ") }),
});
box.classList.add("e-missing");
box.dataset.role = "missing-stations";
+7 -6
View File
@@ -7,6 +7,7 @@
import { el } from "@ui/ui_template_elements";
import { tooltipBox } from "@ui/ui_template_hover_info";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { t } from "@ui/ui_template_locale";
import { cutCells, footprintCenter } from "./logic/E04_Stab3D_Mesh";
import {
@@ -43,8 +44,6 @@ interface View {
const RANGE_COLOR = "#2563eb"; // 고른 구간 — 3D 노선 관과 같은 색 · 안전율 색 단계와 겹치지 않음
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
@@ -60,6 +59,7 @@ export function createPlanMap(): PlanMap {
let line: number[][] = [];
let stations: StationPoint[] = [];
let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정
let range: [number, number] | null = null;
let result: Result3d | null = null;
let layer: MapLayer = "fs";
@@ -140,8 +140,8 @@ export function createPlanMap(): PlanMap {
ctx.stroke();
}
// 측점 — 화면 간격이 좁으면 다섯 개마다 이름
// 이름 = "STA.0+020.000" → "0+020" · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
const gap = stations.length > 1 ? 20 * view.scale : 999;
// 이름 = No.+거리(예 61+14.5) · 이름끼리 70 px 이상 떨어지게 1 · 2 · 5 · 10 … 개마다
const gap = stations.length > 1 ? interval * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
@@ -152,7 +152,7 @@ export function createPlanMap(): PlanMap {
ctx.beginPath();
ctx.arc(px, py, i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if (i % every === 0 && s.label) ctx.fillText(shortLabel(s.label), px + 6, py - 8);
if (i % every === 0) ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 6, py - 8);
});
}
@@ -311,7 +311,7 @@ export function createPlanMap(): PlanMap {
tip.textContent = [
`${t("E04_Layer_FS")} ${f.toFixed(3)}`,
`${t("E04_Layer_Volume")} ${v === null ? "-" : `${Math.round(v).toLocaleString()} m³`}`,
near ? `${t("E04_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
near ? `${t("E04_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "",
]
.filter(Boolean)
.join("\n");
@@ -351,6 +351,7 @@ export function createPlanMap(): PlanMap {
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
interval = inferStationInterval(stations);
fit();
draw();
},
+7 -3
View File
@@ -24,6 +24,7 @@ import { createSegment } from "@ui/ui_template_segment";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { createPlanMap, type MapLayer, type StationPoint } from "./E04_Stab3D_UI_Map";
import { createView3D } from "./E04_Stab3D_UI_View3D";
import { buildSettingsCards, type Settings } from "./E04_Stab3D_UI_Settings";
@@ -246,6 +247,9 @@ export function renderE04Stab3D(root: HTMLElement): void {
let result: Result3d | null = null;
let stations2d: Station2d[] = [];
let running = false;
/** 측점 글 = No.+거리(소수 한 자리) · 간격 = 측점 목록 추정 */
const station = (chainage: number): string =>
stationLabel(chainage, inferStationInterval(overview?.stations ?? []), 1);
const remember = (): void =>
sessionStorage.setItem(cacheKey(projectId), JSON.stringify(settings.read()));
@@ -278,7 +282,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
const byCondition = CONDITIONS.map((c) => savedOf.get(`${key}_${c}`));
const saved = savedOf.get(`${key}_${condition}`);
const row = createListRow({
label: `${section.start_m.toFixed(0)} – ${section.end_m.toFixed(0)} m`,
label: `${station(section.start_m)} – ${station(section.end_m)}`,
trailing: byCondition
.flatMap((x) =>
x
@@ -313,7 +317,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
s.end_m,
);
const ratio = ratio3d2d(s.f_min, f2d);
const station = overview?.stations.reduce<StationPoint | null>(
const nearest = overview?.stations.reduce<StationPoint | null>(
(best, x) =>
!best || Math.abs(x.chainage_m - s.chainage_m) < Math.abs(best.chainage_m - s.chainage_m)
? x
@@ -335,7 +339,7 @@ export function renderE04Stab3D(root: HTMLElement): void {
["E04_Result_Bishop", s.by_method.bishop.toFixed(3)],
["E04_Result_Janbu", s.by_method.janbu_corrected.toFixed(3)],
["E04_Result_Hovland", s.by_method.hovland.toFixed(3)],
["E04_Result_Where", station?.label ?? `${s.chainage_m.toFixed(1)} m`],
["E04_Result_Where", station(nearest?.chainage_m ?? s.chainage_m)],
["E04_Result_Cells", s.cells.toLocaleString()],
["E04_Result_Trials", s.trials.toLocaleString()],
["E04_Result_Time", `${result.elapsed_s.toFixed(1)} s`],
+9
View File
@@ -22,6 +22,7 @@ import {
} from "@ui/ui_template_elements";
import { t } from "@ui/ui_template_locale";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { mountGroupStep } from "../A00_Common/group_workflow";
import {
buildJointCards,
@@ -158,6 +159,11 @@ export function renderE06StabRock(root: HTMLElement): void {
let result: RockResult | null = null;
let active: string | null = null;
let shown: StationResponse | null = null;
let interval = 20; // 측점 간격 — 처음 GET 의 측점 목록에서 추정
/** 측점 글 = No.+거리(소수 한 자리) — 서버 label(STA. 꼴) 대신 */
const relabel = (e: StationEntry): void => {
e.label = stationLabel(e.chainage_m, interval, 1);
};
const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length;
const paintStatus = (): void => {
@@ -241,6 +247,7 @@ export function renderE06StabRock(root: HTMLElement): void {
shown = await api<StationResponse>(
`/projects/${projectId}/stab/rock/result?station=${encodeURIComponent(key)}&side=${sideName}`,
);
relabel(shown.entry);
} catch (error) {
shown = null;
showToast(error instanceof Error ? error.message : t("Common_Status_Error"), "error");
@@ -251,6 +258,7 @@ export function renderE06StabRock(root: HTMLElement): void {
async function loadResult(): Promise<void> {
const body = await api<ResultResponse>(`/projects/${projectId}/stab/rock/result`);
result = body.result;
result?.stations.forEach(relabel);
staleBar.hidden = !body.stale;
const stations = result?.stations ?? [];
const keep = stations.find((e) => entryId(e) === active);
@@ -334,6 +342,7 @@ export function renderE06StabRock(root: HTMLElement): void {
try {
const body = await api<RockResponse>(`/projects/${projectId}/stab/rock`);
const stations = body.stations ?? [];
interval = inferStationInterval(stations);
projection = body.joints.settings.projection;
paintProjection();
view = {
+2 -2
View File
@@ -113,8 +113,8 @@ export function buildBand(
});
const ticks = el("div", { className: "e06-band__ticks" });
ticks.append(
el("span", { text: `${lo.toFixed(0)} m` }),
el("span", { text: `${hi.toFixed(0)} m` }),
el("span", { text: list[0].label ?? `${lo.toFixed(0)} m` }),
el("span", { text: list[list.length - 1].label ?? `${hi.toFixed(0)} m` }),
);
box.append(legend, strip, ticks);
} else box.append(legend, note(t("E06_NoRock")));
+7 -5
View File
@@ -7,6 +7,7 @@
import { el } from "@ui/ui_template_elements";
import { tooltipBox } from "@ui/ui_template_hover_info";
import { t, type LocaleKey } from "@ui/ui_template_locale";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { CLASS_KEYS, cellIndex, rasterRgba, type Decoded } from "./logic/E07_StabArea_Raster";
export type MapLayer = "cls" | "fs";
@@ -34,8 +35,6 @@ interface View {
scale: number; // px / m
}
const shortLabel = (label: string): string => label.replace(/^STA\./, "").replace(/\.0+$/, "");
const css = (name: string, fallback: string): string =>
getComputedStyle(document.documentElement).getPropertyValue(name).trim() || fallback;
@@ -51,6 +50,7 @@ export function createAreaMap(): AreaMap {
let line: number[][] = [];
let stations: StationPoint[] = [];
let interval = 20; // 측점 간격 — setRoute 때 측점 목록에서 추정
let band = 100;
let data: Decoded | null = null;
let layer: MapLayer = "cls";
@@ -112,7 +112,7 @@ export function createAreaMap(): AreaMap {
ctx.lineWidth = 2;
path();
ctx.stroke();
const gap = stations.length > 1 ? 20 * view.scale : 999;
const gap = stations.length > 1 ? interval * view.scale : 999;
const every = [1, 2, 5, 10, 25, 50, 100].find((n) => n * gap >= 70) ?? 100;
ctx.font = "11px sans-serif";
ctx.textBaseline = "middle";
@@ -124,7 +124,8 @@ export function createAreaMap(): AreaMap {
ctx.beginPath();
ctx.arc(px, py, on ? 6 : i % every === 0 ? 3 : 1.5, 0, Math.PI * 2);
ctx.fill();
if ((on || i % every === 0) && s.label) ctx.fillText(shortLabel(s.label), px + 7, py - 9);
if (on || i % every === 0)
ctx.fillText(stationLabel(s.chainage_m, interval, 1), px + 7, py - 9);
});
}
@@ -227,7 +228,7 @@ export function createAreaMap(): AreaMap {
t(`E07_Class_${CLASS_KEYS[data!.cls[i] - 1]}` as LocaleKey),
`SI ${data!.si[i].toFixed(3)} · Fs ${data!.fs[i].toFixed(3)}`,
`${t("E07_Tip_Slope")} ${data!.slope[i].toFixed(1)}°${Number.isNaN(m) ? "" : ` · m ${m.toFixed(2)}`}`,
near ? `${t("E07_Tip_Station")} ${near.label ?? near.chainage_m.toFixed(1)}` : "",
near ? `${t("E07_Tip_Station")} ${stationLabel(near.chainage_m, interval, 1)}` : "",
]
.filter(Boolean)
.join("\n");
@@ -265,6 +266,7 @@ export function createAreaMap(): AreaMap {
setRoute(nextLine, nextStations) {
line = nextLine;
stations = nextStations;
interval = inferStationInterval(stations);
fit();
draw();
},
+2 -3
View File
@@ -30,6 +30,7 @@ import { createSegment } from "@ui/ui_template_segment";
import { createTable } from "@ui/ui_template_table";
import { createSidePanels } from "@ui/ui_template_side_panels";
import { createText } from "@ui/ui_template_text";
import { inferStationInterval, stationLabel } from "../common_util/common_util_station";
import { mountGroupStep } from "../A00_Common/group_workflow";
import { createAreaMap, type MapLayer, type StationPoint } from "./E07_StabArea_UI_Map";
import {
@@ -87,8 +88,6 @@ const NUMBER_FIELDS: [string, LocaleKey, "soil" | "rain" | "band"][] = [
];
const cacheKey = (projectId: string): string => `e07_settings:${projectId}`;
const shortLabel = (label: string | null, ch: number): string =>
(label ?? `${ch.toFixed(1)} m`).replace(/^STA\./, "").replace(/\.0+$/, "");
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
@@ -379,7 +378,7 @@ export function renderE07StabArea(root: HTMLElement): void {
{ label: t("E07_Col_RiskArea"), align: "end" },
],
result.stations.map((r) => [
shortLabel(r.label, r.chainage_m),
stationLabel(r.chainage_m, inferStationInterval(overview?.stations ?? []), 1),
r.fs_min === undefined ? "-" : r.fs_min.toFixed(2),
r.risk_ratio === undefined ? "-" : (r.risk_ratio * 100).toFixed(1),
r.level ? chipCell(LEVEL_COLORS[r.level], t(`E07_Level_${r.level}` as LocaleKey)) : "",