diff --git a/B08_Quantity/B08_Quantity_Milestone_Calc.ts b/B08_Quantity/B08_Quantity_Milestone_Calc.ts index 7fe174baf..e518c7ee3 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Calc.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Calc.ts @@ -81,6 +81,8 @@ export interface MilestoneRoute { dir: Dir | null; lon?: number | null; lat?: number | null; + /** 다이어그램에 끌어 놓은 자리(153 · 왼 위 · 그림 좌표) — 없으면 방위 자동 자리 */ + pos?: [number, number] | null; /** 업체 → 기준점(거침) · 시점 구간 */ legs: Leg[]; rows: MilestoneRow[]; @@ -95,6 +97,7 @@ export interface MilestoneData { dir: Dir | null; lon?: number | null; lat?: number | null; + pos?: [number, number] | null; /** 기준점 → 시점 공통 구간 */ legs: Leg[]; }; diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index 12f3c648b..fcc802a0c 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -362,11 +362,16 @@ export function layoutMilestone( return [b.x + b.w / 2, b.y + b.h / 2]; }; /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + // 153 사용자가 끌어 놓은 자리(왼 위 · 그림 좌표) — 있으면 방위 자리 · 밀어 냄 대신 그 자리 + const posOf = (id: string): [number, number] | null | undefined => + id === "base" ? data.base.pos : data.routes.find((r) => r.id === id)?.pos; const put = (box: DiagramBox, out?: [number, number]): void => { const inner = box.w - 2 * INSET; box.label = clip(box.label, inner, FONT.label); if (box.items) box.items = clip(box.items, inner, FONT.items); - for (let i = 0; out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { + const pos = posOf(box.id); + if (pos) [box.x, box.y] = pos; + for (let i = 0; !pos && out && i < 400 && boxes.some((b) => hits(box, b, GAP)); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 01f281924..e4f3810de 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -92,6 +92,8 @@ class Place(BaseModel): dir: Dir | None = None lon: float | None = Field(default=None, ge=-180, le=180) lat: float | None = Field(default=None, ge=-90, le=90) + # 153 다이어그램에 끌어 놓은 자리(왼 위 · 그림 좌표) — 없으면 방위 자동 자리 + pos: tuple[float, float] | None = None legs: list[Leg] = Field(default_factory=list, max_length=20) diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index e48adb616..ac5998531 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -56,7 +56,15 @@ } /* 244-2 다이어그램 — 상자 · 선 · km 글(테마 변수) · 스크롤 없음(244-8 · 그림 = 틀 안 맞춤) */ +/* 153 [자동 정렬] — 다이어그램 왼쪽 위(끌어 놓은 자리가 있을 때만 · 오른쪽 위 = 진행단계 판 자리) */ +.b08-milestone__arrange { + position: absolute; + top: 8px; + left: 8px; +} + .b08-milestone__diagram { + position: relative; /* 149 높이 고정 · 그림은 틀 비율로 넓힌 범위에 맞춤(스크롤 없음 · 244-8) */ height: clamp(420px, 58vh, 760px); overflow: hidden; @@ -86,17 +94,29 @@ fill: var(--color-text); } +/* 153 상자 색 — 공사지(시점 · 중점) = 강조색 · 기준점 = 경고색 · 거래처 = 성공색 · 옅은 채움 + 테두리(테마 변수 · 밝음 · 어두움 같이) */ .b08-milestone__box.is-site rect { - fill: var(--color-surface-raised); + fill: color-mix(in srgb, var(--color-accent) 7%, var(--color-surface)); + stroke: var(--color-accent); stroke-dasharray: 4 3; } -.b08-milestone__box.is-base rect, .b08-milestone__box.is-start rect, .b08-milestone__box.is-mid rect { + fill: color-mix(in srgb, var(--color-accent) 16%, var(--color-surface)); stroke: var(--color-accent); } +.b08-milestone__box.is-base rect { + fill: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); + stroke: var(--color-warning); +} + +.b08-milestone__box.is-route rect { + fill: color-mix(in srgb, var(--color-success) 14%, var(--color-surface)); + stroke: var(--color-success); +} + .b08-milestone__box.is-route { cursor: pointer; } diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index abc0811a4..e0370ed0e 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -15,7 +15,7 @@ * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -179,6 +179,61 @@ export function createDiagram( return g; }; + /** 153 상자 끌어 옮김 — 4px 넘게 움직이면 끌기(누름 = 모달은 그대로) · 놓으면 그 자리 저장(캐시 · [저장] 때 서버) + * 기준점을 옮기면 거친 거래처도 따라감(자리 안 정한 것만 · 레이아웃이 기준점 둘레로 놓음) */ + const dragBox = (g: SVGGElement, box: DiagramBox, shift: SVGGElement): void => { + g.addEventListener("pointerdown", (down) => { + if (down.button !== 0) return; + down.stopPropagation(); // 빈 곳 팬 안 함 + const ctm = shift.getScreenCTM(); + const k = ctm ? ctm.a : 1; // 화면 px ÷ 그림 좌표 + let moved = false; + const move = (e: PointerEvent): void => { + const dx = (e.clientX - down.clientX) / k; + const dy = (e.clientY - down.clientY) / k; + if (!moved && Math.hypot(e.clientX - down.clientX, e.clientY - down.clientY) < 4) return; + moved = true; + g.setAttribute("transform", `translate(${dx} ${dy})`); + }; + const up = (e: PointerEvent): void => { + window.removeEventListener("pointermove", move); + window.removeEventListener("pointerup", up); + if (!moved) return; + const swallow = (c: Event): void => c.stopPropagation(); // 끌고 난 누름 = 모달 아님 + g.addEventListener("click", swallow, { capture: true, once: true }); + setTimeout(() => g.removeEventListener("click", swallow, { capture: true }), 0); + const pos: [number, number] = [ + Math.round(box.x + (e.clientX - down.clientX) / k), + Math.round(box.y + (e.clientY - down.clientY) / k), + ]; + const fresh = liveMilestone(projectId); + const target = box.id === "base" ? fresh.base : fresh.routes.find((r) => r.id === box.id); + if (!target) return; + target.pos = pos; + userZoomed = true; // 옮긴 뒤 배율 그대로 + putMilestone(projectId, fresh); + }; + window.addEventListener("pointermove", move); + window.addEventListener("pointerup", up); + }); + }; + + // 153 [자동 정렬] — 끌어 놓은 자리 모두 지우고 방위 자동 자리로 + const arrange = createButton({ + label: "자동 정렬", + size: "sm", + variant: "ghost", + onClick: () => { + const fresh = liveMilestone(projectId); + fresh.base.pos = null; + fresh.routes.forEach((r) => (r.pos = null)); + userZoomed = false; + putMilestone(projectId, fresh); + }, + }); + arrange.classList.add("b08-milestone__arrange"); + arrange.dataset.b08MilestoneArrange = ""; + const refresh = (): void => { const data = liveMilestone(projectId); const site = siteInfo(); @@ -262,6 +317,7 @@ export function createDiagram( info(); }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); + if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); @@ -299,7 +355,8 @@ export function createDiagram( }); shift.append(text); } - host.replaceChildren(root); + arrange.hidden = !data.base.pos && !data.routes.some((r) => r.pos); + host.replaceChildren(root, arrange); attachSvgZoomPan({ svg: root, layer, diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts index af216b5cb..e30eb7972 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts @@ -30,6 +30,8 @@ interface KMap { setCenter(p: LatLng): void; setLevel(level: number): void; relayout(): void; + getBounds(): { contain(p: LatLng): boolean }; + panTo(p: LatLng): void; } interface KOverlay { setMap(map: KMap | null): void; @@ -202,6 +204,7 @@ export function editMap( hint.dataset.b08MilestoneMapHint = ""; let map: KMap | null = null; let drawn: KOverlay[] = []; + let fitted = false; // 153 — 처음 한 번만 맞춤 · 그 뒤 고름 · 끌기는 보기(중심 · 줌) 그대로 const set = (pins: MapPin[], lines: Array>): void => { loadKakaoMaps(projectId).then( @@ -253,13 +256,20 @@ export function editMap( }), ]; drawn.forEach((o) => o.setMap(map)); - if (points.length > 1) { + if (fitted) { + // 보기 밖 표지만 그쪽으로 옮김(줌 그대로) + const view = map.getBounds(); + const out = pins.find((p) => !view.contain(new maps.LatLng(p.lat, p.lon))); + if (out) map.panTo(new maps.LatLng(out.lat, out.lon)); + } else if (points.length > 1) { const bounds = new maps.LatLngBounds(); points.forEach((p) => bounds.extend(p)); map.setBounds(bounds); + fitted = true; } else { map.setCenter(first); map.setLevel(points.length ? 4 : 9); + fitted = points.length > 0; } }), (error: unknown) => { diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index fa9bfb2dc..9a0151aca 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -70,3 +70,37 @@ def test_폭_전체_지도_높이_한_값(): assert "width: 100%;" in block[: block.index("}")] assert ".b08-milestone > .ui-data-table .ui-data-table__table {\n width: 100%;" in css assert css.count("height: 480px;") == 1 and ".b08-milestone__map {" in css + + +def test_153_지도_보기_유지_상자_색_끌어_옮김(): + kmap = _read("B08_Quantity/B08_Quantity_UI_Milestone_KakaoMap.ts") + assert ( + "let fitted = false;" in kmap and "map.panTo(" in kmap + ) # 처음 한 번만 맞춤 · 그 뒤 줌 그대로 + css = _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for kind, var in ( + ("is-site", "--color-accent"), + ("is-base", "--color-warning"), + ("is-route", "--color-success"), + ): + block = css[css.index(f".b08-milestone__box.{kind} rect {{") :] + assert var in block[: block.index("}")], kind + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + assert 'if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift);' in ui + assert 'label: "자동 정렬"' in ui and "fresh.routes.forEach((r) => (r.pos = null));" in ui + + +def test_153_끌어_놓은_자리_레이아웃(): + src = IMPORTS.sub("", _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts")) + data = {"site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": True, "name": "기준", "address": "", "dir": "W", "legs": [], "pos": [500, -40]}, + "routes": [{"id": "r", "name": "a", "address": "", "via_base": False, "dir": "N", "legs": [], + "rows": [], "pos": [-700, 300]}]} # fmt: skip + expr = f"layoutMilestone({json.dumps(data)}, {{}}, 0.5).boxes.filter((b) => b.id === 'base' || b.id === 'r').map((b) => [b.id, b.x, b.y])" + out = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", "--input-type=module-typescript", + "-e", src + f"\nconsole.log(JSON.stringify({expr}));"], + capture_output=True, text=True, encoding="utf-8", timeout=60, + ) # fmt: skip + assert out.returncode == 0, out.stderr + assert sorted(json.loads(out.stdout)) == [["base", 500, -40], ["r", -700, 300]]