From 2584325b163e27a02067803a3e19df1d9c1a425d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 11 Oct 2026 14:07:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(B08):=20=EA=B1=B0=EB=A6=AC=EC=9D=B4?= =?UTF-8?q?=EC=A0=95=ED=91=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8?= =?UTF-8?q?=EB=9E=A8=20=E2=80=94=20=EA=B8=B0=EC=A4=80=EC=A0=90=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=20=EC=A0=90=20=C2=B7=20=EC=A3=BC=EC=86=8C=20=EC=A4=84?= =?UTF-8?q?=EB=B0=94=EA=BF=88=20=C2=B7=20=EC=84=A0=20=EA=B8=80=20=EB=91=90?= =?UTF-8?q?=20=EC=A4=84=20=C2=B7=20=EA=B1=B0=EB=A6=AC=20=EB=B9=84=EB=A1=80?= =?UTF-8?q?=20=EC=84=A0=20=EA=B8=B8=EC=9D=B4(254-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 연결 점 = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소 + 알림) - 기준점 상자 주소 · 주소 줄바꿈(최대 3줄) + 상자 높이 = 내용 · 항목 글 13px - 선 글 = 구간마다 두 줄(도로 구분 / km) · 처음 자리 선 길이 = 100 + 90·log10(1 + km)(100 ~ 320) - 밀어 냄 = 놓인 상자 · 놓인 선 둘 다 피함 - 시험 = test_254_2 새로 · 244 · 250 기대값(두 줄 · 짧아진 선) · 244_9 node 입력 = stdin Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01STqEqztUthVFBmEP18wBSz --- B08_Quantity/B08_Quantity_Milestone_Layout.ts | 112 +++++++++++---- B08_Quantity/B08_Quantity_UI_Milestone.css | 9 +- .../B08_Quantity_UI_Milestone_Diagram.ts | 70 ++++++---- .../test_244_12_b08_milestone_base_view.py | 6 +- .../test_244_2_b08_milestone_diagram.py | 11 +- .../tester/test_244_4_b08_milestone_modal.py | 4 +- .../test_244_5_b08_milestone_base_dir.py | 2 +- .../tester/test_244_7_b08_milestone_kakao.py | 7 +- .../test_244_9_b08_milestone_route_map.py | 3 +- .../test_250_1_b08_milestone_roads_width.py | 5 +- .../test_254_2_b08_milestone_diagram.py | 129 ++++++++++++++++++ 11 files changed, 289 insertions(+), 69 deletions(-) create mode 100644 resources/tester/test_254_2_b08_milestone_diagram.py diff --git a/B08_Quantity/B08_Quantity_Milestone_Layout.ts b/B08_Quantity/B08_Quantity_Milestone_Layout.ts index f2de77c52..2c99a0062 100644 --- a/B08_Quantity/B08_Quantity_Milestone_Layout.ts +++ b/B08_Quantity/B08_Quantity_Milestone_Layout.ts @@ -6,8 +6,12 @@ * 거래처 자리 = 기준점(거침 · 없으면 공사지) 기준 지도 방위 — 거래처 경위도(모달 주소 검색)가 있으면 * 방위각 자동(244-4 · 기준점 좌표 없으면 시점 좌표로) · 없으면 옛 8방 · 그도 없으면 빈 방위 차례 · * 같은 방위 여럿 = 옆으로 나란히 - * 기준점 자리 = 기준점 주소 좌표 → 공사지(시점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — 옛 8방 무시(244-5) - * 선 = 거래처 → 기준점(또는 시점) · 기준점 → 시점 · 시점 → 중점 — 글 = 「도로 구분 km」 줄마다 + * 기준점 자리 = 기준점 주소 좌표 → 잇는 곳(시점 · 다른 기준점) 기준 방위각 · 좌표 없으면 서(왼 · 견본) — + * 옛 8방 무시(244-5) · 여럿(254) = 잇는 곳이 먼저 놓이게 사슬 차례 + * 선 = 거래처 → to(기준점 · 시점) · 기준점 → to · 시점 → 중점 — 글 = 구간 줄마다 두 줄(1줄 도로 구분 · + * 2줄 km · 254 · `legText` 가 줄바꿈 글자로 나눔) + * 처음 자리 선 길이(254) = 그 구간 km 비례(`lineLen` · 로그 · 최소 · 최대) — 잇는 곳 테두리 → 상자 테두리 + * 상자 높이 = 내용(254 · 항목 · 이름 · 주소 줄바꿈 `wrap` 최대 3줄 · 좌표) · 기준점 상자도 주소 * 화면 = 북 위(SVG y 아래로 = 남) * 선 글 바로 고침(244-4 ⑥) = `legAt` · `setLeg` — 선(from) · 글 차례 → 그 구간(모달과 같은 값 한 벌) * [길찾기] 자동 km(244-7) = `setAutoKm` — 사용자 km 가 있으면 그 값 · 다르면 호버 「자동 값 ○○ km」 @@ -77,11 +81,9 @@ const AUTO: Dir[] = ["N", "NW", "W", "SW", "S", "SE", "E", "NE"]; 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; +/** 바탕 반폭 · 반높이 — 공사지 둘레 상자(짧은 구간 · 선 길이 최소쯤)가 어느 방위로 와도 담김 */ +const REACH = 390; export const BASE_VIEW: [number, number, number, number] = [ -(REACH + BOX.w / 2 + PAD), -(REACH + BOX.h / 2 + PAD), @@ -93,9 +95,17 @@ export const BASE_VIEW: [number, number, number, number] = [ 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 }; +/** 글 크기(px) — 화면 CSS 와 같음(이름 · 항목 13(254 · 거래처 글과 같음) · 선 글 12 · 작은 글씨 10) */ +const FONT = { label: 13, items: 13, km: 12, note: 10 }; const KM_LINE = 16; +/** 상자 안 글 줄 간격 · 위아래 여백(254 상자 높이 = 줄 × ROW + ROW_PAD · 최소 BOX.h) */ +export const ROW = 15; +const ROW_PAD = 13; +const NOTE_LINES = 3; + +/** 선 길이(254) — 구간 km 비례(로그) · 최소 · 최대(너무 멀어지지 않게) · 잇는 곳 테두리 → 상자 테두리 */ +export const lineLen = (km: number): number => + Math.min(320, Math.max(100, 100 + 90 * Math.log10(1 + Math.max(0, km)))); export interface Rect { x: number; @@ -123,6 +133,36 @@ export function clip(text: string, maxW: number, size: number): string { return `${out}…`; } +/** 여러 줄(254) — 폭 넘으면 낱말(빈칸) 사이에서 · 긴 낱말은 글자에서 줄바꿈 · 최대 줄 넘으면 끝 줄 줄임표 */ +export function wrap(text: string, maxW: number, size: number, maxLines = NOTE_LINES): string[] { + const out: string[] = []; + let line = ""; + for (const word of text.trim().split(/\s+/).filter(Boolean)) { + const next = line ? `${line} ${word}` : word; + if (textW(next, size) <= maxW) { + line = next; + continue; + } + if (line) out.push(line); + line = word; + while (textW(line, size) > maxW) { + let cut = ""; + for (const ch of line) { + if (textW(cut + ch, size) > maxW) break; + cut += ch; + } + out.push(cut); + line = line.slice(cut.length); + } + } + if (line) out.push(line); + if (out.length <= maxLines) return out; + return [...out.slice(0, maxLines - 1), clip(out.slice(maxLines - 1).join(" "), maxW, size)]; +} + +/** 상자 높이(254) = 글 줄(항목 · 이름 · 작은 글씨) · 최소 BOX.h */ +const boxH = (rows: number): number => Math.max(BOX.h, rows * ROW + ROW_PAD); + /** 상자 작은 글씨 — 주소(줄임) · 좌표(위도, 경도 소수 5자리) */ const notesOf = (w: number, address: string | null | undefined, lonlat?: LonLat): string[] => [ ...(address ? [clip(address, w - 2 * INSET, FONT.note)] : []), @@ -162,7 +202,7 @@ export const segHits = (x1: number, y1: number, x2: number, y2: number, r: Rect) segEnter(x1, y1, x2, y2, r) !== null; /** 상자 가운데 → 테두리까지 = 선 방향(dx, dy)의 몇 배 */ -const toEdge = (b: Rect, dx: number, dy: number): number => +const toEdge = (b: { w: number; h: number }, dx: number, dy: number): number => Math.min(dx ? b.w / 2 / Math.abs(dx) : Infinity, dy ? b.h / 2 / Math.abs(dy) : Infinity); /** 경위도 두 점 → 방위각(도 · 북 0 · 시계) — 짧은 거리 평면 근사 */ @@ -181,12 +221,19 @@ export function placeDeg( return own.dir ? DIR_DEG[own.dir] : null; } +/** 구간 km(Calc `legKm` 과 같음 · 형 import 만) */ +const kmOf = (leg: Leg): number | null => leg.km ?? leg.auto_km ?? null; + +/** 선 글 — 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km · 줄바꿈 글자로 나눔) */ export const legText = (legs: Leg[]): string[] => legs.map((leg) => { - const km = leg.km ?? leg.auto_km ?? null; // Calc `legKm` 과 같음(형 import 만) - return `${leg.road ?? "도로 구분 고르기"} ${km == null ? "-" : String(km)}km`; + const km = kmOf(leg); + return `${leg.road ?? "도로 구분 고르기"}\n${km == null ? "-" : String(km)}km`; }); +/** 구간 km 합(선 길이 · 254) */ +const legsKm = (legs: Leg[]): number => legs.reduce((sum, leg) => sum + Number(kmOf(leg) ?? 0), 0); + /** 자동 값 호버 — 사용자 km 가 자동 값과 다를 때만 */ export const legTip = (leg: Leg): string => leg.auto_km != null && leg.km != null && leg.km !== leg.auto_km @@ -309,6 +356,7 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia 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); + const rows = labels.flatMap((l) => l.split("\n")); return { from: a.id, to: b.id, @@ -320,8 +368,8 @@ function edgeLine(a: DiagramBox, b: DiagramBox, legs: Leg[], end: Rect = b): Dia 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, + lw: rows.length ? Math.max(...rows.map((l) => textW(l, FONT.km))) + 8 : 0, + lh: rows.length ? rows.length * KM_LINE + 2 : 0, }; } @@ -390,7 +438,11 @@ export function layoutMilestone( if (box.items) box.items = clip(box.items, inner, FONT.items); 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++) { + // 254 선 길이가 짧아져 앞서 놓인 선 위에 앉지 않게 — 놓인 상자 · 놓인 선(가운데 ↔ 가운데) 둘 다 피함 + const blocked = (): boolean => + boxes.some((b) => hits(box, b, GAP)) || + pending.some(([f, t]) => segHits(...center(f), ...center(t), box)); + for (let i = 0; !pos && out && i < 400 && blocked(); i++) { box.x += out[0] * 4; box.y += out[1] * 4; } @@ -446,6 +498,9 @@ export function layoutMilestone( ); return cands.find((c) => !used.has(keyOf(anchor, c))) ?? cands[auto++ % cands.length]; }; + /** 잇는 곳 가운데 → 상자 가운데(254) = 잇는 곳 테두리 + 상자 테두리 + 선 길이(구간 km 비례) */ + const reachOf = (anchor: string, h: number, ux: number, uy: number, legs: Leg[]): number => + toEdge(byId.get(anchor)!, ux, uy) + toEdge({ w: BOX.w, h }, ux, uy) + lineLen(legsKm(legs)); /** 잇는 곳 상자 — 놓인 기준점이면 그 id · 아니면 공사지 */ const anchorOf = (to: string): string => (to !== "site" && byId.has(to) ? to : "site"); @@ -467,15 +522,20 @@ export function layoutMilestone( degOf.set(b.id, deg); 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 reach = reachOf(anchor, h, ux, uy, b.legs); put( { id: b.id, kind: "base", - x: ax + ux * R_BASE - BOX.w / 2, - y: ay + uy * R_BASE - BOX.h / 2, + x: ax + ux * reach - BOX.w / 2, + y: ay + uy * reach - h / 2, w: BOX.w, - h: BOX.h, + h, label: b.name || "기준점", + notes, + tip: b.address || undefined, }, [ux, uy], ); @@ -495,6 +555,8 @@ export function layoutMilestone( deg: number; legs: Leg[]; linked: boolean; + notes: string[]; + h: number; }> >(); const items = data.routes.map((r) => { @@ -509,6 +571,7 @@ 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(" · "); + const notes = r.address ? wrap(r.address, BOX.w - 2 * INSET, FONT.note) : []; groups.get(key)!.push({ id: r.id, label: r.name || "거래처", @@ -519,16 +582,19 @@ export function layoutMilestone( legs: r.legs, // 155 안 이은 거래처 = 선 없음(연결 점으로 이음) · 없는 기준점에 이음 = 선 없음 linked: r.linked !== false && (r.to === "site" || anchor === r.to), + notes, + h: boxH((items ? 1 : 0) + 1 + notes.length), }); } for (const group of groups.values()) { group.forEach((g, k) => { const [ax, ay] = center(g.anchor); const [ux, uy] = unit(g.deg); - // 나란히 = 방위에 직각 · 상자 직각 폭 + 여유만큼(④) - const spread = Math.abs(ux) * BOX.h + Math.abs(uy) * BOX.w + GAP; + // 나란히 = 방위에 직각 · 상자 직각 폭(무리에서 가장 높은 상자) + 여유만큼(④) + const tall = Math.max(...group.map((x) => x.h)); + const spread = Math.abs(ux) * tall + Math.abs(uy) * BOX.w + GAP; const off = (k - (group.length - 1) / 2) * spread; - const reach = g.anchor === "site" ? R_BASE : R_ROUTE; // 공사지 둘레 = 기준점과 같은 거리(선 글 자리) + const reach = reachOf(g.anchor, g.h, ux, uy, g.legs); const cx = ax + ux * reach - uy * off; const cy = ay + uy * reach + ux * off; put( @@ -536,12 +602,12 @@ export function layoutMilestone( id: g.id, kind: "route", x: cx - BOX.w / 2, - y: cy - BOX.h / 2, + y: cy - g.h / 2, w: BOX.w, - h: BOX.h, + h: g.h, label: g.label, items: g.items, - notes: notesOf(BOX.w, g.address), + notes: g.notes, tip: g.address, }, [ux, uy], diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index fe859ca05..2c7509eae 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -118,7 +118,8 @@ stroke: var(--color-success); } -.b08-milestone__box.is-route { +.b08-milestone__box.is-route, +.b08-milestone__box.is-base { cursor: pointer; } @@ -170,10 +171,10 @@ width: 80px; } -/* 거래처 상자 윗줄 = 항목(244-6) */ +/* 거래처 상자 윗줄 = 항목(244-6) · 글 크기 = 거래처 이름(254) */ .b08-milestone__items { fill: var(--color-text-secondary); - font-size: 12px; + font-size: 13px; } /* 상자 작은 글씨 = 주소 · 좌표(244-12) */ @@ -224,7 +225,7 @@ cursor: pointer; } -/* 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 · 공사지에 놓으면 연결 */ +/* 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254) · 끌어 기준점 · 공사지에 놓으면 연결 */ .b08-milestone__connect { fill: var(--color-success); stroke: var(--color-surface); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index ee2fe6286..548c2b7f4 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -10,12 +10,14 @@ * 상자 누름(244-13) = 등록 모달 같은 한 벌(거래처 · 기준점 = 수정 · 삭제) · 선 · 선 글 누름 = 연결선 모달 * (도로 구분 · 거리 · 다음 지도 경로 · `…_UI_Milestone_LegModal`) · 선 글 더블클릭 = 바로 고침(그대로) * 선 글 더블클릭 = 그 구간 도로 구분 ▾ · km 바로 고침(⑥ · 글 화면 자리에 띄움 · Enter = 반영 · Esc = 취소) - * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) + * 선 글 km = 사용자 값 · 없으면 [길찾기] 자동 값 · 다르면 호버 「자동 값 ○○ km」(244-7) · + * 구간 줄마다 두 줄(254 · 1줄 도로 구분 · 2줄 km) + * 연결 점 ●(254) = 거래처 · 기준점 상자 왼쪽 위 안쪽 · 기준점 = 시점 · 다른 기준점에 놓음(돌기면 취소) * 244-12 — 바탕 = 고정 범위(`BASE_VIEW` · 빈 곳 더블클릭 원배율 = 이 바탕) · 선 = 공사지 바탕 위(시점 → 중점 * 선 보임) · 시점 · 중점 = 주소 · 좌표 작은 글씨 · 거래처 = 항목 · 이름 · 주소 세 줄 · 호버 = 줄이지 않은 주소 * ========================================================================== */ -import { createButton, createSelectField, el } from "@ui/ui_template_elements"; +import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -28,6 +30,7 @@ import { import { layoutMilestone, legAt, + makesLoop, ownerOf, panContent, panStart, @@ -201,13 +204,13 @@ export function createDiagram( }); }; - /** 154 연결 점 ● — 거래처 상자 오른쪽 가운데 · 끌어 기준점 상자 = 그 기준점 거침(254 · to = 기준점 id) · - * 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 같은 연결 = 취소 · 바뀌면 그 거래처 구간 새로(km 비움) + - * 길찾기 한 번(km · 경로 그림) */ + /** 154 연결 점 ● — 거래처 · 기준점 상자 왼쪽 위 안쪽(254 · 테두리 안) · 끌어 기준점 상자 = 그 기준점으로 + * (to = 기준점 id) · 공사지(시점 · 중점) = 시점으로 바로 · 빈 곳 · 자기 · 같은 연결 = 취소 · 기준점 돌기 + * (A → B → A) = 취소 + 알림 · 바뀌면 그 구간 새로(km 비움) + 길찾기 한 번(km · 거래처 = 경로 그림) */ const connectDot = (box: DiagramBox, shift: SVGGElement): SVGCircleElement => { - const cx = box.x + box.w; - const cy = box.y + box.h / 2; - const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 7 }); + const cx = box.x + 10; + const cy = box.y + 10; + const dot = svg("circle", { class: "b08-milestone__connect", cx, cy, r: 6 }); dot.append(svg("title", {}, "끌어서 기준점 · 공사지에 놓으면 연결")); dot.addEventListener("click", (e) => e.stopPropagation()); // 상자 모달 안 뜸 dot.addEventListener("pointerdown", (down) => { @@ -229,6 +232,7 @@ export function createDiagram( const g = hit?.closest("[data-b08-milestone-box]"); const id = g?.dataset.b08MilestoneBox; if (id === "site" || id === "start" || id === "mid") return "site"; + if (id === box.id) return null; // 자기 = 취소 return id && liveMilestone(projectId).bases.some((b) => b.id === id) ? id : null; }; const move = (e: PointerEvent): void => { @@ -250,9 +254,16 @@ export function createDiagram( over?.classList.remove("is-drop-target"); const t = targetOf(e); const fresh = liveMilestone(projectId); - const route = fresh.routes.find((r) => r.id === box.id); + const route = ownerOf(fresh, box.id); if (!t || !route) return; if (route.linked !== false && route.to === t) return; // 같은 연결 = 그대로 + if (box.kind === "base" && makesLoop(fresh, box.id, t)) { + showToast( + "연결이 돌아 제자리로 옵니다(기준점 A → B → A) — 연결하지 않았습니다.", + "warning", + ); + return; + } route.to = t; route.linked = true; // 155 요소 추가 뒤 첫 이음 route.legs = [{ road: null, km: null }]; // 옛 구간 km = 다른 길 — 새로 길찾기 @@ -371,15 +382,16 @@ export function createDiagram( }); g.addEventListener("keydown", (e) => e.key === "Enter" && info()); if (box.kind === "route" || box.kind === "base") dragBox(g, box, shift); - if (box.kind === "route") g.append(connectDot(box, shift)); + if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift)); shift.append(g); if (box.kind === "site") shift.append(lineLayer); } - // 선 글은 상자 위에(가리지 않게 맨 끝) — 줄마다 한 줄 + // 선 글은 상자 위에(가리지 않게 맨 끝) — 구간 줄마다 두 줄(254 · 도로 구분 / km) for (const ln of layout.lines) { // 자리 = 레이아웃이 상자 · 다른 선 · 다른 글을 피해 고른 곳(244-12 ④) const x = ln.lx; - const y = ln.ly - ((ln.labels.length - 1) * 16) / 2; + const count = ln.labels.reduce((n, l) => n + l.split("\n").length, 0); + const y = ln.ly - ((count - 1) * 16) / 2; const text = svg("text", { class: "b08-milestone__km", x, @@ -387,23 +399,27 @@ export function createDiagram( "text-anchor": "middle", "dominant-baseline": "central", }); + let first = true; ln.labels.forEach((label, i) => { - const span = svg("tspan", { x, dy: i ? 16 : 0 }, label); - span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; - if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); - // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 let wait = 0; - span.addEventListener("click", (e) => { - e.stopPropagation(); - clearTimeout(wait); - wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); - }); - span.addEventListener("dblclick", (e) => { - e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 - clearTimeout(wait); - editLegAt(projectId, span, ln.from, i, mk); - }); - text.append(span); + for (const row of label.split("\n")) { + const span = svg("tspan", { x, dy: first ? 0 : 16 }, row); + first = false; + span.dataset.b08MilestoneLeg = `${ln.from}:${i}`; + if (ln.tips[i]) span.append(svg("title", {}, ln.tips[i])); + // 누름 = 연결선 모달(더블클릭이 오면 취소) · 더블클릭 = 바로 고침 + span.addEventListener("click", (e) => { + e.stopPropagation(); + clearTimeout(wait); + wait = window.setTimeout(() => openLegInfo(projectId, ln.from), 250); + }); + span.addEventListener("dblclick", (e) => { + e.stopPropagation(); // 원배율 되돌림(공용 더블클릭) 막음 + clearTimeout(wait); + editLegAt(projectId, span, ln.from, i, mk); + }); + text.append(span); + } }); shift.append(text); } diff --git a/resources/tester/test_244_12_b08_milestone_base_view.py b/resources/tester/test_244_12_b08_milestone_base_view.py index f08f32187..a5365f1c2 100644 --- a/resources/tester/test_244_12_b08_milestone_base_view.py +++ b/resources/tester/test_244_12_b08_milestone_base_view.py @@ -94,7 +94,7 @@ def test_시점_중점_선_상자_주소_좌표(): 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"] + assert line["to"] == "mid" and line["labels"] == ["비포장\n1km"] # 254 두 줄 s, m = boxes["start"], boxes["mid"] assert m["x"] - (s["x"] + s["w"]) >= 100 # 선 글 자리 for b in (s, m): # 상자 = 공사지 안 @@ -108,7 +108,9 @@ def test_거래처_상자_항목_이름_주소(): _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"]) + # 254 긴 주소 = 줄바꿈(낱말 사이) + 상자 세로 늘림(줄 × 15 + 13 · 최소 58) + assert boxes["g"]["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] + assert boxes["g"]["h"] == 4 * 15 + 13 and boxes["k"]["h"] == 58 assert boxes["g"]["tip"] == LONG and boxes["k"]["notes"] == ["거창읍"] assert _node('clip("가나다", 100, 10)') == "가나다" assert _node(f'clip("{LONG}", 108, 10)') == "경상남도 거창군 북상…" # 한글 10 · 빈칸 6 · … 6 diff --git a/resources/tester/test_244_2_b08_milestone_diagram.py b/resources/tester/test_244_2_b08_milestone_diagram.py index 37577bf9e..ac4b3f24a 100644 --- a/resources/tester/test_244_2_b08_milestone_diagram.py +++ b/resources/tester/test_244_2_b08_milestone_diagram.py @@ -66,7 +66,7 @@ def test_공사지_컨테이너_시점_중점_선(): b = next(x for x in lay["boxes"] if x["id"] == bid) assert site["x"] <= b["x"] and b["x"] + b["w"] <= site["x"] + site["w"] assert lay["lines"] == [ - {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장 0.75km"]} + {**lay["lines"][0], "from": "start", "to": "mid", "labels": ["비포장\n0.75km"]} # 254 두 줄 ] @@ -78,8 +78,9 @@ def test_8방_자리_기준점_없음_공사지_기준(): ex, ey = _center(lay, "e") nx, ny = _center(lay, "n") wx, wy = _center(lay, "sw") + # 254 선 길이 = km 비례(빈 km = 최소 100) — 공사지 테두리(가로 190 · 세로 53)에서 잼 assert ex > cx + 200 and abs(ey - cy) < 1 - assert ny < cy - 200 and abs(nx - cx) < 1 + assert ny < cy - 150 and abs(nx - cx) < 1 assert wx < cx and wy > cy line = next(ln for ln in lay["lines"] if ln["from"] == "e") assert line["to"] == "start" # 기준점 없으면 시점으로 @@ -105,8 +106,8 @@ def test_기준점_기준_방위_선_글(): kx, _ = _center(lay, "k") assert kx > cx + 200 # 기준점 안 거침 = 공사지 동쪽 labels = {(ln["from"], ln["to"]): ln["labels"] for ln in lay["lines"]} - assert labels[("g", "b1")] == ["2차포장 23.8km"] - assert labels[("b1", "start")] == ["1차포장 0.6km", "비포장 0.8km"] + assert labels[("g", "b1")] == ["2차포장\n23.8km"] # 254 두 줄(도로 구분 / km) + assert labels[("b1", "start")] == ["1차포장\n0.6km", "비포장\n0.8km"] assert labels[("k", "start")] == [] assert next(b for b in lay["boxes"] if b["id"] == "b1")["label"] == "병곡리 968-31" @@ -130,7 +131,7 @@ def test_빈_방위_차례_같은_방위_나란히(): cx, cy = _center(lay, "c") dx, dy = _center(lay, "d") sx, sy = _center(lay, "site") - assert cy < sy - 200 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) + assert cy < sy - 150 and abs(cx - sx) < 1 # 고르지 않음 = 빈 방위 첫째(북) assert (dx, dy) != (cx, cy) and dx < sx # 다음 빈 방위(북서) boxes = [b for b in lay["boxes"] if b["kind"] == "route"] for i, p in enumerate(boxes): # 업체 상자끼리 안 겹침 diff --git a/resources/tester/test_244_4_b08_milestone_modal.py b/resources/tester/test_244_4_b08_milestone_modal.py index c29c60cf8..2cd00857e 100644 --- a/resources/tester/test_244_4_b08_milestone_modal.py +++ b/resources/tester/test_244_4_b08_milestone_modal.py @@ -63,7 +63,7 @@ def test_방위_좌표_자동(): lay = _node(f"layoutMilestone({json.dumps(migrate_milestone(data))}, {json.dumps(site)}, 0)") bx, by = _center(lay, "b1") sx, sy = _center(lay, "s") - assert abs(sx - bx) < 1 and sy > by + 200 # 남 = 아래 + assert abs(sx - bx) < 1 and sy > by + 150 # 남 = 아래(254 빈 km = 최소 선 길이) cx, cy = _center(lay, "site") ex, ey = _center(lay, "e") assert ex > cx + 200 and abs(ey - cy) < 1 @@ -75,7 +75,7 @@ def test_방위_좌표_자동(): bx, by = _center(lay, "b1") nx, ny = _center(lay, "n") assert bx < cx - 200 # 기준점 = 시점 서쪽(좌표) - assert abs(nx - bx) < 1 and ny < by - 200 + assert abs(nx - bx) < 1 and ny < by - 150 def test_다음_지도_길찾기_주소(): diff --git a/resources/tester/test_244_5_b08_milestone_base_dir.py b/resources/tester/test_244_5_b08_milestone_base_dir.py index 216b0aa41..53c593e4f 100644 --- a/resources/tester/test_244_5_b08_milestone_base_dir.py +++ b/resources/tester/test_244_5_b08_milestone_base_dir.py @@ -49,7 +49,7 @@ def test_좌표_없음_서쪽_옛_방위_무시(): def test_좌표_있음_공사지_기준_방위각(): (cx, cy), (bx, by) = _layout({**BASE, "dir": "W", "lon": 127.8, "lat": 35.8}, SITE) - assert abs(bx - cx) < 1 and by < cy - 200 # 북 = 위(옛 「서」 무시) + assert abs(bx - cx) < 1 and by < cy - 150 # 북 = 위(옛 「서」 무시 · 254 최소 선 길이) (cx, cy), (bx, by) = _layout({**BASE, "dir": None, "lon": 127.9, "lat": 35.7}, SITE) assert bx > cx + 200 and abs(by - cy) < 1 # 동 = 오른 # 시점 좌표 모름 = 서쪽 기본 diff --git a/resources/tester/test_244_7_b08_milestone_kakao.py b/resources/tester/test_244_7_b08_milestone_kakao.py index 4f99399f2..9523bf302 100644 --- a/resources/tester/test_244_7_b08_milestone_kakao.py +++ b/resources/tester/test_244_7_b08_milestone_kakao.py @@ -189,9 +189,10 @@ def test_구간_채움(): # 선 글 = 사용자 값 우선 · 다르면 호버 · 안 고름 = 「도로 구분 고르기」 + 자동 값 lay = _node(LAYOUT, f"layoutMilestone({json.dumps(put)}, {{start_lonlat: {START}}}, 0.75)") line = {ln["from"]: ln for ln in lay["lines"]} - assert line["r1"]["labels"] == ["2차포장 23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] - assert line["b1"]["labels"] == ["도로 구분 고르기 1.4km"] and line["b1"]["tips"] == [""] - assert line["start"]["labels"] == ["비포장 0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 + # 254 선 글 두 줄(도로 구분 / km) + assert line["r1"]["labels"] == ["2차포장\n23.8km"] and line["r1"]["tips"] == ["자동 값 10.5 km"] + assert line["b1"]["labels"] == ["도로 구분 고르기\n1.4km"] and line["b1"]["tips"] == [""] + assert line["start"]["labels"] == ["비포장\n0.75km"] # 시점 → 중점 = 노선 측점 거리 그대로 def test_사용자_값_우선_고르기_표시(): diff --git a/resources/tester/test_244_9_b08_milestone_route_map.py b/resources/tester/test_244_9_b08_milestone_route_map.py index 7fd521f39..df41ff5d7 100644 --- a/resources/tester/test_244_9_b08_milestone_route_map.py +++ b/resources/tester/test_244_9_b08_milestone_route_map.py @@ -47,9 +47,10 @@ def _read(rel): def _node(rels, script): src = "".join(IMPORTS.sub("", _read(rel)) for rel in rels) + # 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 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 93982a914..c965c4822 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -115,7 +115,10 @@ def test_153_끌어_놓은_자리_레이아웃(): def test_154_연결_점_끌어_잇기(): ui = _read("B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts") - assert 'if (box.kind === "route") g.append(connectDot(box, shift));' in ui + # 254 기준점 상자에도 연결 점 + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) assert "route.to = t;" in ui # 254 놓은 기준점 id · 공사지 = "site" assert "route.legs = [{ road: null, km: null }];" in ui # 옛 구간 km 비움 assert "void findSegmentKm(projectId, route.id).then(toastKm);" in ui # 새 길찾기 한 번 diff --git a/resources/tester/test_254_2_b08_milestone_diagram.py b/resources/tester/test_254_2_b08_milestone_diagram.py new file mode 100644 index 000000000..73f092158 --- /dev/null +++ b/resources/tester/test_254_2_b08_milestone_diagram.py @@ -0,0 +1,129 @@ +"""254-2 B08 거리이정표 다이어그램(사용자 162-4 · 5 · 6 · 10) — 기준점 상자 연결 점 · 주소 · +연결 점 왼쪽 위 안쪽 · 항목 글 = 거래처 글 · 주소 줄바꿈 + 상자 높이 · 선 글 두 줄 · +선 길이 = 거리 비례(로그 · 최소 · 최대). +""" + +import json +import math +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_" +LONG = "경상남도 거창군 북상면 병곡리 968-31" + + +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) + + +def _leg(km): + return {"road": None, "km": km} + + +def _route(rid, dir, km, to="site", address=""): + return {"id": rid, "name": rid, "address": address, "dir": dir, "to": to, + "legs": [_leg(km)] if km is not None else [], + "rows": [{"item": "장비", "dest": "start", "note": ""}]} # fmt: skip + + +def _base(bid, to, km, address="", lon=None, lat=None): + return {"id": bid, "name": bid, "address": address, "dir": None, "to": to, "lon": lon, + "lat": lat, "legs": [_leg(km)], "linked": True} # fmt: skip + + +def _data(bases, routes): + return {"site": {"mid_station_m": None, "mid_road": "비포장"}, "bases": bases, "routes": routes} + + +def _lay(data): + return _node(f"layoutMilestone({json.dumps(data, ensure_ascii=False)}, null, 0.5)") + + +def _boxes(lay): + return {b["id"]: b for b in lay["boxes"]} + + +def test_선_길이_거리_비례_최소_최대(): + kms = [0, 1, 10, 23.8, 100, 1e6, -3] + got = _node(f"{json.dumps(kms)}.map(lineLen)") + want = [min(320, max(100, 100 + 90 * math.log10(1 + max(0, k)))) for k in kms] + assert all(abs(a - b) < 1e-9 for a, b in zip(got, want)) + assert got[0] == 100 and got[5] == 320 and got[6] == 100 # 최소 · 최대(너무 멀지 않게) + assert got[0] < got[1] < got[2] < got[3] < got[4] + # 동쪽 거래처 셋 — 공사지 테두리 → 상자 테두리 = 그 구간 선 길이 + lay = _lay(_data([], [_route("a", "E", 1), _route("b", "S", 60), _route("c", "W", None)])) + boxes = _boxes(lay) + site = boxes["site"] + a, b, c = boxes["a"], boxes["b"], boxes["c"] + assert abs((a["x"]) - (site["x"] + site["w"]) - got[1]) < 1e-6 + assert abs(b["y"] - (site["y"] + site["h"]) - _node("lineLen(60)")) < 1e-6 + assert abs(site["x"] - (c["x"] + c["w"]) - 100) < 1e-6 # 빈 km = 최소 + + +def test_기준점_사슬_자리_주소_높이(): + bases = [_base("A", "B", 4.5, address=LONG), _base("B", "site", 0.6, address="거창읍")] + lay = _lay(_data(bases, [_route("g", "N", 23.8, to="A")])) + boxes = _boxes(lay) + a, b, g = boxes["A"], boxes["B"], boxes["g"] + assert (a["kind"], b["kind"]) == ("base", "base") + # 기준점 상자 = 주소(줄바꿈) · 높이 = 이름 + 주소 줄 + assert a["notes"] == ["경상남도 거창군 북상면", "병곡리 968-31"] and a["tip"] == LONG + assert a["h"] == 3 * 15 + 13 and b["notes"] == ["거창읍"] and b["h"] == 58 + # B = 공사지 서쪽(좌표 없음) · A = B 둘레(B 에 이음) · g = A 북쪽 + site = boxes["site"] + assert b["x"] + b["w"] < site["x"] and a["x"] + a["w"] < b["x"] + b["w"] + assert g["y"] + g["h"] < a["y"] + lines = {(ln["from"], ln["to"]) for ln in lay["lines"]} + assert {("g", "A"), ("A", "B"), ("B", "start"), ("start", "mid")} == lines + # 아주 긴 주소 = 최대 3줄 · 끝 줄 줄임표 + many = " ".join([LONG] * 4) + rows = _node(f'wrap("{many}", 132, 10)') + assert len(rows) == 3 and rows[-1].endswith("…") + assert all(_node(f'textW("{r}", 10)') <= 132 for r in rows) + + +def test_선_글_두_줄(): + lay = _lay(_data([_base("B", "site", 0.6)], [_route("g", "N", 23.8, to="B")])) + line = next(ln for ln in lay["lines"] if ln["from"] == "B") + assert line["labels"] == ["1차포장\n0.6km"] or line["labels"] == ["도로 구분 고르기\n0.6km"] + assert line["lh"] == 2 * 16 + 2 # 두 줄 높이 + two = _node("legText([{road: '1차포장', km: 0.6}, {road: '비포장', km: null}])") + assert two == ["1차포장\n0.6km", "비포장\n-km"] + ui = _read(B08 + "UI_Milestone_Diagram.ts") + assert 'for (const row of label.split("\\n"))' in ui # 줄마다 tspan + assert "span.dataset.b08MilestoneLeg = `${ln.from}:${i}`;" in ui # 두 줄 다 그 구간 + + +def test_연결_점_왼쪽_위_안쪽_기준점도_돌기_취소(): + ui = _read(B08 + "UI_Milestone_Diagram.ts") + dot = ui[ui.index("const connectDot") :] + dot = dot[: dot.index("return dot;")] + # 왼쪽 위 안쪽 — 가운데 (10, 10) · 반지름 6 + 테두리 2 = 상자 안 + assert "const cx = box.x + 10;" in dot and "const cy = box.y + 10;" in dot and "r: 6" in dot + assert ( + 'if (box.kind === "route" || box.kind === "base") g.append(connectDot(box, shift));' in ui + ) + assert 'if (box.kind === "base" && makesLoop(fresh, box.id, t))' in dot + assert "if (id === box.id) return null;" in dot # 자기 = 취소 + assert "route.to = t;" in dot and "route.linked = true;" in dot + css = _read(B08 + "UI_Milestone.css") + items = css[css.index(".b08-milestone__items {") :] + assert "font-size: 13px;" in items[: items.index("}")] # 항목 = 거래처 글 + assert "items: 13" in _read(B08 + "Milestone_Layout.ts") + for name in ("Milestone_Layout.ts", "UI_Milestone_Diagram.ts"): + assert len(_read(B08 + name).splitlines()) <= 700, name