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:
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
Reference in New Issue
Block a user