diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index bc1e83b47..6c193c89b 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -11,6 +11,10 @@ * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 + * 바탕(244-12) = 공사지 둘레 거래처 · 기준점 자리까지 담는 고정 범위 `BASE_VIEW` · 넘는 상자가 생길 때만 넓힘 + * 작은 글씨(244-12) = 시점 · 중점 주소 · 좌표(위도, 경도 소수 5자리) · 거래처 주소 — 길면 줄임표(`clip`) + 호버 + * 여유(244-12 ④) = 상자 사이 GAP(겹치면 바깥 방향으로 밀어 냄) · 선 = 상자 테두리에서 끊김 · 선 글 = 상자 · + * 다른 선 · 다른 글을 피한 자리(`placeLabels`) · 상자 안 글 = 안쪽 여백 넘으면 줄임표 * ========================================================================== */ import type { Dir, Leg, MilestoneData } from "./B08_Quantity_Milestone_Calc"; @@ -25,6 +29,10 @@ export interface DiagramBox { label: string; /** 거래처 상자 윗줄 — 표 줄 항목(244-6 · 예 「장비」) */ items?: string; + /** 이름 아래 작은 글씨 줄(244-12 · 주소 · 좌표) */ + notes?: string[]; + /** 호버 = 줄이지 않은 주소 */ + tip?: string; } export interface DiagramLine { @@ -37,6 +45,11 @@ export interface DiagramLine { labels: string[]; /** 글마다 호버(사용자 km ≠ 자동 값이면 「자동 값 ○○ km」 · 아니면 "") */ tips: string[]; + /** 선 글 자리(가운데 · 244-12 ④ 상자 · 다른 선 · 다른 글 피함) · 폭 · 높이 */ + lx: number; + ly: number; + lw: number; + lh: number; } export interface DiagramLayout { @@ -60,12 +73,96 @@ export const DIR_DEG: Record = { }; const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; -const SITE = { w: 240, h: 96 }; -const NODE = { w: 72, h: 32 }; -const BOX = { w: 148, h: 44 }; -const R_BASE = 300; -const R_ROUTE = 260; -const SPREAD = 64; +const SITE = { w: 380, h: 106 }; +const NODE = { w: 124, h: 58 }; +const BOX = { w: 148, h: 58 }; +const R_BASE = 390; +const R_ROUTE = 280; +const PAD = 48; +/** 바탕 반폭 · 반높이 — 기준점 · 공사지 둘레 거래처(같은 거리)가 어느 방위로 와도 담김 */ +const REACH = R_BASE; +export const BASE_VIEW: [number, number, number, number] = [ + -(REACH + BOX.w / 2 + PAD), + -(REACH + BOX.h / 2 + PAD), + 2 * (REACH + BOX.w / 2 + PAD), + 2 * (REACH + BOX.h / 2 + PAD), +]; + +/** 여유(244-12 ④) — 상자 사이 · 선 끝 · 상자 안 글 */ +export const GAP = 12; +const LINE_GAP = 4; +const INSET = 8; +/** 글 크기(px) — 화면 CSS 와 같음(이름 13 · 항목 · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 12, km: 12, note: 10 }; +const KM_LINE = 16; + +export interface Rect { + x: number; + y: number; + w: number; + h: number; +} + +/** 글 폭 어림 — 한글 · 한자 = 글 크기 · 나머지 = 0.6 배 */ +export const textW = (text: string, size: number): number => + [...text].reduce( + (w, ch) => + w + (/[\u1100-\u11ff\u3130-\u318f\u3400-\u9fff\uac00-\ud7a3]/.test(ch) ? 1 : 0.6) * size, + 0, + ); + +/** 한 줄 — 폭 넘으면 줄임표 */ +export function clip(text: string, maxW: number, size: number): string { + if (textW(text, size) <= maxW) return text; + let out = ""; + for (const ch of text) { + if (textW(`${out}${ch}…`, size) > maxW) break; + out += ch; + } + return `${out}…`; +} + +/** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ +const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ + ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), + ...(lonlat ? [`${lonlat[1].toFixed(5)}, ${lonlat[0].toFixed(5)}`] : []), +]; + +/** 두 네모가 gap 안으로 붙거나 겹침 */ +export const hits = (a: Rect, b: Rect, gap = 0): boolean => + a.x < b.x + b.w + gap && b.x < a.x + a.w + gap && a.y < b.y + b.h + gap && b.y < a.y + a.h + gap; + +/** 선분이 네모에 들어가는 자리(0~1 · Liang–Barsky) · 안 지나면 null */ +function segEnter(x1: number, y1: number, x2: number, y2: number, r: Rect): number | null { + let t0 = 0; + let t1 = 1; + const dx = x2 - x1; + const dy = y2 - y1; + for (const [p, q] of [ + [-dx, x1 - r.x], + [dx, r.x + r.w - x1], + [-dy, y1 - r.y], + [dy, r.y + r.h - y1], + ]) { + if (p === 0) { + if (q < 0) return null; + continue; + } + const t = q / p; + if (p < 0) t0 = Math.max(t0, t); + else t1 = Math.min(t1, t); + if (t0 > t1) return null; + } + return t0; +} + +/** 선분이 네모를 지남 */ +export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect): boolean => + segEnter(x1, y1, x2, y2, r) !== null; + +/** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ +const toEdge = (b: Rect, dx: number, dy: number): number => + Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ export function bearingDeg(from: [number, number], to: [number, number]): number { @@ -169,25 +266,105 @@ const unit = (deg: number): [number, number] => { return [Math.sin(rad), -Math.cos(rad)]; }; +/** 선 = 두 상자 테두리에서 끊김(④ · 상자 안으로 안 들어감) · end = 끝 테두리(바깥 → 시점 = 공사지 바탕 테두리 · + * 바탕 안 중점 · 시점 상자를 가로지르지 않음) · 글 자리는 `placeLabels` */ +function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): DiagramLine { + const [ax, ay] = [a.x + a.w / 2, a.y + a.h / 2]; + const [bx, by] = [b.x + b.w / 2, b.y + b.h / 2]; + const [dx, dy] = [bx - ax, by - ay]; + const len = Math.hypot(dx, dy) || 1; + const t1 = Math.min(toEdge(a, dx, dy) + LINE_GAP / len, 0.5); + const t2 = Math.max((segEnter(ax, ay, bx, by, end) ?? 1) - LINE_GAP / len, t1); + const labels = legText(legs); + return { + from: a.id, + to: b.id, + x1: ax + dx * t1, + y1: ay + dy * t1, + x2: ax + dx * t2, + y2: ay + dy * t2, + labels, + tips: legs.map(legTip), + lx: (ax + bx) / 2, + ly: (ay + by) / 2, + lw: labels.length ? Math.max(...labels.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: labels.length * KM_LINE + 2, + }; +} + +export const labelRect = (ln: DiagramLine): Rect => ({ + x: ln.lx - ln.lw / 2, + y: ln.ly - ln.lh / 2, + w: ln.lw, + h: ln.lh, +}); + +/** 선 글 자리(④) — 선 위 가운데부터 앞뒤 · 위아래 차례로 보아 상자 · 다른 선 · 다른 글을 안 덮는 첫 자리 + * (시점 → 중점 글은 공사지 바탕 안이 제자리) · 다 덮이면 가장 적게 덮는 자리 */ +function placeLabels(lines: DiagramLine[], boxes: DiagramBox[]): void { + const placed: Rect[] = []; + for (const ln of lines) { + if (!ln.labels.length) continue; + const [dx, dy] = [ln.x2 - ln.x1, ln.y2 - ln.y1]; + const len = Math.hypot(dx, dy) || 1; + const [px, py] = [-dy / len, dx / len]; + const side = (Math.abs(px) * ln.lw + Math.abs(py) * ln.lh) / 2 + 4; + const score = (r: Rect): number => + boxes.filter((b) => !(b.kind === "site" && ln.from === "start") && hits(r, b, 2)).length + + lines.filter((o) => o !== ln && segHits(o.x1, o.y1, o.x2, o.y2, r)).length + + placed.filter((p) => hits(r, p, 2)).length; + let best: { r: Rect; s: number; x: number; y: number } | null = null; + for (const t of [0.5, 0.4, 0.6, 0.3, 0.7, 0.2, 0.8]) { + for (const o of [0, side, -side]) { + const x = ln.x1 + dx * t + px * o; + const y = ln.y1 + dy * t + py * o; + const r = labelRect({ ...ln, lx: x, ly: y }); + const s = score(r); + if (!best || s < best.s) best = { r, s, x, y }; + if (s === 0) break; + } + if (best?.s === 0) break; + } + ln.lx = best!.x; + ln.ly = best!.y; + placed.push(best!.r); + } +} + export function layoutMilestone( data: MilestoneData, - site: { start_lonlat?: LonLat; mid_lonlat?: LonLat } | null, + site: { + start_lonlat?: LonLat; + mid_lonlat?: LonLat; + start_address?: string | null; + mid_address?: string | null; + } | null, midKm: number, ): DiagramLayout { const boxes: DiagramBox[] = []; - const lines: DiagramLine[] = []; - const center = new Map(); - const put = (box: DiagramBox): void => { + const pending: Array<[string, string, Leg[]]> = []; + const byId = new Map(); + const center = (id: string): [number, number] => { + const b = byId.get(id)!; + return [b.x + b.w / 2, b.y + b.h / 2]; + }; + /** 상자 놓기 — 상자 안 글 줄임(안쪽 여백) · out = 바깥 방향(겹치면 GAP 날 때까지 그쪽으로 밀어 냄) */ + 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++) { + box.x += out[0] * 4; + box.y += out[1] * 4; + } boxes.push(box); - center.set(box.id, [box.x + box.w / 2, box.y + box.h / 2]); + byId.set(box.id, box); }; const line = (from: string, to: string, legs: Leg[]): void => { - const [x1, y1] = center.get(from)!; - const [x2, y2] = center.get(to)!; - lines.push({ from, to, x1, y1, x2, y2, labels: legText(legs), tips: legs.map(legTip) }); + pending.push([from, to, legs]); }; - // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) + // 공사지 = 가운데 컨테이너 · 안에 시점(왼) · 중점(오른) · 사이 = 시점 → 중점 선 · 글 put({ id: "site", kind: "site", @@ -197,24 +374,22 @@ export function layoutMilestone( h: SITE.h, label: "공사지", }); - put({ - id: "start", - kind: "start", - x: -SITE.w / 2 + 16, - y: -6, - w: NODE.w, - h: NODE.h, - label: "시점", - }); - put({ - id: "mid", - kind: "mid", - x: SITE.w / 2 - 16 - NODE.w, - y: -6, - w: NODE.w, - h: NODE.h, - label: "중점", - }); + for (const [id, label, x, address, lonlat] of [ + ["start", "시점", -SITE.w / 2 + 12, site?.start_address, site?.start_lonlat], + ["mid", "중점", SITE.w / 2 - 12 - NODE.w, site?.mid_address, site?.mid_lonlat], + ] as const) { + put({ + id, + kind: id, + x, + y: -SITE.h / 2 + 34, + w: NODE.w, + h: NODE.h, + label, + notes: notesOf(NODE.w, address, lonlat), + tip: address ?? undefined, + }); + } line("start", "mid", [{ road: data.site.mid_road, km: Math.round(midKm * 1000) / 1000 }]); const startLL = site?.start_lonlat ?? null; @@ -227,22 +402,33 @@ export function layoutMilestone( const [ux, uy] = unit(baseDeg); const cx = ux * R_BASE; const cy = uy * R_BASE; - put({ - id: "base", - kind: "base", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, - w: BOX.w, - h: BOX.h, - label: data.base.name || "기준점", - }); + put( + { + id: "base", + kind: "base", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: data.base.name || "기준점", + }, + [ux, uy], + ); line("base", "start", data.base.legs); } // 거래처 — 기준점(거침) · 공사지 기준 방위 · 같은 방위끼리 옆으로 나란히 const groups = new Map< string, - Array<{ id: string; label: string; items: string; anchor: string; deg: number; legs: Leg[] }> + Array<{ + id: string; + label: string; + items: string; + address: string; + anchor: string; + deg: number; + legs: Leg[]; + }> >(); const used = new Set(); const keyOf = (anchor: string, deg: number): string => `${anchor}:${Math.round(deg) % 360}`; @@ -267,41 +453,60 @@ export function layoutMilestone( const key = keyOf(anchor, d); if (!groups.has(key)) groups.set(key, []); const items = [...new Set(r.rows.map((row) => row.item.trim()).filter(Boolean))].join(" · "); - groups - .get(key)! - .push({ id: r.id, label: r.name || "거래처", items, anchor, deg: d, legs: r.legs }); + groups.get(key)!.push({ + id: r.id, + label: r.name || "거래처", + items, + address: r.address, + anchor, + deg: d, + legs: r.legs, + }); } for (const group of groups.values()) { group.forEach((g, k) => { - const [ax, ay] = center.get(g.anchor === "base" ? "base" : "site")!; + const [ax, ay] = center(g.anchor === "base" ? "base" : "site"); const [ux, uy] = unit(g.deg); - const off = (k - (group.length - 1) / 2) * SPREAD; - const reach = g.anchor === "site" ? R_ROUTE + SITE.w / 4 : R_ROUTE; + // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) + const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + const off = (k - (group.length - 1) / 2) * spread; + const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; - put({ - id: g.id, - kind: "route", - x: cx - BOX.w / 2, - y: cy - BOX.h / 2, - w: BOX.w, - h: BOX.h, - label: g.label, - items: g.items, - }); + put( + { + id: g.id, + kind: "route", + x: cx - BOX.w / 2, + y: cy - BOX.h / 2, + w: BOX.w, + h: BOX.h, + label: g.label, + items: g.items, + notes: notesOf(BOX.w, g.address), + tip: g.address, + }, + [ux, uy], + ); line(g.id, g.anchor === "base" ? "base" : "start", g.legs); }); } - const pad = 48; - const xs = boxes.flatMap((b) => [b.x, b.x + b.w]); - const ys = boxes.flatMap((b) => [b.y, b.y + b.h]); - const minX = Math.min(...xs) - pad; - const minY = Math.min(...ys) - pad; + const lines = pending.map(([from, to, legs]) => + edgeLine(byId.get(from)!, byId.get(to)!, legs, byId.get(to === "start" ? "site" : to)!), + ); + placeLabels(lines, boxes); + + // 바탕 = 고정 범위 · 넘는 상자가 있을 때만 그쪽으로 넓힘(새 상자가 와도 다시 줌아웃 없음) + const [bx, by, bw, bh] = BASE_VIEW; + const xs = [bx + PAD, bx + bw - PAD, ...boxes.flatMap((b) => [b.x, b.x + b.w])]; + const ys = [by + PAD, by + bh - PAD, ...boxes.flatMap((b) => [b.y, b.y + b.h])]; + const minX = Math.min(...xs) - PAD; + const minY = Math.min(...ys) - PAD; return { boxes, lines, - view: [minX, minY, Math.max(...xs) + pad - minX, Math.max(...ys) + pad - minY], + view: [minX, minY, Math.max(...xs) + PAD - minX, Math.max(...ys) + PAD - minY], }; } diff --git a/B08_Quantity/B08_Quantity_Router_Milestone.py b/B08_Quantity/B08_Quantity_Router_Milestone.py index 43465fa21..6f962871b 100644 --- a/B08_Quantity/B08_Quantity_Router_Milestone.py +++ b/B08_Quantity/B08_Quantity_Router_Milestone.py @@ -10,7 +10,8 @@ 표지 → 서버 헤드리스 캡처(`B08_Quantity_Milestone_Capture`) → `auto/<경로 id>.png` · 좌표는 그림 뒤 버림 · 실패 = 까닭 한 줄 GET /api/projects/{id}/quantity/milestone/site-address 시점 모달(244-4) — 등록 주소 · - 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) + 시점 · 중점 VWorld 주소(좌표 → 주소 · 서버 단독 · 저장 안 함) · ?mid_m= 다이어그램 + 지금 중점 측점(244-12 · 상자 작은 글씨) POST /api/projects/{id}/quantity/milestone/directions [길찾기](244-7) — 두 좌표 → 카카오 길찾기 → km(소수 1자리) · 실패 · 키 없음 = 까닭 한 줄(서버 단독 · 키 서버만 · 저장 안 함) + 경로 좌표 [위도, 경도] 목록(244-9 · 카카오 지도 길 선 · 그때그때 · 저장 안 함) @@ -245,11 +246,15 @@ async def milestone_save(project_id: UUID, body: Milestone) -> dict[str, Any]: @router.get(PATH + "/site-address") -async def milestone_site_address(project_id: UUID) -> dict[str, Any]: - """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소.""" +async def milestone_site_address( + project_id: UUID, mid_m: Annotated[float | None, Query(ge=0)] = None +) -> dict[str, Any]: + """시점 모달 — 프로젝트 등록 때 사업지역 · 시점 · 중점 경위도와 VWorld 주소. + mid_m = 다이어그램 지금 중점 측점(캐시 · 244-12) · 없으면 저장본.""" root = await _project_root(project_id) data = await asyncio.to_thread(read_data, root) - site = await asyncio.to_thread(site_info, root, data["site"]["mid_station_m"]) + mid = data["site"]["mid_station_m"] if mid_m is None else mid_m + site = await asyncio.to_thread(site_info, root, mid) project = await get_project_by_id(str(project_id)) or {} async def where(lonlat: list[float] | None) -> str | None: diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index 1cac2d074..1581b8005 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -160,6 +160,12 @@ font-size: 12px; } +/* 상자 작은 글씨 = 주소 · 좌표(244-12) */ +.b08-milestone__note { + fill: var(--color-text-secondary); + font-size: 10px; +} + /* 도로 구분 안 고른 줄 = 합계 칸 「도로 구분 고르기」(244-7) */ .b08-milestone .is-pick-road { color: var(--color-danger); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index c4e7cd91b..55bf4e8a9 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -9,6 +9,8 @@ * 거래처 상자 = 항목(윗줄) + 거래처 이름 · 기준점 없으면 위에 [기준점 +](244-6) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 + * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ import { createButton, createSelectField, el } from "@ui/ui_template_elements"; @@ -33,6 +35,7 @@ import { liveMilestone, putMilestone, roadOptions, + siteAddress, siteInfo, } from "./B08_Quantity_UI_Milestone_State"; @@ -148,7 +151,18 @@ export function createDiagram( const data = liveMilestone(projectId); const site = siteInfo(); const mk = midKm(data, site?.mid_default_m ?? null); - const layout = layoutMilestone(data, site, mk); + // 시점 · 중점 주소 · 좌표(244-12) — 지금 중점 측점으로 서버 VWorld(뒤에서 · 오면 다시 그림) + const addr = siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null); + const layout = layoutMilestone( + data, + { + start_lonlat: addr?.start_lonlat ?? site?.start_lonlat, + mid_lonlat: addr?.mid_lonlat ?? site?.mid_lonlat, + start_address: addr?.start_address, + mid_address: addr?.mid_address, + }, + mk, + ); const [vx, vy, vw, vh] = layout.view; // viewBox 원점 0 — 줌 · 팬 계산이 0 기준 · 그림은 안쪽 묶음에서 옮김 const root = svg("svg", { @@ -166,37 +180,36 @@ export function createDiagram( const shift = svg("g", { transform: `translate(${-vx} ${-vy})` }); layer.append(shift); root.append(layer); + // 선 = 공사지 바탕 위 · 시점 · 중점 · 거래처 상자 아래(244-12 · 시점 → 중점 선이 바탕에 안 가림) + const lineLayer = svg("g", {}); for (const ln of layout.lines) { - shift.append( + lineLayer.append( svg("line", { class: "b08-milestone__line", x1: ln.x1, y1: ln.y1, x2: ln.x2, y2: ln.y2 }), ); } for (const box of layout.boxes) { const g = svg("g", { class: `b08-milestone__box is-${box.kind}` }); g.dataset.b08MilestoneBox = box.id; - g.append( - svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), - svg( - "text", - { - x: box.x + box.w / 2, - y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2, - "text-anchor": "middle", - "dominant-baseline": "central", - }, - box.kind === "route" && box.items ? "" : box.label, - ), + // 글 줄 = 항목(거래처 윗줄) · 이름 · 작은 글씨(주소 · 좌표) — 상자 가운데 맞춤 + const rows: Array<[string, string]> = [ + ...(box.kind === "route" && box.items + ? [[box.items, "b08-milestone__items"] as [string, string]] + : []), + [box.label, ""], + ...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"]), + ]; + const x = box.x + box.w / 2; + const text = svg("text", { + x, + y: box.kind === "site" ? box.y + 18 : box.y + box.h / 2 - ((rows.length - 1) * 15) / 2, + "text-anchor": "middle", + "dominant-baseline": "central", + }); + rows.forEach(([row, cls], i) => + text.append(svg("tspan", { x, dy: i ? 15 : 0, ...(cls ? { class: cls } : {}) }, row)), ); - if (box.kind === "route" && box.items) { - // 거래처 상자 = 항목(윗줄) + 거래처 이름(아랫줄) - const x = box.x + box.w / 2; - const two = g.lastChild as SVGTextElement; - two.setAttribute("y", String(box.y + box.h / 2 - 8)); - two.append( - svg("tspan", { x, class: "b08-milestone__items" }, box.items), - svg("tspan", { x, dy: 16 }, box.label), - ); - } + g.append(svg("rect", { x: box.x, y: box.y, width: box.w, height: box.h, rx: 6 }), text); + if (box.tip) g.append(svg("title", {}, box.tip)); // 줄이지 않은 주소 // 상자 누름 = 모달(탭 이동 아님) const info = (): void => INFO[box.kind](box.id); g.setAttribute("role", "button"); @@ -209,12 +222,20 @@ export function createDiagram( if (box.kind === "site") g.append(plus(box, false)); if (box.kind === "base") g.append(plus(box, true)); shift.append(g); + if (box.kind === "site") shift.append(lineLayer); } // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 for (const ln of layout.lines) { - const x = (ln.x1 + ln.x2) / 2; - const y = (ln.y1 + ln.y2) / 2 - ((ln.labels.length - 1) * 16) / 2; - const text = svg("text", { class: "b08-milestone__km", x, y, "text-anchor": "middle" }); + // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) + const x = ln.lx; + const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const text = svg("text", { + class: "b08-milestone__km", + x, + y, + "text-anchor": "middle", + "dominant-baseline": "central", + }); ln.labels.forEach((label, i) => { const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts index 411f9c173..fc0ed923c 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts @@ -378,7 +378,7 @@ export function openBaseInfo(projectId: string, create = false): void { }); } -interface SiteAddress { +export interface SiteAddress { region: string; start_lonlat: [number, number] | null; mid_lonlat: [number, number] | null; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts index a92539d1b..bdb3d9188 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_State.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_State.ts @@ -19,6 +19,7 @@ import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { ROADS, type MilestoneData } from "./B08_Quantity_Milestone_Calc"; import { kmSegments, mapMarks, putAutoKm, type KmSegment } from "./B08_Quantity_Milestone_Layout"; +import type { SiteAddress } from "./B08_Quantity_UI_Milestone_Modal"; export interface SiteInfo { length_m: number | null; @@ -57,6 +58,25 @@ export async function loadMilestone(projectId: string, force = false): Promise

saved?.site ?? null; + +/** 시점 · 중점 주소(244-12 · 다이어그램 상자 작은 글씨) — 중점 측점마다 한 번 서버 VWorld(뒤에서 · + * 오면 다시 그림) · 아직 · 실패 = null(좌표만) */ +const siteAddresses = new Map(); +export function siteAddress(projectId: string, midM: number | null): SiteAddress | null { + const key = `${projectId}:${midM ?? ""}`; + if (!siteAddresses.has(key)) { + siteAddresses.set(key, null); + const query = midM == null ? "" : `?mid_m=${midM}`; + request(`/projects/${projectId}/quantity/milestone/site-address${query}`).then( + (s) => { + siteAddresses.set(key, s); + changes.dispatchEvent(new Event("change")); + }, + () => undefined, + ); + } + return siteAddresses.get(key) ?? null; +} export const routeImages = (): Record => saved?.images ?? {}; /** 보이는 그림이 사용자 그림이고 자동 그림도 있음 = 「자동 그림으로 되돌리기」 */ diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py new file mode 100644 index 000000000..2c83b6a93 --- /dev/null +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -0,0 +1,221 @@ +"""244-12 B08 거리이정표 다이어그램(사용자 142~144번) — ① 넉넉한 고정 바탕(공사지만 있어도 +거래처 · 기준점 자리까지 · 넘는 상자가 생길 때만 넓힘) ② 시점 → 중점 선(공사지 바탕 위) · 시점 · +중점 상자 작은 글씨 주소 · 좌표(위도, 경도 소수 5자리) · 중점 주소 = 지금 중점 측점(?mid_m=) +③ 거래처 상자 = 항목 · 이름 · 주소(줄임표 + 호버). +""" + +import json +import re +import subprocess +import sys +from pathlib import Path +from uuid import uuid4 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from B08_Quantity import B08_Quantity_Router_Milestone as ms # noqa: E402 + +IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE) +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +def _read(rel): + return (ROOT / rel).read_text(encoding="utf-8") + + +def _node(expr): + src = _read("B08_Quantity/B08_Quantity_Milestone_Layout.ts") + result = subprocess.run( + ["node", "--experimental-strip-types", "--no-warnings", + "--input-type=module-typescript", "-e", + IMPORTS.sub("", 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) + + +def _route(rid, dir=None, via=True, address="", item=""): + return {"id": rid, "name": rid, "address": address, "via_base": via, "dir": dir, + "lon": None, "lat": None, "legs": [], + "rows": [{"item": item, "dest": "start", "note": ""}]} # fmt: skip + + +def _data(routes, base=False): + return { + "site": {"mid_station_m": None, "mid_road": "비포장"}, + "base": {"enabled": base, "name": "", "address": "", "dir": None, "legs": []}, + "routes": routes, + } + + +def _clipped(note, box_w): + # 줄임표 · 안쪽 여백(양쪽 8) 안 · 앞부분 그대로 + assert note.endswith("…") and LONG.startswith(note[:-1]) + assert _node(f'textW("{note}", 10)') <= box_w - 16 + + +def _lay(data, site=None): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, {json.dumps(site)}, 1)") + + +def test_바탕_고정_범위_넘을_때만_넓힘(): + base_view = _node("BASE_VIEW") + assert _lay(_data([]))["view"] == base_view # 공사지만 있어도 넉넉한 바탕 + vx, vy, vw, vh = base_view + assert vw >= 900 and vh >= 800 + # 공사지 둘레 8방 거래처 · 기준점(서) — 바탕 그대로(다시 줌아웃 없음) + dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] + for lay in (_lay(_data([_route(d, d, via=False) for d in dirs])), _lay(_data([], base=True))): + assert lay["view"] == base_view + for b in lay["boxes"]: + assert vx <= b["x"] and b["x"] + b["w"] <= vx + vw + assert vy <= b["y"] and b["y"] + b["h"] <= vy + vh + # 기준점 너머 거래처 = 바탕 넘음 → 그쪽만 넓힘 + lay = _lay(_data([_route("far", "W")], base=True)) + x, y, w, h = lay["view"] + far = next(b for b in lay["boxes"] if b["id"] == "far") + assert x < vx and x <= far["x"] - 48 and x + w == vx + vw and (y, h) == (vy, vh) + + +def test_시점_중점_선_상자_주소_좌표(): + site = {"start_lonlat": [127.812345678, 35.7], "mid_lonlat": [127.9, 35.712341], + "start_address": LONG, "mid_address": None} # fmt: skip + lay = _lay(_data([]), site) + boxes = {b["id"]: b for b in lay["boxes"]} + note, coord = boxes["start"]["notes"] + _clipped(note, boxes["start"]["w"]) + assert coord == "35.70000, 127.81235" + assert boxes["start"]["tip"] == LONG # 호버 = 줄이지 않은 주소 + assert boxes["mid"]["notes"] == ["35.71234, 127.90000"] # 주소 없음 = 좌표만 + assert _lay(_data([]))["boxes"][1]["notes"] == [] # 좌표도 없음 = 빈 줄 + line = next(ln for ln in lay["lines"] if ln["from"] == "start") + assert line["to"] == "mid" and line["labels"] == ["비포장 1km"] + s, m = boxes["start"], boxes["mid"] + assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 + for b in (s, m): # 상자 = 공사지 안 + site_box = boxes["site"] + assert site_box["x"] <= b["x"] and b["x"] + b["w"] <= site_box["x"] + site_box["w"] + assert site_box["y"] + 20 < b["y"] and b["y"] + b["h"] <= site_box["y"] + site_box["h"] + + +def test_거래처_상자_항목_이름_주소(): + lay = _lay(_data([_route("g", "N", via=False, address=LONG, item="장비"), + _route("k", "E", via=False, address="거창읍")])) # fmt: skip + boxes = {b["id"]: b for b in lay["boxes"]} + assert (boxes["g"]["items"], boxes["g"]["label"]) == ("장비", "g") + _clipped(boxes["g"]["notes"][0], boxes["g"]["w"]) + assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] + assert _node('clip("가나다", 100, 10)') == "가나다" + assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 + + +def test_화면_선_순서_글_줄_중점_주소(): + ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") + # 선 = 공사지 바탕 뒤 · 다른 상자 앞(시점 → 중점 선 보임) + assert 'if (box.kind === "site") shift.append(lineLayer);' in ui + assert '...(box.notes ?? []).map((n): [string, string] => [n, "b08-milestone__note"])' in ui + assert 'if (box.tip) g.append(svg("title", {}, box.tip));' in ui + assert "siteAddress(projectId, data.site.mid_station_m ?? site?.mid_default_m ?? null)" in ui + state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") + assert "site-address${query}" in state and "?mid_m=${midM}" in state + assert ".b08-milestone__note {" in _read("B08_Quantity/B08_Quantity_UI_Milestone.css") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts", "UI_Milestone_State.ts"): + assert len(_read(f"B08_Quantity/B08_Quantity_{name}").splitlines()) <= 700 + + +def test_중점_주소_지금_측점(tmp_path, monkeypatch): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + async def root(_pid): + return tmp_path + + async def project(_pid): + return {"region": ""} + + async def where(lon, lat): + return f"주소 {lon:.5f}" + + monkeypatch.setattr(ms, "_project_root", root) + monkeypatch.setattr(ms, "get_project_by_id", project) + monkeypatch.setattr(ms, "reverse_geocode", where) + app = FastAPI() + app.include_router(ms.router) + client = TestClient(app) + path = tmp_path / "B05_Profile" / "route" / "planned_route.csv" + path.parent.mkdir(parents=True) + path.write_text("sequence,x,y\n0,208000,367000\n1,209000,367000", encoding="utf-8") + url = f"/api/projects/{uuid4()}/quantity/milestone/site-address" + half = client.get(url).json() # 저장본 없음 = 기본(연장 ÷ 2) + near = client.get(url, params={"mid_m": 100}).json() + assert near["start_lonlat"] == half["start_lonlat"] + assert near["start_lonlat"][0] < near["mid_lonlat"][0] < half["mid_lonlat"][0] + assert near["mid_address"] == f"주소 {near['mid_lonlat'][0]:.5f}" + assert client.get(url, params={"mid_m": -1}).status_code == 422 + + +def _hits(a, b, gap=0): + return (a["x"] < b["x"] + b["w"] + gap and b["x"] < a["x"] + a["w"] + gap + and a["y"] < b["y"] + b["h"] + gap and b["y"] < a["y"] + a["h"] + gap) # fmt: skip + + +def _seg_hits(x1, y1, x2, y2, r): + t0, t1, dx, dy = 0.0, 1.0, x2 - x1, y2 - y1 + for p, q in ((-dx, x1 - r["x"]), (dx, r["x"] + r["w"] - x1), + (-dy, y1 - r["y"]), (dy, r["y"] + r["h"] - y1)): # fmt: skip + if p == 0: + if q < 0: + return False + continue + t = q / p + t0, t1 = (max(t0, t), t1) if p < 0 else (t0, min(t1, t)) + if t0 > t1: + return False + return True + + +def test_여유_상자_선_글_겹침_0(): + """④(145번) — 몰린 꼴(기준점 거침 셋 같은 방위 · 방위 없음 둘 · 공사지 동쪽 셋 · + 서쪽 = 기준점 자리).""" + legs = [{"road": "2차포장", "km": 23.8}] + routes = [{**_route(f"n{i}", "N", address=LONG, item="장비"), "legs": legs} for i in range(3)] + routes += [{**_route(f"f{i}"), "legs": legs} for i in range(2)] + routes += [{**_route(f"e{i}", "E", via=False), "legs": legs} for i in range(3)] + routes += [{**_route("w", "W", via=False), "legs": legs}] # 기준점과 같은 방위 = 밀려남 + data = _data(routes, base=True) + data["base"]["legs"] = [{"road": "1차포장", "km": 0.6}, {"road": "비포장", "km": 0.8}] + lay = _lay(data) + gap = _node("GAP") + boxes = lay["boxes"] + inside = {("site", "start"), ("site", "mid")} + for i, a in enumerate(boxes): # 상자 사이 여유(공사지 안 시점 · 중점만 품음) + for b in boxes[i + 1 :]: + if (a["id"], b["id"]) not in inside: + assert not _hits(a, b, gap - 0.01), (a["id"], b["id"]) + for ln in lay["lines"]: # 선 끝 = 어느 상자 안도 아님(바깥 → 시점 = 공사지 테두리에서 끊김) + for x, y in ((ln["x1"], ln["y1"]), (ln["x2"], ln["y2"])): + for b in boxes: + if not (b["id"] == "site" and ln["from"] == "start"): + assert not (b["x"] < x < b["x"] + b["w"] and b["y"] < y < b["y"] + b["h"]), ( + ln["from"], + ln["to"], + b["id"], + x, + y, + ) + rects = {ln["from"]: _node(f"labelRect({json.dumps(ln, ensure_ascii=False)})") + for ln in lay["lines"] if ln["labels"]} # fmt: skip + for k, r in rects.items(): # 선 글 ↔ 다른 선 + for o in lay["lines"]: + if o["from"] != k: + assert not _seg_hits(o["x1"], o["y1"], o["x2"], o["y2"], r), (k, o["from"]) + keys = list(rects) + for i, k in enumerate(keys): # 선 글 ↔ 상자 · 다른 선 글 + for b in boxes: + if not (b["kind"] == "site" and k == "start"): + assert not _hits(rects[k], b), (k, b["id"]) + for j in keys[i + 1 :]: + assert not _hits(rects[k], rects[j]), (k, j) diff --git a/resources/tester/test_244_6_b08_milestone_pan_client.py b/resources/tester/test_244_6_b08_milestone_pan_client.py index 4d459a64f..341e66d4b 100644 --- a/resources/tester/test_244_6_b08_milestone_pan_client.py +++ b/resources/tester/test_244_6_b08_milestone_pan_client.py @@ -97,8 +97,8 @@ def test_거래처_상자_항목_글(): assert boxes["g"]["items"] == "장비 · 골재" and boxes["g"]["label"] == "거창군청" assert boxes["k"]["items"] == "" and boxes["k"]["label"] == "거래처" ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'svg("tspan", { x, class: "b08-milestone__items" }, box.items)' in ui - assert 'svg("tspan", { x, dy: 16 }, box.label)' in ui + # 244-12 — 글 줄 = 항목(윗줄) · 이름 · 작은 글씨 + assert '[box.items, "b08-milestone__items"]' in ui and '[box.label, ""]' in ui def test_표_폭_다이어그램_폭_좌측_카드_없음():