Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-09-29 20:24:03 +09:00
6 changed files with 321 additions and 74 deletions
@@ -12,7 +12,7 @@
/** 회전 단추 아이콘 — **반만 도는 화살표**(2026-09-12 사용자 지시 ㉙). 한 바퀴를 다 그린
* 기호(`↺`·`↻`)는 「한 바퀴 돈다」로 읽혀 한 칸씩 도는 동작과 안 맞았다. */
const HALF_TURN_ICON = {
export const HALF_TURN_ICON = {
ccw: `<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true" fill="none"
stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 8a5 5 0 0 0-10 0" /><path d="M3 8 1.2 5.6" /><path d="M3 8 5.4 6.6" /></svg>`,
@@ -42,9 +42,9 @@ export function createRouteEditChrome(): RouteEditChrome {
<strong>계획노선 편집</strong>
<span class="b05-routeedit__rec-now" data-rec-now></span>
<span class="b05-routeedit__actions">
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="recommend" disabled
title="예상노선 추적 · 토공 최소 · 기울기 순한 · 비중 계산 중 하나로 노선을 추천받아 미리 봅니다">노선 추천</button>
<i class="b05-routeedit__divider" aria-hidden="true"></i>
<button type="button" class="ui-btn ui-btn--ghost" data-act="contour-minor"
aria-pressed="false" disabled
title="1m 간격 등고선을 얇고 옅게 겹칩니다 (확대하면 보임)">1m 등고선</button>
@@ -34,6 +34,8 @@ export interface RouteEditNavigationParams {
/** 돌린 지도 보정 — 화면에서 민 만큼(dx, dy)을 **그림 좌표의 만큼**으로 바꾼다.
* 안 주면 안 돌린 것으로 본다(배수유역도는 회전이 없다). */
unrotateDelta?: (dx: number, dy: number) => [number, number];
/** 마우스 왼쪽 버튼으로도 팬 — 집을 노드가 없는 미리 보기 지도용. 편집 창은 가운데 버튼만. */
leftPan?: boolean;
draw: () => void;
}
@@ -78,7 +80,12 @@ export function bindRouteEditNavigation(params: RouteEditNavigationParams): void
// 가운데 버튼 기본 동작(페이지 자동 스크롤)이 팬과 겹치지 않게 막는다.
if (event.button === 1) event.preventDefault();
// 마우스는 가운데 버튼만 팬이다 — 왼쪽 버튼은 노드·손잡이 조작 몫.
if (event.pointerType === "mouse" && event.button !== 1) return;
if (
event.pointerType === "mouse" &&
event.button !== 1 &&
!(params.leftPan && event.button === 0)
)
return;
const view = getView();
dragStart = {
x: event.clientX,
@@ -23,7 +23,6 @@ import {
criteriaLine,
CRITERIA_KEYS,
LOGIC_LABEL,
mapPaths,
metricsLine,
rebalanceWeights,
relaxedLine,
@@ -36,6 +35,7 @@ import {
type MapLayer,
type WeightKey,
} from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createRecommendMap, RECOMMEND_MAP_HTML } from "./B05_Profile_UI_RouteEdit_Recommend_Map";
import "./B05_Profile_UI_Style_RouteRecommend.css";
type Vertex = [number, number];
@@ -61,34 +61,31 @@ const CRITERIA_FIELD: Record<
{ label: string; unit: string; title: string; of: (c: InitialCriteria) => number | null }
> = {
max_grade_pct: {
label: "기울기",
label: "종단기울기 상한",
unit: "%",
title: "종단기울기 상한(%) — 비우면 프로젝트 기본값(설계속도 · 지형 표값)",
of: (c) => c.maxGradePct,
},
min_radius_m: {
label: "최소R",
label: "최소 반지름",
unit: "m",
title: "최소 곡선반지름(m) — 비우면 프로젝트 기본값",
of: (c) => c.minRadiusM,
},
stream_offset_m: {
label: "계류",
label: "계류 이격",
unit: "m",
title: "계류 이격(m) — 비우면 기본값 100m",
of: (c) => c.streamOffsetM,
},
grade_smooth_m: {
label: "지반",
label: "지반 고름 폭",
unit: "m",
title: "기울기 잴 지반 고름 폭(m) — 비우면 기본값 5m",
of: (c) => c.gradeSmoothM,
},
};
const MAP_W = 460;
const MAP_H = 340;
export interface RecommendParams {
/** 칸에 처음 채울 제약 겹침(직접 넣은 값만). */
criteria: RouteInitialCriteria | null;
@@ -120,13 +117,17 @@ export function openRecommendModal(params: RecommendParams): void {
let seq = 0;
let bodyEl: HTMLElement | null = null;
let map: ReturnType<typeof createRecommendMap> | null = null;
openModal({
title: "노선 추천",
closeLabel: "✕",
dialogClass: "b05-routerec",
// 곡선 라벨 패널(z-index 1001)이 떠 있어도 이 모달과 뒤 막이 그 위에 오게 한다.
backdropClass: "b05-routerec-backdrop",
// ✕ · Esc · 바깥 누르기 모두 [닫기]와 같다 — 미리 보기를 버린다.
beforeClose: () => {
closed = true;
map?.dispose();
return true;
},
mount: (body, close) => {
@@ -141,21 +142,28 @@ export function openRecommendModal(params: RecommendParams): void {
</div>
<div class="b05-routerec__main">
<div class="b05-routerec__mapbox">
<svg class="b05-routerec__map" viewBox="0 0 ${MAP_W} ${MAP_H}" role="img"
aria-label="추천 노선 미리 보기"></svg>
<div class="b05-routerec__mapwrap" role="img" aria-label="추천 노선 미리 보기">
${RECOMMEND_MAP_HTML}
</div>
<div class="b05-routerec__legend">
<span><i class="is-expected"></i>예상노선(원본)</span>
<span><i class="is-current"></i>창을 연 노선</span>
<span><i class="is-preview"></i>미리 보기</span>
</div>
<div class="b05-routerec__maphint">휠 = 확대 · 축소 &nbsp; 끌기 = 이동 &nbsp; ↺ ↻ = 회전</div>
</div>
<div class="b05-routerec__side">
<div class="b05-routerec__weights" hidden>
${WEIGHT_KEYS.map(
(key) => `<label><span>${WEIGHT_NAME[key]}</span>
<input type="range" min="0" max="100" step="1" data-weight="${key}" />
<output data-weight-out="${key}"></output></label>`,
).join("")}
<div class="b05-routerec__bars">
${WEIGHT_KEYS.map(
(
key,
) => `<label class="b05-routerec__bar"><output data-weight-out="${key}"></output>
<input type="range" min="0" max="100" step="1" data-weight="${key}"
aria-label="${WEIGHT_NAME[key]} 비중" />
<span>${WEIGHT_NAME[key]}</span></label>`,
).join("")}
</div>
<span class="b05-routerec__sum" data-weight-sum></span>
</div>
<div class="b05-routerec__criteria">
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
</div>`;
const q = <T extends HTMLElement>(sel: string): T => body.querySelector<T>(sel)!;
const svg = body.querySelector<SVGSVGElement>(".b05-routerec__map")!;
map = createRecommendMap(q<HTMLElement>(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll<HTMLButtonElement>("[data-logic]")];
const weightsBox = q<HTMLElement>(".b05-routerec__weights");
const rerun = q<HTMLButtonElement>('[data-act="rerun"]');
@@ -213,9 +221,7 @@ export function openRecommendModal(params: RecommendParams): void {
{ kind: "current", points: params.current },
];
if (preview) layers.push({ kind: "preview", points: preview.snapshot.planned });
svg.innerHTML = mapPaths(layers, MAP_W, MAP_H)
.map((path) => `<path class="is-${path.kind}" d="${path.d}" />`)
.join("");
map?.setLayers(layers);
}
function render(): void {
@@ -0,0 +1,183 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Recommend_Map.ts
* [노선 추천] 모달의 미리 보기 지도 — 캔버스 하나에 선 세 겹(예상노선 · 창을 연 노선 · 미리 보기).
*
* 화면 셈 · 조작은 편집 창 것을 그대로 가져다 쓴다(베끼지 않음) — 좌표 옮기기 `metricToScreen` ·
* 처음 맞춤 `computeRouteView` · 캔버스 크기 `fitCanvas`(`_View`) · 휠 확대 · 끌기
* `bindRouteEditNavigation`(`_Input`) · 회전 `createMapRotation`(`_Rotate`). 배경 지도(VWorld)는
* 없어서, 선들의 범위를 감싼 가짜 도엽(`VWorldMeta`)을 만들어 같은 셈에 넣는다.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import {
computeMapRect,
computeRouteView,
metricToScreen,
ROUTE_VIEW_MARGIN_M,
routeLineColor,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { HALF_TURN_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
import { bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input";
import type { MapLayer } from "./B05_Profile_UI_RouteEdit_Initial_Model";
import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate";
import { fitCanvas } from "./B05_Profile_UI_RouteEdit_View";
/** 처음 맞출 때 선 범위 둘레로 두는 여유(m) — 편집 창(200 m)보다 좁게, 선이 지도를 채우게. */
const FIT_MARGIN_M = 30;
/** 지도 칸 마크업 — 캔버스 + 회전 단추 둘(편집 창과 같은 모양 · 같은 `data-act`). */
export const RECOMMEND_MAP_HTML = `
<canvas class="b05-routerec__map"></canvas>
<div class="b05-routeedit__spin">
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-ccw"
title="반시계로 돌리기" aria-label="반시계로 돌리기">${HALF_TURN_ICON.ccw}</button>
<button type="button" class="ui-btn ui-btn--glass" data-act="rotate-cw"
title="시계로 돌리기" aria-label="시계로 돌리기">${HALF_TURN_ICON.cw}</button>
</div>`;
export interface RecommendMap {
/** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
setLayers: (layers: MapLayer[]) => void;
dispose: () => void;
}
/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
export function createRecommendMap(box: HTMLElement): RecommendMap {
const canvas = box.querySelector<HTMLCanvasElement>("canvas")!;
const context = canvas.getContext("2d")!;
let layers: MapLayer[] = [];
let meta: VWorldMeta | null = null;
let fitted = false;
let view: ViewState = {
width: 0,
height: 0,
scale: 1,
offsetX: 0,
offsetY: 0,
mapRect: computeMapRect(null, 0, 0),
};
const rotation = createMapRotation({ overlay: box, size: () => view, onChange: () => draw() });
function bounds(): { x_min: number; x_max: number; y_min: number; y_max: number } | null {
const all = layers.flatMap((layer) => layer.points);
if (!all.length) return null;
const xs = all.map(([x]) => x);
const ys = all.map(([, y]) => y);
return {
x_min: Math.min(...xs),
x_max: Math.max(...xs),
y_min: Math.min(...ys),
y_max: Math.max(...ys),
};
}
/** 선 범위 + 여유를 도엽으로 본다 — 이후 확대 상한 · 맞춤이 편집 창과 같은 공식을 탄다. */
function makeMeta(range: NonNullable<ReturnType<typeof bounds>>): VWorldMeta {
const x_min = range.x_min - ROUTE_VIEW_MARGIN_M;
const y_min = range.y_min - ROUTE_VIEW_MARGIN_M;
const x_max = range.x_max + ROUTE_VIEW_MARGIN_M;
const y_max = range.y_max + ROUTE_VIEW_MARGIN_M;
return {
x_min,
x_max,
y_min,
y_max,
width_meters: x_max - x_min,
height_meters: y_max - y_min,
center_x: (x_min + x_max) / 2,
center_y: (y_min + y_max) / 2,
lon_min: 0,
lon_max: 1,
lat_min: 0,
lat_max: 1,
};
}
function refit(): void {
const b = bounds();
if (!b || !view.width) return;
meta = makeMeta(b);
view = {
...view,
mapRect: computeMapRect(meta, view.width, view.height),
...computeRouteView(meta, b, view.width, view.height, FIT_MARGIN_M),
};
fitted = true;
}
function draw(): void {
context.clearRect(0, 0, view.width, view.height);
if (!meta) return;
context.save();
context.translate(view.width / 2, view.height / 2);
context.rotate(rotation.radians());
context.translate(-view.width / 2, -view.height / 2);
context.lineCap = "round";
context.lineJoin = "round";
const styles: Record<MapLayer["kind"], () => void> = {
expected: () => {
context.strokeStyle = themeColor("--color-text-secondary", "#9ca3af");
context.lineWidth = 1.5;
context.setLineDash([5, 4]);
},
current: () => {
context.strokeStyle = routeLineColor();
context.globalAlpha = 0.55;
context.lineWidth = 2;
context.setLineDash([]);
},
preview: () => {
context.strokeStyle = themeColor("--color-primary", "#6d28d9");
context.lineWidth = 3;
context.setLineDash([]);
},
};
for (const layer of layers) {
if (layer.points.length < 2) continue;
context.save();
styles[layer.kind]();
context.beginPath();
layer.points.forEach(([x, y], index) => {
const [sx, sy] = metricToScreen(meta!, view, x, y);
if (index) context.lineTo(sx, sy);
else context.moveTo(sx, sy);
});
context.stroke();
context.restore();
}
context.restore();
}
function resize(): void {
const { width, height } = fitCanvas(canvas, context);
if (!width || !height) return;
view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) };
// 처음 한 번만 맞춘다 — 그 뒤 창 크기가 바뀌어도 사용자가 만진 배율 · 자리를 지킨다.
if (!fitted) refit();
draw();
}
bindRouteEditNavigation({
canvas,
getView: () => view,
setView: (next) => (view = next),
getMeta: () => meta,
unrotateDelta: rotation.unrotateDelta,
leftPan: true,
draw,
});
// 모달이 붙기 전에는 칸 크기가 0 이다 — 붙은 뒤 크기가 잡히면 맞추고 그린다.
const observer = new ResizeObserver(() => resize());
observer.observe(canvas.parentElement!);
return {
setLayers(next) {
layers = next;
if (!fitted) refit();
draw();
},
dispose: () => observer.disconnect(),
};
}
@@ -9,13 +9,20 @@
white-space: nowrap;
}
/* 곡선 라벨 패널(`.b05-routeedit__label` = 모달 + 1) 위로 — 모달과 뒤 막이 함께 올라간다. */
.ui-modal__backdrop.b05-routerec-backdrop {
z-index: calc(var(--z-modal, 1000) + 2);
}
.ui-modal.b05-routerec {
width: min(900px, 94vw);
width: min(960px, 94vw);
max-height: 92vh;
font-size: var(--text-body-sm);
}
.b05-routerec__body {
gap: var(--spacing-12);
font-size: var(--text-body-sm);
}
/* 알약 — 하나만 고른 상태(라디오 묶음). */
@@ -26,12 +33,12 @@
}
.b05-routerec__pill {
padding: 6px var(--spacing-16);
padding: 8px var(--spacing-16);
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-body-sm);
font-size: var(--text-body);
cursor: pointer;
}
@@ -53,7 +60,7 @@
.b05-routerec__main {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr);
grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr);
gap: var(--spacing-12);
align-items: start;
}
@@ -64,36 +71,27 @@
gap: var(--spacing-4, 4px);
}
.b05-routerec__map {
display: block;
width: 100%;
height: auto;
/* 미리 보기 지도 — 캔버스 칸(높이 고정). 회전 단추는 편집 창 것(`.b05-routeedit__spin`)과 같은 모양. */
.b05-routerec__mapwrap {
position: relative;
height: 380px;
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-8, 6px);
background: var(--color-surface);
}
.b05-routerec__map path {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
.b05-routerec__map {
display: block;
width: 100%;
height: 100%;
touch-action: none;
cursor: grab;
}
.b05-routerec__map path.is-expected {
stroke: var(--color-text-secondary, #9ca3af);
stroke-dasharray: 5 4;
stroke-width: 1.5;
}
.b05-routerec__map path.is-current {
stroke: var(--map-route, #f97316);
stroke-width: 2;
opacity: 0.55;
}
.b05-routerec__map path.is-preview {
stroke: var(--color-primary);
stroke-width: 2.5;
.b05-routerec__maphint {
color: var(--color-text-secondary);
font-size: var(--text-body-sm);
}
.b05-routerec__legend {
@@ -101,7 +99,7 @@
flex-wrap: wrap;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
font-size: var(--text-body-sm);
}
.b05-routerec__legend i {
@@ -128,53 +126,81 @@
display: flex;
flex-direction: column;
gap: var(--spacing-12);
color: var(--color-text-secondary);
font-size: var(--text-caption);
color: var(--color-text-body);
font-size: var(--text-body-sm);
}
/* 비중 — 세로 막대 셋이 나란히(값 위 · 이름 아래). 슬라이더가 세로로 선다. */
.b05-routerec__weights {
display: grid;
gap: 4px;
display: flex;
flex-direction: column;
gap: var(--spacing-4, 4px);
}
.b05-routerec__weights label {
display: grid;
grid-template-columns: 3.5em 1fr 2.5em;
align-items: center;
.b05-routerec__weights[hidden] {
display: none;
}
.b05-routerec__bars {
display: flex;
gap: var(--spacing-8);
}
.b05-routerec__weights output {
.b05-routerec__bar {
display: flex;
flex: 1 1 0;
flex-direction: column;
align-items: center;
gap: var(--spacing-4, 4px);
}
.b05-routerec__bar input[type="range"] {
width: 28px;
height: 130px;
margin: 0;
writing-mode: vertical-lr;
direction: rtl; /* 위로 올릴수록 큰 값 */
accent-color: var(--color-primary);
}
.b05-routerec__bar output {
color: var(--color-text-body);
text-align: right;
font-size: var(--text-body);
font-weight: 600;
}
.b05-routerec__bar span {
color: var(--color-text-secondary);
}
.b05-routerec__sum {
text-align: right;
text-align: center;
font-weight: 600;
}
/* 공통 제약 네 칸 — 비우면 placeholder 가 기본값. */
/* 공통 제약 네 칸 — 한 줄에 하나(이름 · 입력 · 단위). 비우면 placeholder 가 기본값. */
.b05-routerec__criteria {
display: flex;
flex-wrap: wrap;
gap: var(--spacing-8) var(--spacing-12);
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: var(--spacing-8);
}
.b05-routerec__criteria label {
display: inline-flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 5.5em 1.5em;
align-items: center;
gap: 4px;
white-space: nowrap;
gap: var(--spacing-8);
color: var(--color-text-body);
}
.b05-routerec__criteria input {
width: 4em;
padding: 2px 4px;
width: 100%;
padding: 4px 8px;
border: 1px solid var(--color-border);
border-radius: var(--radius-4, 4px);
background: var(--color-surface);
color: var(--color-text-body);
font-size: var(--text-caption);
font-size: var(--text-body);
text-align: right;
}
@@ -191,8 +217,8 @@
flex-direction: column;
gap: 2px;
color: var(--color-text-secondary);
font-size: var(--text-caption);
line-height: 1.45;
font-size: var(--text-body-sm);
line-height: 1.5;
}
/* 상한을 풀었다는 안내 — 눈에 띄게(계획서 20). */
@@ -212,7 +238,7 @@
.b05-routerec__state {
flex: 1 1 12em;
color: var(--color-text-body);
font-size: var(--text-caption);
font-size: var(--text-body-sm);
}
/* 좁은 폭 — 지도 아래로 조작 칸. */
@@ -220,4 +246,8 @@
.b05-routerec__main {
grid-template-columns: minmax(0, 1fr);
}
.b05-routerec__mapwrap {
height: 300px;
}
}
@@ -192,3 +192,24 @@ def test_되돌리기_다시하기가_현재_추천을_함께_되살림(results)
{"logic": "earthwork"},
None,
]
def test_노선_추천_다듬기_23_3():
"""구분선은 [노선 추천] 오른쪽 · 모달 지도는 편집 창 함수를 가져다 씀 · 모달이 라벨 패널 위."""
page = os.path.join(HERE, "..", "..", "B05_Profile")
def read(name):
return open(os.path.join(page, name), encoding="utf-8").read()
chrome = read("B05_Profile_UI_RouteEdit_Chrome.ts")
rec = chrome.index('data-act="recommend"')
divider = chrome.index("b05-routeedit__divider", rec)
assert rec < divider < chrome.index('data-act="contour-minor"')
assert chrome.count("b05-routeedit__divider") == 2
shared = read("B05_Profile_UI_RouteEdit_Recommend_Map.ts")
for name in ("bindRouteEditNavigation", "createMapRotation", "fitCanvas", "metricToScreen"):
assert f"{name}(" in shared
css = read("B05_Profile_UI_Style_RouteRecommend.css")
assert "calc(var(--z-modal, 1000) + 2)" in css # 라벨 패널(+1) 위
assert "writing-mode: vertical-lr" in css
assert 'backdropClass: "b05-routerec-backdrop"' in read("B05_Profile_UI_RouteEdit_Recommend.ts")