feat(B08): 거리이정표 상자 모달 제목 · 상자 안 장소 그림(254-3)
- 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 항목 정보 없음) - 장소 그림(256-1 place_images) = 상자 글 아래 132 × 88 · 그림 없으면 자리 없음 · 키 바뀔 때만 다시 받음 - 시험 = test_254_3 새로 · 244_6 · 244_10 node 입력 = stdin(명령 줄 길이) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz
This commit is contained in:
@@ -38,6 +38,8 @@ export interface DiagramBox {
|
||||
notes?: string[];
|
||||
/** 호버 = 줄이지 않은 주소 */
|
||||
tip?: string;
|
||||
/** 장소 그림 자리(254-3 · 글 아래 · 그림 없으면 없음) */
|
||||
pic?: Rect;
|
||||
}
|
||||
|
||||
export interface DiagramLine {
|
||||
@@ -101,6 +103,8 @@ const KM_LINE = 16;
|
||||
/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */
|
||||
export const ROW = 15;
|
||||
const ROW_PAD = 13;
|
||||
/** 장소 그림(254-3) — 상자 안쪽 폭 · 서버 그림 480 × 320 과 같은 3:2 */
|
||||
export const PIC = { w: 132, h: 88 };
|
||||
const NOTE_LINES = 3;
|
||||
|
||||
/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */
|
||||
@@ -421,8 +425,10 @@ export function layoutMilestone(
|
||||
mid_address?: string | null;
|
||||
} | null,
|
||||
midKm: number,
|
||||
pics: ReadonlySet<string> = new Set(), // 254-3 장소 그림 있는 상자 id
|
||||
): DiagramLayout {
|
||||
const boxes: DiagramBox[] = [];
|
||||
const picH = (id: string): number => (pics.has(id) ? PIC.h + INSET : 0);
|
||||
const pending: Array<[string, string, Leg[]]> = [];
|
||||
const byId = new Map<string, DiagramBox>();
|
||||
const center = (id: string): [number, number] => {
|
||||
@@ -523,7 +529,7 @@ export function layoutMilestone(
|
||||
const [ax, ay] = center(anchor);
|
||||
const [ux, uy] = unit(deg);
|
||||
const notes = b.address ? wrap(b.address, BOX.w - 2 * INSET, FONT.note) : [];
|
||||
const h = boxH(1 + notes.length);
|
||||
const h = boxH(1 + notes.length) + picH(b.id);
|
||||
const reach = reachOf(anchor, h, ux, uy, b.legs);
|
||||
put(
|
||||
{
|
||||
@@ -583,7 +589,7 @@ export function layoutMilestone(
|
||||
// 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음
|
||||
linked: r.linked !== false && (r.to === "site" || anchor === r.to),
|
||||
notes,
|
||||
h: boxH((items ? 1 : 0) + 1 + notes.length),
|
||||
h: boxH((items ? 1 : 0) + 1 + notes.length) + picH(r.id),
|
||||
});
|
||||
}
|
||||
for (const group of groups.values()) {
|
||||
@@ -616,6 +622,9 @@ export function layoutMilestone(
|
||||
});
|
||||
}
|
||||
|
||||
// 254-3 장소 그림 = 글 아래 · 안쪽 여백 안(그림 없으면 자리 없음)
|
||||
for (const b of boxes)
|
||||
if (pics.has(b.id)) b.pic = { x: b.x + INSET, y: b.y + b.h - INSET - PIC.h, ...PIC };
|
||||
const lines = pending.map(([from, to, legs]) =>
|
||||
edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!),
|
||||
);
|
||||
|
||||
@@ -183,6 +183,11 @@
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
/* 254-3 상자 안 장소 그림 — 누름 · 끌기는 상자 몫 */
|
||||
.b08-milestone__pic {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 경로 지도(244-9) — 카카오 지도 칸 · 표지 이름 · 높이 어디서나 한 값(150) */
|
||||
.b08-milestone__map {
|
||||
width: 100%;
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
* 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) ·
|
||||
* 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km)
|
||||
* 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소)
|
||||
* 장소 그림(254-3) = 서버 `place/<상자 id>.png`(256-1) 을 상자 글 아래 작게 · 그림 없으면 자리 없음
|
||||
* 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점
|
||||
* 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소
|
||||
* ========================================================================== */
|
||||
@@ -42,6 +43,8 @@ import { openPlaceModal, openSiteInfo, toastKm } from "./B08_Quantity_UI_Milesto
|
||||
import {
|
||||
findSegmentKm,
|
||||
liveMilestone,
|
||||
placeImageBlobUrl,
|
||||
placeImageKey,
|
||||
putMilestone,
|
||||
roadOptions,
|
||||
siteAddress,
|
||||
@@ -298,6 +301,18 @@ export function createDiagram(
|
||||
addElem.dataset.b08MilestoneAdd = "open";
|
||||
const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] });
|
||||
|
||||
/** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */
|
||||
const picUrls = new Map<string, { key: string; url: Promise<string> }>();
|
||||
const picUrl = (id: string, key: string): Promise<string> => {
|
||||
let got = picUrls.get(id);
|
||||
if (!got || got.key !== key) {
|
||||
got?.url.then(URL.revokeObjectURL, () => undefined);
|
||||
got = { key, url: placeImageBlobUrl(projectId, id) };
|
||||
picUrls.set(id, got);
|
||||
}
|
||||
return got.url;
|
||||
};
|
||||
|
||||
const refresh = (): void => {
|
||||
const data = liveMilestone(projectId);
|
||||
const site = siteInfo();
|
||||
@@ -313,6 +328,7 @@ export function createDiagram(
|
||||
mid_address: addr?.mid_address,
|
||||
},
|
||||
mk,
|
||||
new Set([...data.bases, ...data.routes].map((o) => o.id).filter((id) => placeImageKey(id))),
|
||||
);
|
||||
// 틀 = 페이지 폭 전체 · 높이 고정(CSS · 149) — 그림 범위를 틀 비율로 넓혀 가운데 둠
|
||||
// (화면 px ↔ 그림 좌표 비율이 가로세로 같아야 팬 · 줌이 어긋나지 않음)
|
||||
@@ -361,9 +377,10 @@ export function createDiagram(
|
||||
...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]),
|
||||
];
|
||||
const x = box.x + box.w / 2;
|
||||
const th = box.pic ? box.pic.y - box.y : box.h; // 글 자리 = 그림 위
|
||||
const text = svg("text", {
|
||||
x,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2,
|
||||
y: box.kind === "site" ? box.y + 18 : box.y + th / 2 - ((rows.length - 1) * 15) / 2,
|
||||
"text-anchor": "middle",
|
||||
"dominant-baseline": "central",
|
||||
});
|
||||
@@ -371,6 +388,24 @@ export function createDiagram(
|
||||
text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)),
|
||||
);
|
||||
g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text);
|
||||
// 254-3 장소 그림 — 글 아래 작게(그림 없으면 자리 없음 · 못 읽으면 지움)
|
||||
const key = box.pic && placeImageKey(box.id);
|
||||
if (box.pic && key) {
|
||||
const { x: px, y: py, w: pw, h: ph } = box.pic;
|
||||
const pic = svg("image", {
|
||||
class: "b08-milestone__pic",
|
||||
x: px,
|
||||
y: py,
|
||||
width: pw,
|
||||
height: ph,
|
||||
});
|
||||
pic.setAttribute("preserveAspectRatio", "xMidYMid slice");
|
||||
g.append(pic);
|
||||
picUrl(box.id, key).then(
|
||||
(url) => pic.setAttribute("href", url),
|
||||
() => pic.remove(),
|
||||
);
|
||||
}
|
||||
if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소
|
||||
// 상자 누름 = 모달(탭 이동 아님)
|
||||
const info = (): void => INFO[box.kind](box.id);
|
||||
|
||||
@@ -104,7 +104,7 @@ export function openPlaceModal(
|
||||
? "요소 추가 — 기준점 · 거래처"
|
||||
: base
|
||||
? `기준점 — ${base.name || "기준점"}`
|
||||
: `항목 정보 — ${route!.name || "거래처"}`;
|
||||
: `거래처 — ${route!.name || "거래처"}`; // 254-3 표 머리 글(항목 정보) 없음
|
||||
openModal({
|
||||
title,
|
||||
closeLabel: "닫기",
|
||||
|
||||
@@ -59,9 +59,10 @@ console.log(JSON.stringify({ injected, hint: hint.textContent, boxHidden: box.hi
|
||||
"""
|
||||
)
|
||||
src = IMPORTS.sub("", _read(LAYOUT)) + script
|
||||
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src],
|
||||
"--input-type=module-typescript", "-"], input=src,
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -27,9 +27,10 @@ def _node(script):
|
||||
IMPORTS.sub("", _read(rel))
|
||||
for rel in ("A00_Common/b_svg_zoom_pan.ts", "B08_Quantity/B08_Quantity_Milestone_Layout.ts")
|
||||
)
|
||||
# 254 — 글이 길어 명령 줄(Windows 32767자)을 넘지 않게 stdin 으로 넘김
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-e", src + "\n" + script],
|
||||
"--input-type=module-typescript", "-"], input=src + "\n" + script,
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"""254-3 B08 거리이정표 상자 모달 제목 · 장소 그림(사용자 162-6 · 8).
|
||||
|
||||
① 상자 누름 모달 제목 = 「거래처 — 이름」 · 「기준점 — 이름」(표 머리 글 「항목 정보」 없음)
|
||||
② 장소 그림(256-1 `place_images`) = 상자 안 글 아래 작게 · 그림 없으면 자리 없음(상자 높이 그대로)
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
|
||||
B08 = "B08_Quantity/B08_Quantity_"
|
||||
|
||||
|
||||
def _read(rel):
|
||||
return (ROOT / rel).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _node(expr):
|
||||
src = IMPORTS.sub("", _read(B08 + "Milestone_Layout.ts"))
|
||||
result = subprocess.run(
|
||||
["node", "--experimental-strip-types", "--no-warnings",
|
||||
"--input-type=module-typescript", "-"],
|
||||
input=src + f"\nconsole.log(JSON.stringify({expr}));",
|
||||
capture_output=True, text=True, encoding="utf-8", timeout=60,
|
||||
) # fmt: skip
|
||||
assert result.returncode == 0, result.stderr
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
DATA = {
|
||||
"site": {"mid_station_m": None, "mid_road": "비포장"},
|
||||
"bases": [{"id": "B", "name": "병곡리", "address": "거창읍", "dir": None, "to": "site",
|
||||
"legs": [{"road": None, "km": 0.6}], "linked": True}],
|
||||
"routes": [
|
||||
{"id": "g", "name": "거창군청", "address": "거창읍", "dir": "N", "to": "B",
|
||||
"legs": [{"road": None, "km": 23.8}],
|
||||
"rows": [{"item": "장비", "dest": "start", "note": ""}]},
|
||||
{"id": "k", "name": "김천역", "address": "", "dir": "E", "to": "site",
|
||||
"legs": [], "rows": [{"item": "", "dest": "start", "note": ""}]},
|
||||
],
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def test_장소_그림_상자_안_글_아래():
|
||||
d = json.dumps(DATA, ensure_ascii=False)
|
||||
plain, with_pic = _node(
|
||||
f"[layoutMilestone({d}, null, 0.5), layoutMilestone({d}, null, 0.5, new Set(['B', 'g']))]"
|
||||
)
|
||||
a = {b["id"]: b for b in plain["boxes"]}
|
||||
b = {b["id"]: b for b in with_pic["boxes"]}
|
||||
pic = _node("PIC")
|
||||
assert pic == {"w": 132, "h": 88} # 서버 480 × 320 과 같은 3:2 · 상자 안쪽 폭
|
||||
# 그림 없음 = 자리 없음(높이 그대로 · pic 없음)
|
||||
assert all("pic" not in box for box in a.values())
|
||||
assert "pic" not in b["k"] and b["k"]["h"] == a["k"]["h"]
|
||||
for bid in ("B", "g"):
|
||||
box, before = b[bid], a[bid]
|
||||
assert box["h"] == before["h"] + pic["h"] + 8 # 그림 + 아래 여백만큼 늘림
|
||||
p = box["pic"]
|
||||
# 안쪽 여백 안 · 글 아래(글 자리 = 그림 위 높이 = 그림 없을 때 높이)
|
||||
assert p["x"] == box["x"] + 8 and p["x"] + p["w"] == box["x"] + box["w"] - 8
|
||||
assert p["y"] - box["y"] == before["h"] and p["y"] + p["h"] == box["y"] + box["h"] - 8
|
||||
|
||||
|
||||
def test_모달_제목_화면_그림():
|
||||
modal = _read(B08 + "UI_Milestone_Modal.ts")
|
||||
assert "`거래처 — ${route!.name" in modal and "`기준점 — ${base.name" in modal
|
||||
assert "항목 정보 —" not in modal
|
||||
ui = _read(B08 + "UI_Milestone_Diagram.ts")
|
||||
assert "placeImageBlobUrl(projectId, id)" in ui and "placeImageKey(id)" in ui
|
||||
assert 'pic.setAttribute("href", url)' in ui and "() => pic.remove()" in ui
|
||||
assert "if (!got || got.key !== key)" in ui # 키(이름#시각) 바뀔 때만 다시 받음
|
||||
assert "const th = box.pic ? box.pic.y - box.y : box.h;" in ui # 글 = 그림 위 가운데
|
||||
css = _read(B08 + "UI_Milestone.css")
|
||||
pic = css[css.index(".b08-milestone__pic {") :]
|
||||
assert "pointer-events: none;" in pic[: pic.index("}")] # 누름 · 끌기 = 상자 몫
|
||||
for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_Modal.ts"):
|
||||
assert len(_read(B08 + name).splitlines()) <= 700, name
|
||||
Reference in New Issue
Block a user