-
+
+ ${RECOMMEND_MAP_HTML}
+
예상노선(원본)
창을 연 노선
미리 보기
+
휠 = 확대 · 축소 끌기 = 이동 ↺ ↻ = 회전
@@ -181,7 +189,7 @@ export function openRecommendModal(params: RecommendParams): void {
`;
const q =
(sel: string): T => body.querySelector(sel)!;
- const svg = body.querySelector(".b05-routerec__map")!;
+ map = createRecommendMap(q(".b05-routerec__mapwrap"));
const pills = [...body.querySelectorAll("[data-logic]")];
const weightsBox = q(".b05-routerec__weights");
const rerun = q('[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) => ``)
- .join("");
+ map?.setLayers(layers);
}
function render(): void {
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts
new file mode 100644
index 00000000..9408a1c1
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Recommend_Map.ts
@@ -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 = `
+
+
+
+
+
`;
+
+export interface RecommendMap {
+ /** 겹을 바꿔 다시 그린다. 처음 점이 있는 겹이 들어올 때 한 번만 화면을 맞춘다. */
+ setLayers: (layers: MapLayer[]) => void;
+ dispose: () => void;
+}
+
+/** `box` = `RECOMMEND_MAP_HTML` 을 넣은 칸(높이가 정해져 있어야 함). */
+export function createRecommendMap(box: HTMLElement): RecommendMap {
+ const canvas = box.querySelector("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>): 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 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(),
+ };
+}
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css
index fe59d8ca..0eef5e46 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteRecommend.css
@@ -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;
+ }
}
diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py
index 43a2adbd..fb6068d8 100644
--- a/resources/tester/test_b05_routeedit_initial.py
+++ b/resources/tester/test_b05_routeedit_initial.py
@@ -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")