feat(b08): 거리이정표 — 카카오 지도 보기 유지 · 상자 색 · 상자 끌어 옮김 · 자동 정렬 (250-2)

- 카카오 지도 처음 한 번만 맞춤 · 고르기 · 끌기 뒤 중심 · 줌 그대로
- 공사지 강조색 · 기준점 경고색 · 거래처 성공색(테마 변수 · 밝음 · 어두움)
- 거래처 · 기준점 상자 끌어 옮김(pos 저장) · [자동 정렬]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LLCk2y5xE8YGVgjbTntWsY
This commit is contained in:
2026-10-11 00:13:15 +09:00
co-authored by Claude Opus 5.5
parent d70b66f57e
commit 5a249afb21
7 changed files with 137 additions and 6 deletions
@@ -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[];
};
@@ -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;
}
@@ -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)
+22 -2
View File
@@ -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;
}
@@ -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,
@@ -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<Array<[number, number]>>): 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) => {
@@ -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]]