Merge remote-tracking branches 'origin/main_desktop_1' and 'origin/sub_desktop_1' into main_laptop_1

This commit is contained in:
2026-10-05 15:11:52 +09:00
11 changed files with 687 additions and 22 deletions
+1
View File
@@ -166,6 +166,7 @@ export async function openRouteEditModal(
measureControls.sync();
draw();
},
onHint: () => measureControls.sync(),
});
/** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */
const measureControls = bindMeasureControls({
+156 -16
View File
@@ -10,6 +10,10 @@
* 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는
* 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를
* 끄는 동안에는 한 번도 부르지 않는다.
*
* 첫 점을 찍으면 그 노선 땅 높이를 일정 간격으로 **한 번에** 받아 둔다(계획서 48-1). 커서를
* 움직이는 동안 길이 · 지반고 · 기울기는 이 표에서 보간해 바로 보이고 서버는 안 부른다.
* 둘째 점을 누르면 전처럼 두 점만 다시 물어 그 값으로 고정한다.
* ========================================================================== */
import { fetchRouteElevations } from "./B05_Profile_Api_Replan";
@@ -23,6 +27,10 @@ export type MeasureLine = "planned" | "expected";
/** 구간 재기로 노선을 짚었다고 볼 거리(px). */
const MEASURE_HIT_PX = 14;
/** 커서 미리보기용 땅 높이 표 간격(m). */
const PROFILE_STEP_M = 2;
/** 한 번에 묻는 점 수 상한 — 긴 노선은 간격을 넓힌다. */
const PROFILE_MAX_POINTS = 3000;
interface MeasurePoint extends RoutePointHit {
line: MeasureLine;
@@ -41,6 +49,69 @@ export interface MeasureToolParams {
isClosed: () => boolean;
/** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */
onChange: () => void;
/** 커서 미리보기 값이 바뀌었다 — 작은 창 글만 다시 적는다(그리기는 포인터 몫). */
onHint?: () => void;
}
/** 첫 점 노선의 땅 높이 표 — 누가거리 `chainages[i]` 자리의 지반고 `z[i]`. */
interface LineProfile {
/** 표를 만든 노선 — 편집으로 노선이 바뀌면 배열이 새로 생겨 낡은 표를 알아챈다. */
vertices: Vertex[];
stepM: number;
chainages: number[];
/** 받는 중이면 null. */
z: Array<number | null> | null;
failed: boolean;
}
/** 노선 전체 길이(m). */
function lineLength(vertices: Vertex[]): number {
let total = 0;
for (let index = 0; index < vertices.length - 1; index += 1) {
total += Math.hypot(
vertices[index + 1][0] - vertices[index][0],
vertices[index + 1][1] - vertices[index][1],
);
}
return total;
}
/** 노선을 따라 `stepM` 마다 한 점(끝점 포함) — 점과 그 누가거리. */
function sampleAlong(vertices: Vertex[], stepM: number): { points: Vertex[]; chainages: number[] } {
const points: Vertex[] = [];
const chainages: number[] = [];
let travelled = 0;
let next = 0;
for (let index = 0; index < vertices.length - 1; index += 1) {
const [ax, ay] = vertices[index];
const [bx, by] = vertices[index + 1];
const segmentM = Math.hypot(bx - ax, by - ay);
while (segmentM > 0 && next <= travelled + segmentM) {
const t = (next - travelled) / segmentM;
points.push([ax + (bx - ax) * t, ay + (by - ay) * t]);
chainages.push(next);
next += stepM;
}
travelled += segmentM;
}
if (points.length && travelled - chainages[chainages.length - 1] > 1e-6) {
points.push(vertices[vertices.length - 1]);
chainages.push(travelled);
}
return { points, chainages };
}
/** 표에서 누가거리 자리의 지반고를 선형 보간 — 양옆 중 하나라도 모르면 null. */
function profileZ(profile: LineProfile, chainageM: number): number | null {
const { chainages, z } = profile;
if (!z || chainages.length === 0) return null;
if (chainages.length === 1) return z[0];
const index = Math.max(0, Math.min(chainages.length - 2, Math.floor(chainageM / profile.stepM)));
const [c0, c1] = [chainages[index], chainages[index + 1]];
const [z0, z1] = [z[index], z[index + 1]];
if (z0 === null || z1 === null) return null;
const t = c1 > c0 ? Math.max(0, Math.min(1, (chainageM - c0) / (c1 - c0))) : 0;
return z0 + (z1 - z0) * t;
}
export interface MeasureMark {
@@ -114,27 +185,86 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
let picked: MeasurePoint[] = [];
/** 첫 점만 찍혔을 때 커서가 가리키는 둘째 점 후보. */
let hover: MeasureMark | null = null;
/** 첫 점 노선의 땅 높이 표 — 커서 미리보기 값의 바탕. */
let profile: LineProfile | null = null;
/** 두 점을 찍고 그 자리 지반고를 묻는 중 — 답이 오기 전에 「못 읽음」으로 보이지 않게. */
let fixing = false;
/** 첫 점 노선 땅 높이를 한 번에 받아 둔다. 같은 노선 표가 살아 있으면 다시 안 묻는다. */
const loadProfile = (line: MeasureLine): void => {
const vertices = params.lines()[line];
if (profile && profile.vertices === vertices) return;
const stepM = Math.max(PROFILE_STEP_M, lineLength(vertices) / PROFILE_MAX_POINTS);
const { points, chainages } = sampleAlong(vertices, stepM);
const asked: LineProfile = { vertices, stepM, chainages, z: null, failed: !points.length };
profile = asked;
if (!points.length) return;
fetchRouteElevations(params.projectId, points)
.then((heights) => {
asked.z = chainages.map((_, index) => heights[index] ?? null);
})
.catch(() => {
asked.failed = true;
})
.finally(() => {
if (params.isClosed() || profile !== asked) return; // 그 사이 다른 노선을 짚었으면 버린다.
params.onHint?.();
});
};
/** 구간 한 줄 — 두 점(누가거리 · 지반고)에서 길이 · 지반고 · 종단기울기. */
const spanText = (
name: string,
a: { chainageM: number; z: number | null },
b: { chainageM: number; z: number | null },
missing: string,
): string => {
const span = Math.abs(b.chainageM - a.chainageM);
const head =
`${name}구간 ${formatStation(a.chainageM, params.stationIntervalM)} → ` +
`${formatStation(b.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (a.z === null || b.z === null || span <= 1e-6) return `${head} · ${missing}`;
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = b.z - a.z;
return (
`${head} · 지반고 ${a.z.toFixed(1)} → ${b.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
);
};
/** 커서 미리보기 한 줄 — 받아 둔 높이 표에서 보간한 값(누르면 두 점만 다시 물어 고정). */
const previewText = (first: MeasurePoint, at: MeasureMark): string => {
const table =
profile !== null && profile.vertices === params.lines()[first.line] ? profile : null;
const zOf = (chainageM: number): number | null => (table ? profileZ(table, chainageM) : null);
const missing = !table
? "지반고 읽는 중…"
: table.failed
? "지반고를 못 읽어 기울기는 못 냅니다."
: table.z
? "지표면 밖이라 기울기는 못 냅니다."
: "지반고 읽는 중…";
const text = spanText(
LINE_NAME[first.line],
{ chainageM: first.chainageM, z: zOf(first.chainageM) },
{ chainageM: at.chainageM, z: zOf(at.chainageM) },
missing,
);
return `${text} · 누르면 고정`;
};
const hint = (): string => {
if (picked.length === 0) return "";
const first = picked[0];
const name = LINE_NAME[first.line];
if (picked.length === 1) {
return `${name}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
if (hover) return previewText(first, hover);
return `${LINE_NAME[first.line]}구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`;
}
const second = picked[1];
const span = Math.abs(second.chainageM - first.chainageM);
const head =
`${name}구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` +
`${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`;
if (first.z === null || second.z === null || span <= 1e-6) {
return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`;
}
// 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다.
const rise = second.z - first.z;
return (
`${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` +
` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%`
return spanText(
LINE_NAME[first.line],
first,
picked[1],
fixing ? "지반고 읽는 중…" : "지반고를 못 읽어 기울기는 못 냅니다.",
);
};
@@ -171,10 +301,14 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
const next = picked.length === 1 ? hitAt(px, py) : null;
if (!next && !hover) return false;
hover = next;
// 노선을 고친 뒤면 표가 낡았다 — 다시 받는다.
if (next) loadProfile(picked[0].line);
params.onHint?.();
return true;
},
clear() {
hover = null;
profile = null;
if (picked.length === 0) return;
picked = [];
params.onChange();
@@ -188,8 +322,12 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
return;
}
picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }];
fixing = picked.length === 2;
params.onChange();
if (picked.length < 2) return;
if (picked.length < 2) {
loadProfile(hit.line);
return;
}
const asked = picked;
try {
const heights = await fetchRouteElevations(
@@ -202,7 +340,9 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool {
});
} catch {
/* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */
if (picked !== asked) return;
}
fixing = false;
params.onChange();
},
};
@@ -43,6 +43,7 @@ import {
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import { recommendHelpHtml, syncRecommendHelp } from "./B05_Profile_UI_RouteEdit_Recommend_Help";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
@@ -225,7 +226,8 @@ export function openRecommendModal(params: RecommendParams): void {
<span class="b05-routerec__state" data-state></span>
<button type="button" class="ui-btn ui-btn--filled" data-act="apply" disabled>적용</button>
<button type="button" class="ui-btn ui-btn--ghost" data-act="close">닫기</button>
</div>`;
</div>
${recommendHelpHtml()}`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
@@ -342,6 +344,7 @@ export function openRecommendModal(params: RecommendParams): void {
? criteriaLine(preview.criteria, params.planMinRadiusM, preview.logic === "gradeline")
: "";
line("criteria").hidden = !preview;
syncRecommendHelp(body, logic);
drawMap();
}
@@ -0,0 +1,188 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Help.ts
* [노선 추천] 모달 오른쪽 설명 칸(계획서 48-2) — 갈래 여덟 · 공통 제약 넷 · 결과 줄 읽는 법.
*
* 글은 서버 엔진(`B05_Profile_Engine_RouteInitial*`)이 실제로 하는 일을 따라 적는다 — 엔진을
* 바꾸면 여기 글도 같이 고친다. 고른 갈래는 강조하고 보이는 자리로 굴린다.
* ========================================================================== */
import type { RouteInitialLogic } from "./B05_Profile_Api_RouteInitial";
import { LOGIC_LABEL } from "./B05_Profile_UI_RouteEdit_Initial_Model";
interface HelpEntry {
/** 한 줄 요약. */
summary: string;
/** 노선을 만드는 법. */
how: string[];
/** 이럴 때 고름. */
when: string;
/** 주의할 점. */
caution: string;
}
const LOGIC_HELP: Record<RouteInitialLogic, HelpEntry> = {
follow: {
summary: "전처리 때 만든 예상노선(점선)을 그대로 따라가는 노선",
how: [
"새로 길을 찾지 않음 — 예상노선 꺾임점에 최소 곡선반지름으로 곡선만 넣음",
"전처리 초기 계획노선과 같은 규칙이라 같은 선이 나옴",
"종단기울기 · 계류 이격은 막지 않고, 넘은 곳만 「못 지킨 구간」에 적음",
],
when: "현장 답사로 정한 노선을 출발점으로 삼을 때 · 가장 빨리 나옴",
caution: "법정 기울기 · 계류 이격을 보장하지 않음 — 못 지킨 구간을 꼭 확인",
},
gradeline: {
summary: "정한 기울기로 등고선을 비스듬히 건너는 등경사선(영선) 노선",
how: [
"구간마다 기울기가 「목표 기울기」에 가장 가깝게 붙도록 길을 찾음",
"목표 칸을 비우면 종단기울기 상한이 목표 · 상한보다 크게 넣으면 상한으로 잘림",
"후보 중 목표와의 차(측점 20m 기울기 평균)가 가장 작은 것을 고름",
],
when: "일정한 기울기로 오르내리는 노선이 필요할 때(지형도 위 영선 잡기와 같은 일)",
caution:
"오르막 · 내리막을 가리지 않음 · 목표를 비우면 상한에 붙은 가파른 노선이 되기 쉬움 — 목표를 상한보다 조금 낮게 넣기를 권함",
},
earthwork: {
summary: "깎고 쌓는 흙(절토 + 성토)이 가장 적은 노선",
how: [
"옆경사가 급한 곳 · 지반이 상한보다 가파른 곳을 비싸게 보고 길을 찾음",
"후보 중 절토 + 성토 어림 양이 가장 적은 것을 고름",
"흙 양 = 계획고를 상한 안으로 맞춘 선과 옆경사 단면(노폭 4m)으로 어림",
],
when: "토공량 · 사토 처리를 줄여야 할 때",
caution: "비탈면 흙은 빠진 어림값 · 연장이 길어지는지 비교값으로 함께 볼 것",
},
gentle: {
summary: "종단기울기가 가장 순한 노선",
how: [
"기울기가 상한에 가까울수록 크게 벌점을 매겨 길을 찾음",
"후보 중 가장 가파른 구간의 기울기가 가장 낮은 것을 고름(같으면 평균 기울기)",
],
when: "차량 주행 · 노면 침식이 걱정되는 노선",
caution: "거리 비용이 작아 돌아가는 긴 노선이 나올 수 있음 — 연장을 확인",
},
safe: {
summary: "급한 옆경사 · 계류 가까운 곳을 피하는 노선",
how: [
"옆경사 35°(약 70%) 이상 · 계류 이격 거리 안을 위험 구간으로 보고 피함",
"후보 중 위험 구간을 지나는 길이가 가장 짧은 것을 고름(같으면 평균 옆경사)",
"35° 기준 = 「35° 이상 전폭 절토」 기술 기준",
],
when: "붕괴 · 유실 위험이 큰 급경사지 · 계곡 가까운 지형",
caution:
"계곡을 따라가는 지형에서는 위험 구간이 어디서나 길어 다른 갈래와 차이가 작음 — 계류 이격 값을 줄여 다시 셈해 볼 것",
},
crossings: {
summary: "계류를 건너는 횟수가 가장 적은 노선",
how: [
"계류 위를 지나는 칸에 벌점을 매겨 길을 찾음",
"후보 중 계류 횡단 수가 가장 적은 것을 고름(같으면 계류에서 더 먼 쪽)",
"10m 안에서 여러 번 건너는 것은 한 번으로 셈",
],
when: "배수관 · 세월교 같은 횡단 구조물을 줄이고 싶을 때",
caution: "소하천 · 지방하천 · 국가하천만 셈 — 작은 세류는 빠짐",
},
cost: {
summary: "토공 · 연장 · 계류 횡단을 한 숫자로 묶은 공사비 어림이 가장 낮은 노선",
how: [
"공사비 어림 = 흙 1㎥ × 1 + 연장 1m × 2 + 계류 횡단 1곳 × 400(흙 ㎥ 상당)",
"같은 비율을 1m 당 값으로 바꿔 길을 찾고, 후보 중 어림값이 가장 낮은 것을 고름",
],
when: "토공 · 연장 · 구조물 수를 한꺼번에 비교하고 싶을 때",
caution: "실제 원가가 아닌 상대값(임시 비율) — 갈래끼리 비교용으로만 볼 것",
},
weighted: {
summary: "예상 · 토공 · 기울기 세 비중(합 100)을 섞어 찾은 노선",
how: [
"예상 = 예상노선에서 떨어지지 않기 · 토공 = 흙 양 줄이기 · 기울기 = 순한 기울기",
"비중대로 섞은 비용 하나로 한 번 길을 찾음 — 한쪽이 100 이면 그 갈래와 같은 노선",
"자물쇠로 비중 하나를 고정하면 나머지 둘만 움직임 · 기본 50 / 25 / 25",
],
when: "예상노선을 어느 정도 살리면서 토공 · 기울기도 챙기고 싶을 때",
caution: "제약은 벌점일 뿐이라 넘는 구간이 남을 수 있음 — 못 지킨 구간을 확인",
},
};
const CRITERIA_HELP: Array<{ name: string; text: string }> = [
{
name: "종단기울기 상한(%)",
text: "이보다 가파른 구간을 만들지 않음. 비우면 임도 종류 · 지형 · 설계속도 법정 값(20km/h 일반 9% · 특수 14%). 곡선을 넣은 뒤 넘으면 상한을 조금씩 낮춰 다시 찾음",
},
{
name: "최소 반지름(m)",
text: "꺾는 곳마다 이 반지름의 곡선이 들어갈 만큼 곧게 간 뒤에만 다시 꺾음. 비우면 설계속도 · 지형 법정 값(20km/h 15m · 12m)",
},
{
name: "계류 이격(m)",
text: "계류에서 이 거리 안으로는 따라 걷지 않고 건너거나 빠져나가는 방향으로만 움직임. 비우면 100m(타당성평가 「산지계류 100m 이내」 기준)",
},
{
name: "지반 고름 폭(m)",
text: "기울기를 잴 때 지형 잔굴곡을 고르는 폭. 클수록 매끈한 지반으로 봄. 비우면 5m",
},
];
const LINES_HELP: Array<{ name: string; text: string }> = [
{
name: "비교값",
text: "연장 · 기울기 최대 · 평균 · 옆경사 · 절토 · 성토 · 계류 횡단 · 공사비 어림 — 갈래를 바꿔 가며 견줌",
},
{
name: "못 지킨 구간",
text: "제약을 넘은 측점(5곳까지 · 나머지는 「외 N곳」, 전체는 마우스를 올리면 보임)",
},
{
name: "상한을 풂",
text: "지형상 상한 안으로는 시점 · 종점을 이을 수 없어 꼭 필요한 만큼만 상한을 올린 경우",
},
{
name: "거의 같은 노선",
text: "제약 안에서 갈 길이 좁은 띠 하나뿐이라 갈래가 달라도 노선이 겹치는 경우 — 제약 값을 바꾸면 벌어질 수 있음",
},
];
const LOGIC_ORDER: RouteInitialLogic[] = [
"follow",
"gradeline",
"earthwork",
"gentle",
"safe",
"crossings",
"cost",
"weighted",
];
/** 설명 칸 HTML — 모달 본문 끝에 붙는다(세로 구분선 오른쪽). */
export function recommendHelpHtml(): string {
const logics = LOGIC_ORDER.map((key) => {
const entry = LOGIC_HELP[key];
return `<article class="b05-routerec__help-item" data-help-logic="${key}">
<h4>${LOGIC_LABEL[key]}</h4>
<p class="b05-routerec__help-summary">${entry.summary}</p>
<ul>${entry.how.map((line) => `<li>${line}</li>`).join("")}</ul>
<p><b>이럴 때</b> ${entry.when}</p>
<p class="b05-routerec__help-caution"><b>주의</b> ${entry.caution}</p>
</article>`;
}).join("");
const list = (items: Array<{ name: string; text: string }>): string =>
`<dl>${items.map((item) => `<dt>${item.name}</dt><dd>${item.text}</dd>`).join("")}</dl>`;
return `<aside class="b05-routerec__help" aria-label="노선 추천 설명">
<h3>추천 갈래</h3>
<p class="b05-routerec__help-lead">제약을 지키는 후보 노선 여러 벌을 찾은 뒤, 갈래마다 자기 기준이 가장 좋은 것을 고름.</p>
${logics}
<h3>공통 제약</h3>
<p class="b05-routerec__help-lead">예상노선 추적 · 비중 계산을 뺀 갈래는 넘지 않음 · 칸을 비우면 기본값.</p>
${list(CRITERIA_HELP)}
<h3>결과 줄 읽는 법</h3>
${list(LINES_HELP)}
</aside>`;
}
/** 고른 갈래 설명을 강조하고 보이는 자리로 굴린다. */
export function syncRecommendHelp(body: HTMLElement, logic: RouteInitialLogic): void {
body.querySelectorAll<HTMLElement>("[data-help-logic]").forEach((item) => {
const on = item.dataset.helpLogic === logic;
if (on && !item.classList.contains("is-on")) item.scrollIntoView({ block: "nearest" });
item.classList.toggle("is-on", on);
});
}
@@ -184,10 +184,17 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
context.lineWidth = 0.8;
drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM);
// 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다.
// 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만).
if (scene.pickedContour >= 0) {
const { layer } = scene.contours;
const level = layer.features[scene.pickedContour]?.labelValue ?? null;
context.strokeStyle = style.getPropertyValue("--map-flow-arrow") || "#7c3aed";
context.lineWidth = 2.6;
drawPreparedFeature(context, scene.contours.layer, scene.pickedContour, view);
layer.features.forEach((feature, index) => {
if (index === scene.pickedContour || (level !== null && feature.labelValue === level)) {
drawPreparedFeature(context, layer, index, view);
}
});
}
// 높이값 라벨은 여기서 안 낸다 — 노선·눈금 **뒤에** 그려야 안 묻힌다(맨 아래 참고).
}
@@ -102,8 +102,16 @@ export function createGroupBoxes(): GroupBoxes {
body.className = "b05-structure__grid";
for (const scope of scopes) {
const marked = [...scope.querySelectorAll<HTMLElement>("[data-option-keys]")];
const fits = marked.filter((node) =>
(node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)),
// 지금 숨은 칸(다른 시설 몫 · 안 고른 갈래)은 그 자리에 둠 — 같은 키 칸이 두 벌 옮겨 오지 않게
const shown = (node: HTMLElement): boolean => {
for (let at: HTMLElement | null = node; at && at !== scope; at = at.parentElement)
if (at.hidden) return false;
return true;
};
const fits = marked.filter(
(node) =>
shown(node) &&
(node.dataset.optionKeys ?? "").split(" ").every((key) => key && keys.has(key)),
);
// 바깥 칸만 — 옮길 칸 안의 칸은 따라 옴
for (const node of fits.filter((n) => !fits.some((o) => o !== n && o.contains(n)))) {
@@ -66,7 +66,8 @@ export function createStructuresSection(
// 폼 뼈대는 전용 조립기가 세운다(2026-09-03 · 700줄 제한) — 여기서는 값·검증·저장만.
const form = buildStructuresForm({
getInterval: () => callbacks.getInterval(),
onFacilityChange: () => liveCommit(),
// 갈래(유입구 구조 등)가 바뀌면 보이는 칸이 달라짐 — 그룹 상자도 다시 세움(PLAN 46-3)
onFacilityChange: () => (regroup(), liveCommit()),
});
const {
root,
@@ -14,8 +14,9 @@
z-index: calc(var(--z-modal, 1000) + 2);
}
/* 오른쪽 설명 칸(계획서 48-2)만큼 넓힘 — 왼쪽 기존 칸은 예전 폭 그대로. */
.ui-modal.b05-routerec {
width: min(960px, 94vw);
width: min(1320px, 96vw);
--modal-max: 92vh;
font-size: var(--text-body-sm);
}
@@ -25,6 +26,119 @@
font-size: var(--text-body-sm);
}
/* 왼쪽 = 기존 칸 넷(알약 · 지도 · 결과 줄 · 바닥) · 오른쪽 = 세로 구분선 + 설명 칸. */
.ui-modal.b05-routerec .b05-routerec__body {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 340px);
align-content: start;
column-gap: var(--spacing-16, 16px);
}
.b05-routerec__body > :not(.b05-routerec__help) {
grid-column: 1;
}
/* 설명 칸 — 왼쪽 높이를 따라가고(제 글 길이로 창을 키우지 않음) 넘치면 제 안에서 굴림. */
.b05-routerec__help {
grid-column: 2;
grid-row: 1 / span 4;
contain: size;
overflow-y: auto;
padding-left: var(--spacing-16, 16px);
border-left: 1px solid var(--color-border);
color: var(--color-text-body);
font-size: var(--text-body-sm);
line-height: 1.55;
}
.b05-routerec__help h3 {
margin: var(--spacing-12) 0 var(--spacing-4, 4px);
color: var(--color-text-heading, var(--color-text-body));
font-size: var(--text-body);
font-weight: 700;
}
.b05-routerec__help h3:first-child {
margin-top: 0;
}
.b05-routerec__help-lead {
margin: 0 0 var(--spacing-8);
color: var(--color-text-secondary);
}
.b05-routerec__help-item {
margin-bottom: var(--spacing-8);
padding: var(--spacing-8);
border: 1px solid transparent;
border-radius: var(--radius-8, 6px);
}
/* 고른 갈래 — 알약과 같은 강조색 테두리. */
.b05-routerec__help-item.is-on {
border-color: var(--color-primary);
background: var(--color-surface);
}
.b05-routerec__help-item h4 {
margin: 0 0 2px;
font-size: var(--text-body-sm);
font-weight: 700;
}
.b05-routerec__help-item.is-on h4 {
color: var(--color-primary);
}
.b05-routerec__help-item p,
.b05-routerec__help dd {
margin: 2px 0;
}
.b05-routerec__help-item ul {
margin: 2px 0;
padding-left: 1.1em;
}
.b05-routerec__help-summary {
font-weight: 600;
}
.b05-routerec__help-caution {
color: var(--color-text-secondary);
}
.b05-routerec__help dl {
margin: 0;
}
.b05-routerec__help dt {
margin-top: var(--spacing-8);
font-weight: 600;
}
.b05-routerec__help dd {
color: var(--color-text-secondary);
}
/* 좁은 폭 — 설명 칸은 아래로(가로 구분선 · 제 높이 한도 안에서 굴림). */
@media (max-width: 1000px) {
.ui-modal.b05-routerec .b05-routerec__body {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__help {
grid-column: 1;
grid-row: auto;
contain: none;
max-height: 280px;
padding-top: var(--spacing-12);
padding-left: 0;
border-top: 1px solid var(--color-border);
border-left: none;
}
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
/* 여덟 알약 — 네 칸 두 줄로 고르게(좁은 폭은 두 칸 네 줄). */
.b05-routerec__pills {
@@ -0,0 +1,89 @@
/* 48-1 · B05 계획노선 편집 구간 재기 — 첫 점 뒤 커서 자리 값 실시간 표시
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선이 있는 프로젝트 · 저장 안 함)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 격자(40px)로 계획노선 자리 둘(a · b)을 찾고 → a 를 찍음 → b 로 커서 이동 → 높이 표가
* 올 때까지 기다림 → 커서를 b 근처에서 여러 번 움직이며 서버 부름 수를 셈 → b 를 찍어 고정 값과 비교
* 기대 수치: hover 글에 「길이」·「종단기울기」·「누르면 고정」 · 표가 온 뒤 커서 이동 중 /route/elevations
* 부름 0 · 고정 글의 길이 = 미리보기 길이 · 기울기 차 ≤ 0.5%p(보간 vs 두 점 정밀 조회)
* · 끝나면 잰 것 지우고 재기 단추 끔
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const o = document.querySelector(".b05-routeedit");
const c = o.querySelector("canvas");
const box = o.querySelector(".b05-routeedit__measure");
const text = o.querySelector(".b05-routeedit__measure-text");
const btn = o.querySelector('[data-act="measure"]');
const close = o.querySelector(".b05-routeedit__measure-close");
const r = c.getBoundingClientRect();
const fire = (type, x, y) =>
c.dispatchEvent(
new PointerEvent(type, {
button: 0,
clientX: x,
clientY: y,
pointerId: 1,
pointerType: "mouse",
bubbles: true,
}),
);
const ev = (type, x, y) => {
fire(type, x, y);
if (type === "pointerdown") fire("pointerup", x, y);
};
let calls = 0;
const realFetch = window.fetch;
window.fetch = (...args) => {
if (String(args[0]).includes("/route/elevations")) calls += 1;
return realFetch(...args);
};
const out = {};
try {
if (!btn.classList.contains("is-active")) btn.click();
const planned = [];
for (let y = r.top + 20; y < r.bottom - 20 && planned.length < 40; y += 40)
for (let x = r.left + 20; x < r.right - 20; x += 40) {
ev("pointerdown", x, y);
if (/시작/.test(text.textContent) && !/^예상노선/.test(text.textContent))
planned.push([x, y]);
if (!box.hidden) close.click();
if (!btn.classList.contains("is-active")) btn.click();
}
const a = planned[0];
const b = planned.find((p) => Math.hypot(p[0] - a[0], p[1] - a[1]) > 200);
out.found = planned.length;
if (!a || !b) return JSON.stringify({ ...out, error: "자리 못 찾음" });
ev("pointerdown", ...a);
ev("pointermove", ...b);
out.hoverFirst = text.textContent;
for (let i = 0; i < 150 && !/종단기울기|못 냅니다/.test(text.textContent); i += 1)
await wait(100);
out.hover = text.textContent;
const before = calls;
for (let i = 0; i < 10; i += 1) {
ev("pointermove", b[0] + (i % 3) - 1, b[1] + (i % 2));
await wait(30);
}
ev("pointermove", ...b);
out.hoverCallsWhileMoving = calls - before;
out.hoverAgain = text.textContent;
ev("pointerdown", ...b);
for (let i = 0; i < 150 && !/종단기울기|못 냅니다/.test(text.textContent); i += 1)
await wait(100);
out.final = text.textContent;
const len = (s) => (s.match(/길이 ([\d.]+)m/) || [])[1];
const grade = (s) => Number((s.match(/종단기울기 (-?[\d.]+)%/) || [])[1]);
out.pass =
/길이/.test(out.hoverAgain) &&
/종단기울기/.test(out.hoverAgain) &&
/누르면 고정/.test(out.hoverAgain) &&
out.hoverCallsWhileMoving === 0 &&
len(out.hoverAgain) === len(out.final) &&
Math.abs(grade(out.hoverAgain) - grade(out.final)) <= 0.5;
} finally {
window.fetch = realFetch;
if (!box.hidden) close.click();
if (btn.classList.contains("is-active")) btn.click();
}
return JSON.stringify(out);
})();
@@ -0,0 +1,48 @@
/* 48-2 · B05 계획노선 편집 [노선 추천] 모달 — 오른쪽 세로 구분선 + 설명 칸
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(노선 추천 모달은 닫힘)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: [노선 추천] 열기 → 설명 칸 자리 · 구분선 · 갈래 여덟 확인 → 「비중 계산」 누름 → 강조가 따라가고
* 보이는 자리에 있는지 확인 → 셈이 끝나면 [닫기](미리 보기 버림 · 편집값 안 바뀜)
* 기대 수치: 설명 칸이 왼쪽 칸 오른쪽(left > 지도 right) · 왼쪽 테두리 1px · 갈래 글 8 · 처음 강조 follow
* · 누른 뒤 강조 weighted 1개 · 강조 글이 설명 칸 안에 보임 · 설명 칸 높이 = 본문 높이(창을 키우지 않음)
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const editor = document.querySelector(".b05-routeedit");
[...editor.querySelectorAll("button")].find((b) => b.textContent.trim() === "노선 추천").click();
await wait(1500);
const modal = document.querySelector(".ui-modal.b05-routerec");
const body = modal.querySelector(".b05-routerec__body");
const help = modal.querySelector(".b05-routerec__help");
const map = modal.querySelector(".b05-routerec__main").getBoundingClientRect();
const box = help.getBoundingClientRect();
const out = {
rightOfLeft: box.left > map.right,
border: getComputedStyle(help).borderLeftWidth,
items: help.querySelectorAll("[data-help-logic]").length,
first: help.querySelector(".is-on")?.dataset.helpLogic,
sameHeight: Math.abs(box.height - body.clientHeight) <= 2,
};
modal.querySelector('[data-logic="weighted"]').click();
await wait(300);
const on = help.querySelectorAll(".is-on");
const onBox = on[0].getBoundingClientRect();
out.after = on[0].dataset.helpLogic;
out.onCount = on.length;
out.visible = onBox.top >= box.top - 1 && onBox.bottom <= box.bottom + 1;
for (let i = 0; i < 300 && modal.querySelector('[data-logic="weighted"]').disabled; i += 1)
await wait(100);
modal.querySelector('[data-act="close"]').click();
out.closed = !document.querySelector(".ui-modal.b05-routerec");
out.pass =
out.rightOfLeft &&
out.border === "1px" &&
out.items === 8 &&
out.first === "follow" &&
out.sameHeight &&
out.after === "weighted" &&
out.onCount === 1 &&
out.visible &&
out.closed;
return JSON.stringify(out);
})();
@@ -0,0 +1,66 @@
/* 48-3 · B05 계획노선 편집 등고선 고르기 — 끊어진 같은 높이 줄을 함께 강조
* 시작 화면: B05 종단설계 · [계획노선 편집] 창이 떠 있음(저장 안 함 · 재기 모드 꺼짐)
* 부르는 법: orca eval --expression "$(cat 이 파일)" (async 식 · JSON 글을 돌려줌)
* 하는 일: 격자(30px)로 눌러 「고른 등고선 Nm」 가 뜨는 자리를 높이마다 하나씩 모으고, 그 높이를 골랐을 때
* 강조색(#7c3aed) 픽셀 수를 셈 → 같은 높이의 **다른 가닥 자리**를 눌러도 같은 수가 나오는지 봄
* 기대 수치: 같은 높이를 다른 가닥에서 골라도 강조 픽셀 수 차 ≤ 12% — 남는 차는 높이 글자 상자(같은 색)가
* 고른 가닥 자리에 붙어 선과 겹치는 정도가 달라서 · 고치기 전 코드는 최대 57%(2026-10-05 데스크탑_보조)
* · 끝나면 빈 자리를 눌러 고름을 풂
* 고치기 전 코드와 견줄 때: 같은 높이 두 자리의 강조 픽셀 수가 크게 다름(가닥별로만 칠함)
*/
(async () => {
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const o = document.querySelector(".b05-routeedit");
const c = o.querySelector("canvas");
const status = o.querySelector(".b05-routeedit__status");
const r = c.getBoundingClientRect();
const fire = (type, x, y) =>
c.dispatchEvent(
new PointerEvent(type, {
button: 0,
clientX: x,
clientY: y,
pointerId: 1,
pointerType: "mouse",
bubbles: true,
}),
);
const click = (x, y) => {
fire("pointerdown", x, y);
fire("pointerup", x, y);
};
const purple = () => {
const d = c.getContext("2d").getImageData(0, 0, c.width, c.height).data;
let n = 0;
for (let i = 0; i < d.length; i += 4)
if (d[i] === 124 && d[i + 1] === 58 && d[i + 2] === 237) n++;
return n;
};
const byLevel = {};
for (let y = r.top + 30; y < r.bottom - 30; y += 30)
for (let x = r.left + 30; x < r.right - 30; x += 30) {
click(x, y);
const m = status.textContent.match(/고른 등고선 (-?[\d.]+)m/);
if (!m) continue;
await wait(30);
const list = (byLevel[m[1]] ??= []);
if (list.length < 4) list.push({ x, y, purple: purple() });
}
// 빈 자리(왼쪽 위 모서리)를 눌러 고름을 푼다.
click(r.left + 2, r.top + 2);
const levels = Object.entries(byLevel).filter(([, list]) => list.length >= 2);
const spread = levels.map(([level, list]) => {
const counts = list.map((entry) => entry.purple);
const max = Math.max(...counts);
const min = Math.min(...counts);
return { level, counts, ratio: max ? (max - min) / max : 0 };
});
const worst = spread.reduce((a, b) => (b.ratio > a ? b.ratio : a), 0);
return JSON.stringify({
levelsFound: Object.keys(byLevel).length,
compared: spread.length,
worst: Math.round(worst * 1000) / 1000,
sample: spread.slice(0, 6),
pass: spread.length > 0 && worst <= 0.12,
});
})();