Merge remote-tracking branch 'origin/dev' into sub_laptop_2
This commit is contained in:
@@ -195,3 +195,72 @@
|
||||
transform: translate(-100%, 0);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 칸을 골랐을 때 그 칸의 알약 — 굵은 테두리 + 바깥 고리(칸이 바뀐 첫 그림만 한 번 반짝) */
|
||||
.ss-link.is-here {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: 2px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.ss-link.is-pop {
|
||||
animation: ss-link-pop 1.2s ease-out 1 both;
|
||||
}
|
||||
|
||||
@keyframes ss-link-pop {
|
||||
0%,
|
||||
40%,
|
||||
80% {
|
||||
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-accent) 55%, transparent);
|
||||
}
|
||||
20%,
|
||||
60%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 0 transparent;
|
||||
}
|
||||
}
|
||||
|
||||
/* 일위대가에 없는 알약 — ⚠ + 점선 붉은 테두리 + [×] */
|
||||
.ss-link.is-orphan {
|
||||
border: 1px dashed var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.ss-link__x {
|
||||
margin-left: 2px;
|
||||
padding: 0 4px;
|
||||
border-radius: 50%;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ss-link__x:hover {
|
||||
background: color-mix(in srgb, var(--color-danger) 20%, transparent);
|
||||
}
|
||||
|
||||
.ss-link-cell.is-orphan {
|
||||
outline: 1px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 보낸 범위(도면 표) — 점선 테두리(채움 없음) + 왼위 이름표 */
|
||||
.ss-link-range {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
box-sizing: border-box;
|
||||
border: 2px dashed var(--color-accent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ss-link-range__tag {
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
bottom: 100%;
|
||||
padding: 0 4px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-canvas);
|
||||
font-size: 10px;
|
||||
line-height: 14px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
import "./spreadsheet_links.css";
|
||||
import { createMapContextMenu } from "@ui/ui_template_context_menu";
|
||||
import { el, showToast } from "@ui/ui_template_elements";
|
||||
import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { inRange, parseA1, parseRange, quoteSheet, toA1 } from "./spreadsheet_address";
|
||||
import { applyCommand } from "./spreadsheet_commands";
|
||||
import { askShare } from "./spreadsheet_link_ask";
|
||||
import { linkHues } from "./spreadsheet_link_color";
|
||||
@@ -53,6 +53,11 @@ export interface LinkOptions {
|
||||
onItemsChange?: (항목: LinkItem[]) => void;
|
||||
/** 자재DB 찾기(빈 글 = 처음 목록) — 자재 모드만 */
|
||||
자재찾기?: (글: string) => Promise<LinkMaterial[]>;
|
||||
/** 입력 모드 — 호스트가 항목 목록을 믿을 수 있게 받았음(받기 실패 · 빈 목록 구분) · true 일 때만
|
||||
* 목록에 없는 연결 알약에 ⚠ + [×] */
|
||||
확정?: boolean;
|
||||
/** 다른 곳으로 보낸 칸 범위 표시(예: 도면 표) — 시트 id · 범위 · 이름표 · 마우스 올림 글 */
|
||||
표시범위?: { 시트: string; 범위: string; 이름: string; 설명?: string }[];
|
||||
}
|
||||
|
||||
export interface Links extends PartHandle {
|
||||
@@ -74,6 +79,8 @@ const TEXT = {
|
||||
fail: "자재 목록을 못 받음",
|
||||
picked: "칸을 고른 뒤 새 알약을 누르면 연결",
|
||||
dup: "이미 목록에 있는 자재",
|
||||
orphan: "일위대가에 없는 알약 — 연결을 풀면 [저장] 때 남음",
|
||||
orphanX: "연결 풀기",
|
||||
};
|
||||
|
||||
type RowCol = Extract<Command, { 종류: "행넣기" | "행지우기" | "열넣기" | "열지우기" }>;
|
||||
@@ -146,6 +153,8 @@ export function mountLinks(
|
||||
let dragging: LinkItem | null = null;
|
||||
const dropMark = el("div", { className: "ss-link-drop" });
|
||||
let pick: HTMLElement | null = null;
|
||||
/** 마지막으로 강조를 판정한 칸(시트 id!칸) — 바뀔 때만 반짝 · 스크롤 */
|
||||
let lastAt = "";
|
||||
|
||||
/** 키 → 연결(시트 · 줄) */
|
||||
function found(): Map<string, { sheet: Sheet; link: CellLink }> {
|
||||
@@ -204,6 +213,8 @@ export function mountLinks(
|
||||
item: LinkItem,
|
||||
hit: { sheet: Sheet; link: CellLink } | undefined,
|
||||
hue: number | undefined,
|
||||
orphan: boolean,
|
||||
here: string,
|
||||
) {
|
||||
const where = hit
|
||||
? hit.sheet.id === ctx.selection.시트
|
||||
@@ -211,15 +222,30 @@ export function mountLinks(
|
||||
: `${quoteSheet(hit.sheet.이름)}!${hit.link.칸}`
|
||||
: "";
|
||||
const b = el("button", {
|
||||
className: hit ? "ss-link is-linked" : "ss-link",
|
||||
attrs: { type: "button", title: hit ? TEXT.linked : TEXT.free, "data-key": item.키 },
|
||||
className: `ss-link${hit ? " is-linked" : ""}${orphan ? " is-orphan" : ""}${
|
||||
here ? ` is-here${here === "new" ? " is-pop" : ""}` : ""
|
||||
}`,
|
||||
attrs: {
|
||||
type: "button",
|
||||
title: orphan ? TEXT.orphan : hit ? TEXT.linked : TEXT.free,
|
||||
"data-key": item.키,
|
||||
},
|
||||
children: [
|
||||
el("span", { text: item.이름 }),
|
||||
el("span", { text: orphan ? `⚠ ${item.이름}` : item.이름 }),
|
||||
...(item.단위 ? [el("small", { text: item.단위 })] : []),
|
||||
...(where ? [el("code", { text: where })] : []),
|
||||
],
|
||||
});
|
||||
if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue));
|
||||
if (orphan && !ctx.readOnly) {
|
||||
const x = el("span", {
|
||||
className: "ss-link__x",
|
||||
text: "×",
|
||||
attrs: { role: "button", title: TEXT.orphanX },
|
||||
});
|
||||
x.addEventListener("click", (e) => (e.stopPropagation(), unlink(), back()));
|
||||
b.append(x);
|
||||
}
|
||||
b.draggable = !ctx.readOnly;
|
||||
b.addEventListener("dragstart", (e) => {
|
||||
dragging = item;
|
||||
@@ -233,13 +259,13 @@ export function mountLinks(
|
||||
// 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림)
|
||||
if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : void link(item)));
|
||||
const unlink = (): void => {
|
||||
function unlink(): void {
|
||||
if (hit)
|
||||
put(
|
||||
hit.sheet,
|
||||
(hit.sheet.연결 ?? []).filter((l) => l.키 !== item.키),
|
||||
);
|
||||
};
|
||||
}
|
||||
b.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const inList = opts?.모드 === "자재" && !item.고정 && items.some((i) => i.키 === item.키);
|
||||
@@ -307,7 +333,17 @@ export function mountLinks(
|
||||
: { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 },
|
||||
);
|
||||
const hues = linkHues(map.keys());
|
||||
const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키)));
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts.모드 === "입력" && opts.확정 === true;
|
||||
// 칸을 고르면 그 칸에 이은 알약 강조 — 칸이 바뀐 첫 그림만 한 번 반짝("new")
|
||||
const at = `${ctx.selection.시트}!${toA1(ctx.selection.활성.r, ctx.selection.활성.c)}`;
|
||||
const fresh = at !== lastAt;
|
||||
lastAt = at;
|
||||
const pills = shown.map((item) => {
|
||||
const hit = map.get(item.키);
|
||||
const here = hit && `${hit.sheet.id}!${hit.link.칸}` === at ? (fresh ? "new" : "same") : "";
|
||||
return pill(item, hit, hues.get(item.키), sure && !!hit && !listed.has(item.키), here);
|
||||
});
|
||||
if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) {
|
||||
const more = el("button", {
|
||||
className: "ss-link ss-link--add",
|
||||
@@ -318,6 +354,8 @@ export function mountLinks(
|
||||
pills.push(more);
|
||||
}
|
||||
bar.replaceChildren(...pills);
|
||||
if (fresh)
|
||||
bar.querySelector(".is-here")?.scrollIntoView?.({ block: "nearest", inline: "nearest" });
|
||||
}
|
||||
|
||||
/** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */
|
||||
@@ -325,6 +363,8 @@ export function mountLinks(
|
||||
for (const d of dots) d.remove();
|
||||
dots = [];
|
||||
const hues = linkHues(found().keys());
|
||||
const listed = new Set(items.map((i) => i.키));
|
||||
const sure = opts?.모드 === "입력" && opts.확정 === true;
|
||||
// 칸마다 이은 알약(이은 차례) — 색 = 먼저 이은 알약 · 둘 넘으면 수
|
||||
const byCell = new Map<string, CellLink[]>();
|
||||
for (const l of ctx.sheet().연결 ?? []) byCell.set(l.칸, [...(byCell.get(l.칸) ?? []), l]);
|
||||
@@ -340,6 +380,7 @@ export function mountLinks(
|
||||
dot.style.left = `${box.x}px`;
|
||||
dot.style.top = `${box.y + box.h - 7}px`;
|
||||
const marks = [tint, dot];
|
||||
if (sure && !listed.has(first.키)) tint.classList.add("is-orphan");
|
||||
if (list.length > 1) {
|
||||
const count = el("div", { className: "ss-link-count", text: String(list.length) });
|
||||
count.style.left = `${box.x + box.w}px`;
|
||||
@@ -350,6 +391,22 @@ export function mountLinks(
|
||||
layer.append(...marks);
|
||||
dots.push(...marks);
|
||||
}
|
||||
// 보낸 범위(도면 표 등) — 채움 없는 점선 테두리 + 왼위 이름표(알약 색 채움과 안 겹침)
|
||||
for (const mark of opts?.표시범위 ?? []) {
|
||||
const g = mark.시트 === ctx.sheet().id ? parseRange(mark.범위) : null;
|
||||
if (!g) continue;
|
||||
const { layer, box } = slot(ctx, g.r0, g.c0);
|
||||
const end = slot(ctx, g.r1, g.c1).box;
|
||||
if (!box.w || !box.h) continue;
|
||||
const frame = el("div", {
|
||||
className: "ss-link-range",
|
||||
attrs: { "data-range": mark.범위 },
|
||||
children: [el("span", { className: "ss-link-range__tag", text: mark.이름 })],
|
||||
});
|
||||
frame.style.cssText = `left:${box.x}px;top:${box.y}px;width:${Math.max(box.w, end.x + end.w - box.x)}px;height:${Math.max(box.h, end.y + end.h - box.y)}px`;
|
||||
layer.append(frame);
|
||||
dots.push(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/** 칸에 마우스를 올리면 이은 알약 이름들(격자 title) — 이은 칸이 아니면 지움 */
|
||||
@@ -360,6 +417,11 @@ export function mountLinks(
|
||||
const names = a1
|
||||
? (ctx.sheet().연결 ?? []).filter((l) => l.칸 === a1).map((l) => l.이름 ?? l.키)
|
||||
: [];
|
||||
if (hit && hit.r >= 0 && hit.c >= 0)
|
||||
for (const m of opts?.표시범위 ?? []) {
|
||||
const g = m.시트 === ctx.sheet().id ? parseRange(m.범위) : null;
|
||||
if (g && inRange(g, hit.r, hit.c)) names.push(m.설명 ?? m.이름);
|
||||
}
|
||||
if (names.length) ctx.grid.root.title = names.join(" · ");
|
||||
else if (tipped) ctx.grid.root.removeAttribute("title");
|
||||
tipped = names.length > 0;
|
||||
|
||||
@@ -104,7 +104,16 @@ def sections(book: str, division: str = "", chapter: str = "", q: str = "", limi
|
||||
|
||||
|
||||
# ── 로직 원문(출처 글 → 품셈 본문 절) ──────────────────────────────────
|
||||
_NUMBER = re.compile(r"(?<![\d-])\d+(?:-\d+)+(?![\d-])")
|
||||
_NUMBER = re.compile(r"(?<![\d-])(\d+(?:-\d+)+)(?![\d-])(?:\s+(주[①-⑳]?|\d{1,2})(?![\d-]))?")
|
||||
_ESCAPE = re.compile(r"\\([.\-*_()#\[\]>~`])") # 마크다운 이스케이프 `\.` `\-` …
|
||||
_COMMENT = re.compile(r"<!--.*?-->") # 글 안 주석(글자층 표시) — 글자만 남김
|
||||
_CIRCLED = "①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮⑯⑰⑱⑲⑳"
|
||||
|
||||
|
||||
def _plain(text: str) -> str:
|
||||
"""원문 md 기호 벗김 — 주석 · 이스케이프 · 인용 머리 `> `."""
|
||||
text = _ESCAPE.sub(r"\1", _COMMENT.sub("", text)).strip()
|
||||
return text[1:].strip() if text.startswith(">") else text
|
||||
|
||||
|
||||
def _blocks(lines: list[str]) -> list[dict]:
|
||||
@@ -117,10 +126,15 @@ def _blocks(lines: list[str]) -> list[dict]:
|
||||
if not text or text.startswith("<!--"):
|
||||
joinable = False
|
||||
continue
|
||||
if not text.startswith(("#", "|")):
|
||||
text = _plain(text)
|
||||
if not text:
|
||||
joinable = False
|
||||
continue
|
||||
if text.startswith("#"):
|
||||
out.append({"h": len(text) - len(text.lstrip("#")), "text": text.lstrip("#").strip()})
|
||||
elif text.startswith("|"):
|
||||
cells = [c.strip() for c in text.strip("|").split("|")]
|
||||
cells = [_plain(c) for c in text.strip("|").split("|")]
|
||||
if all(re.fullmatch(r":?-{3,}:?", c) for c in cells):
|
||||
continue # 머리 · 몸 가름 줄
|
||||
if not joinable:
|
||||
@@ -134,6 +148,44 @@ def _blocks(lines: list[str]) -> list[dict]:
|
||||
return out
|
||||
|
||||
|
||||
def _mark(blocks: list[dict], item: str) -> bool:
|
||||
"""출처 항 · 주 번호(「1」 · 「주③」 · 「주」)에 드는 조각에 hit 표시 — 못 찾으면 False(절 통째만)."""
|
||||
texts = [b.get("p") or b.get("text") or "" for b in blocks]
|
||||
if item.startswith("주"):
|
||||
note = next((i for i, t in enumerate(texts) if t.startswith("[주]")), None)
|
||||
if note is None:
|
||||
return False
|
||||
start, stop = note, len(blocks)
|
||||
if len(item) > 1: # 「주③」 — [주] 아래 ③ 줄 하나(다음 ① ~ ⑳ 줄 앞까지)
|
||||
start = next(
|
||||
(i for i in range(note + 1, len(texts)) if texts[i].startswith(item[1])), None
|
||||
)
|
||||
if start is None:
|
||||
return False
|
||||
stop = next(
|
||||
(i for i in range(start + 1, len(texts)) if texts[i][:1] in _CIRCLED), len(blocks)
|
||||
)
|
||||
else: # 「1」 — 「1. …」 제목 · 줄 ~ 다음 제목 · 「2. …」 앞(줄이면 [주] 앞도)
|
||||
head = re.compile(rf"{item}[.)]\s")
|
||||
start = next((i for i, t in enumerate(texts) if head.match(t)), None)
|
||||
if start is None:
|
||||
return False
|
||||
under = "h" in blocks[start]
|
||||
stop = next(
|
||||
(
|
||||
i
|
||||
for i in range(start + 1, len(blocks))
|
||||
if "h" in blocks[i]
|
||||
or (not under and re.match(r"\d+[.)]\s|\[주\]", texts[i]))
|
||||
or (under and re.match(r"\d+[.)]\s", texts[i]) and "p" in blocks[i])
|
||||
),
|
||||
len(blocks),
|
||||
)
|
||||
for b in blocks[start:stop]:
|
||||
b["hit"] = True
|
||||
return True
|
||||
|
||||
|
||||
def _section(book: str, division: str, number: str) -> dict | None:
|
||||
"""번호 절을 품셈 본문에서 찾음 — 없으면 끝 마디를 하나씩 떼며 위 절로(두 마디까지)."""
|
||||
parts = number.split("-")
|
||||
@@ -178,8 +230,14 @@ def source(src: str) -> dict:
|
||||
if book == "건설품셈"
|
||||
else ""
|
||||
)
|
||||
found = [_section(book, division, n) for n in dict.fromkeys(_NUMBER.findall(src))]
|
||||
return {"source": src, "found": [x for x in found if x]}
|
||||
found = []
|
||||
for number, item in dict.fromkeys(_NUMBER.findall(src)):
|
||||
one = _section(book, division, number)
|
||||
if one:
|
||||
if item and _mark(one["blocks"], item):
|
||||
one["item"] = item
|
||||
found.append(one)
|
||||
return {"source": src, "found": found}
|
||||
|
||||
|
||||
# ── 표 조건 값 ─────────────────────────────────────────────────────────
|
||||
|
||||
@@ -55,3 +55,9 @@
|
||||
.m01lab-src__table td {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
/* 출처가 가리킨 항 · 주 — 옅은 바탕 + 왼쪽 막대 */
|
||||
.m01lab-src__hit {
|
||||
background: color-mix(in srgb, var(--color-accent) 14%, transparent);
|
||||
box-shadow: -3px 0 0 var(--color-accent);
|
||||
}
|
||||
|
||||
@@ -26,15 +26,24 @@ interface Block {
|
||||
text?: string;
|
||||
p?: string;
|
||||
table?: string[][];
|
||||
/** 출처가 가리킨 항 · 주 — 강조 */
|
||||
hit?: boolean;
|
||||
}
|
||||
interface Found {
|
||||
number: string;
|
||||
matched: string;
|
||||
file: string;
|
||||
blocks: Block[];
|
||||
item?: string;
|
||||
}
|
||||
|
||||
function blockNode(b: Block): HTMLElement {
|
||||
const node = rawNode(b);
|
||||
if (b.hit) node.classList.add("m01lab-src__hit");
|
||||
return node;
|
||||
}
|
||||
|
||||
function rawNode(b: Block): HTMLElement {
|
||||
if (b.table)
|
||||
return el("div", {
|
||||
className: "m01-logic__scroll",
|
||||
@@ -89,11 +98,20 @@ export function createSourceView(): SourceView {
|
||||
...found.flatMap((f) => [
|
||||
el("p", {
|
||||
className: "m01-logic__muted m01lab-src__file",
|
||||
text: f.matched === f.number ? f.file : `${f.file} · ${ts("Near", f.number)}`,
|
||||
text: [
|
||||
f.file,
|
||||
f.matched === f.number ? "" : ts("Near", f.number),
|
||||
f.item ? (/^\d+$/.test(f.item) ? `${f.item}항` : f.item) : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" · "),
|
||||
}),
|
||||
...f.blocks.map(blockNode),
|
||||
]),
|
||||
);
|
||||
requestAnimationFrame(() =>
|
||||
body.querySelector(".m01lab-src__hit")?.scrollIntoView({ block: "center" }),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,13 @@ import {
|
||||
showToast,
|
||||
} from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import { getActiveOwner, type Layer, type StructureDoc } from "./M02_MasterTemplete_Api_Fetch";
|
||||
import {
|
||||
getActiveOwner,
|
||||
readTemplate,
|
||||
type Layer,
|
||||
type StructureDoc,
|
||||
} from "./M02_MasterTemplete_Api_Fetch";
|
||||
import type { LinkOptions } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { Selection } from "./M02_MasterTemplete_UI_Main";
|
||||
|
||||
/** 표 칸 하나(CAD `TableCell` 꼴에서 쓰는 것만) */
|
||||
@@ -71,6 +77,8 @@ const TEXT = {
|
||||
noPlace: "이 도면 화면은 표 놓기를 아직 못 함",
|
||||
stale: "면적산출 값이 바뀐 표 {n}개",
|
||||
update: "업데이트",
|
||||
tag: "{id} 도면",
|
||||
tip: "도면 표 {id} 로 보낸 범위 {range} · 글자 {size}",
|
||||
updated: "표 {n}개를 지금 면적 값으로 바꿈 — [저장]하면 남음",
|
||||
} as const;
|
||||
|
||||
@@ -112,6 +120,22 @@ const sameValues = (a: Record<string, string> = {}, b: Record<string, string> =
|
||||
return ka.length === Object.keys(b).length && ka.every((k) => a[k] === b[k]);
|
||||
};
|
||||
|
||||
/** 면적 시트에 겹쳐 보일 「도면으로 보낸 범위」 — 같은 열 도면 `연결표` 에서(받기 실패 = 없음 · 조용히) */
|
||||
export async function sentRanges(at: Selection): Promise<NonNullable<LinkOptions["표시범위"]>> {
|
||||
try {
|
||||
const got = await readTemplate(at.layer, "structure", at.name, at.projectId);
|
||||
const rows = (got.문서 as StructureDoc | undefined)?.연결표 ?? [];
|
||||
return rows.map((row) => ({
|
||||
시트: row.시트,
|
||||
범위: row.범위,
|
||||
이름: fill(TEXT.tag, "id", row.연결),
|
||||
설명: fill(fill(fill(TEXT.tip, "id", row.연결), "range", row.범위), "size", row.글자크기),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export interface AreaToDrawingOptions {
|
||||
/** 같은 페이지에서 그 열 도면 상세를 엶(`UI_Main.open`) */
|
||||
open: (sel: Selection) => Promise<void>;
|
||||
|
||||
@@ -160,7 +160,9 @@ export async function pillsFor(
|
||||
...(p.자재 ? { 자재: p.자재 } : {}),
|
||||
고정: true,
|
||||
}));
|
||||
return 항목.length ? { links: { 항목, 모드: "입력" } } : none(TEXT.noPills);
|
||||
// 받기 성공 = 목록 확정 — 이 목록에 없는 연결 알약만 ⚠(받기 실패 때는 표시 안 함)
|
||||
const links: LinkOptions = { 항목, 모드: "입력", 확정: true };
|
||||
return 항목.length ? { links } : { ...none(TEXT.noPills), links };
|
||||
} catch (error) {
|
||||
return none(fill(TEXT.pillsFailed, why(error)));
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "./M02_MasterTemplete_Structure";
|
||||
import { askSettle } from "./M02_MasterTemplete_TreePanel_Dialogs";
|
||||
import { buildAreaPanel, type AreaPanel } from "./M02_MasterTemplete_AreaPanel";
|
||||
import { createAreaToDrawing } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { createAreaToDrawing, sentRanges } from "./M02_MasterTemplete_AreaToDrawing";
|
||||
import { detailHelp, HELP_TITLE } from "./M02_MasterTemplete_CardHelp";
|
||||
import { helpParts } from "./M02_MasterTemplete_CardShell";
|
||||
import type { SpreadsheetHandle } from "../A00_Common/spreadsheet/spreadsheet";
|
||||
@@ -273,13 +273,14 @@ export function createMain(
|
||||
): Promise<void> {
|
||||
const mine = seq;
|
||||
try {
|
||||
const [{ createSpreadsheet }, pills] = await Promise.all([
|
||||
const [{ createSpreadsheet }, pills, sent] = await Promise.all([
|
||||
import("../A00_Common/spreadsheet/spreadsheet"),
|
||||
pillsFor(kind, at.name),
|
||||
sentRanges(at), // 도면으로 보낸 범위 — 시트 위 점선 테두리
|
||||
]);
|
||||
if (mine !== seq) return;
|
||||
if (pills.notice) showNotice([pills.notice]);
|
||||
const links: LinkOptions = pills.links;
|
||||
const links: LinkOptions = { ...pills.links, 표시범위: sent };
|
||||
const handle: SpreadsheetHandle = createSpreadsheet(
|
||||
host,
|
||||
doc as Parameters<typeof createSpreadsheet>[1],
|
||||
|
||||
@@ -6,7 +6,10 @@ import { createSpreadsheet } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { LinkMaterial, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet";
|
||||
|
||||
const w = window as unknown as Record<string, unknown>;
|
||||
const mode = new URLSearchParams(location.search).get("mode") === "자재" ? "자재" : "입력";
|
||||
const q = new URLSearchParams(location.search);
|
||||
const mode = q.get("mode") === "자재" ? "자재" : "입력";
|
||||
// ?ghost=1 — 목록 확정 + 목록에 없는 연결 알약 X(B1) · 목록 알약 L(B2) / ?range=1 — 도면 보낸 범위 A1:B2
|
||||
const ghost = q.get("ghost") === "1";
|
||||
|
||||
const book: Workbook = {
|
||||
종류: "통합문서",
|
||||
@@ -29,6 +32,11 @@ const book: Workbook = {
|
||||
],
|
||||
활성: "s1",
|
||||
};
|
||||
if (ghost)
|
||||
book.시트[0].연결 = [
|
||||
{ 키: "X", 칸: "B1", 이름: "지운로직", 단위: "m" },
|
||||
{ 키: "L", 칸: "B2", 이름: "길이", 단위: "m" },
|
||||
];
|
||||
|
||||
const MATERIALS: LinkMaterial[] = [
|
||||
{ 코드: "M001", 이름: "레미콘", 규격: "25-21-12", 단위: "m³" },
|
||||
@@ -59,6 +67,14 @@ w.ss = createSpreadsheet(document.getElementById("host")!, book, {
|
||||
{ 키: "H", 이름: "높이", 단위: "m" },
|
||||
{ 키: "A", 이름: "면적", 단위: "m²" },
|
||||
],
|
||||
...(ghost ? { 확정: true } : {}),
|
||||
...(q.get("range") === "1"
|
||||
? {
|
||||
표시범위: [
|
||||
{ 시트: "s1", 범위: "A1:B2", 이름: "T1 도면", 설명: "도면 표 T1 로 보낸 범위" },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
onItemsChange: (목록) => (w.ssItems as unknown[]).push(목록),
|
||||
자재찾기: async (글) => {
|
||||
asked.push(글);
|
||||
|
||||
@@ -61,3 +61,46 @@ def test_tables_split_and_br() -> None:
|
||||
tables = [b["table"] for b in one["blocks"] if "table" in b]
|
||||
assert [len(t[0]) for t in tables] == [7, 2]
|
||||
assert tables[0][2][0] == "인력운반\n타설"
|
||||
|
||||
|
||||
def test_markdown_stripped() -> None:
|
||||
"""역슬래시 · 인용 `>` 머리 · 글 안 주석을 벗김(50-2) — 모든 정본 출처 원문에 0."""
|
||||
bad = 0
|
||||
for path in store.FOLDER.glob("로직_*품셈_*.json"):
|
||||
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
|
||||
for one in make.source(row.get("출처", ""))["found"]:
|
||||
for b in one["blocks"]:
|
||||
t = b.get("p") or b.get("text") or ""
|
||||
bad += "\\" in t or "<!--" in t or t.startswith(">")
|
||||
assert bad == 0
|
||||
one = make.source("산림품셈 3-4-2 1")["found"][0]
|
||||
assert any(b.get("p") == "Q = 3,600 × q × K × f × E / Cm" for b in one["blocks"])
|
||||
|
||||
|
||||
def test_item_marked() -> None:
|
||||
"""출처 항 · 주 번호 → 그 조각만 hit(절은 통째) · 못 찾으면 item 없음."""
|
||||
|
||||
def hits(src: str) -> list[str]:
|
||||
one = make.source(src)["found"][0]
|
||||
return [(b.get("p") or b.get("text") or "table")[:2] for b in one["blocks"] if b.get("hit")]
|
||||
|
||||
one = make.source("산림품셈 3-4-2 1")["found"][0]
|
||||
assert one["item"] == "1" and one["blocks"][0].get("hit") is None
|
||||
assert not any(b.get("hit") for b in one["blocks"] if b.get("p", "").startswith("2."))
|
||||
assert hits("산림품셈 3-4-2 1")[0] == "1."
|
||||
assert hits("산림품셈 9-7-1 주⑥") == ["⑥ "]
|
||||
assert make.source("산림품셈 13-10-2 2")["found"][0]["item"] == "2"
|
||||
assert "item" not in make.source("산림품셈 3-4-2")["found"][0]
|
||||
|
||||
|
||||
def test_item_all_marked() -> None:
|
||||
"""항 · 주 번호가 달린 정본 출처는 전부 표시가 붙음(전수)."""
|
||||
total = missing = 0
|
||||
for path in store.FOLDER.glob("로직_*품셈_*.json"):
|
||||
for row in json.loads(path.read_text(encoding="utf-8"))["줄"]:
|
||||
src = row.get("출처", "")
|
||||
if not any(i for _n, i in make._NUMBER.findall(src)):
|
||||
continue
|
||||
total += 1
|
||||
missing += not any(f.get("item") for f in make.source(src)["found"])
|
||||
assert total >= 30 and missing == 0, (total, missing)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""50-1 면적산출 알약 · 보낸 범위 표시 — 원문 규칙 점검(화면 수치는 ui_checks/50-1_*.js 를 ORCA 로)."""
|
||||
|
||||
import os
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
ROOT = os.path.join(HERE, "..", "..")
|
||||
|
||||
|
||||
def _read(*parts: str) -> str:
|
||||
with open(os.path.join(ROOT, *parts), encoding="utf-8") as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
def test_orphan_mark_only_when_list_confirmed():
|
||||
# ⚠ 는 호스트가 목록을 확정했을 때만 — 알약 받기 실패(확정 없음)는 표시 안 함
|
||||
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
|
||||
assert 'opts.모드 === "입력" && opts.확정 === true' in links
|
||||
structure = _read("M02_MasterTemplete", "M02_MasterTemplete_Structure.ts")
|
||||
assert "확정: true" in structure
|
||||
assert structure.count("확정: true") == 1 # 실패 길(none)은 확정 없음
|
||||
|
||||
|
||||
def test_cell_pick_highlights_pill_and_range_is_unfilled():
|
||||
links = _read("A00_Common", "spreadsheet", "spreadsheet_links.ts")
|
||||
assert "is-here" in links and "scrollIntoView" in links
|
||||
css = _read("A00_Common", "spreadsheet", "spreadsheet_links.css")
|
||||
block = css.split(".ss-link-range {", 1)[1].split("}", 1)[0]
|
||||
assert "dashed" in block and "background" not in block # 채움 없음 = 알약 색과 안 겹침
|
||||
|
||||
|
||||
def test_sent_ranges_from_same_column_drawing():
|
||||
src = _read("M02_MasterTemplete", "M02_MasterTemplete_AreaToDrawing.ts")
|
||||
assert 'readTemplate(at.layer, "structure", at.name, at.projectId)' in src
|
||||
assert '"{id} 도면"' in src
|
||||
@@ -0,0 +1,42 @@
|
||||
/* 50-1 면적 시트 「도면으로 보낸 범위」 읽기(`sentRanges`) — orca eval 한 번(서버 쓰기 없음 · 가짜 응답).
|
||||
* 시작 화면: 로그인한 M02(#/m02-master-template?open=구조물조합&root=master) 아무 화면.
|
||||
* 하는 일: 도면 읽기 길만 가짜 연결표(T1 · T3)로 바꿔 부름 → 그 답 · 실패 때(500) 빈 목록 → fetch 되돌림.
|
||||
* 기대(out): rows = 2 · tag = ["T1 도면","T3 도면"] · tip0 = 「도면 표 T1 로 보낸 범위 A1:B2 · 글자 2.5」 ·
|
||||
* sheet0 = 가짜 시트 id · failed = [] .
|
||||
*/
|
||||
(async () => {
|
||||
const mod =
|
||||
await import("/@fs/C:/Aislo_wt/sub_laptop_3/M02_MasterTemplete/M02_MasterTemplete_AreaToDrawing.ts");
|
||||
const real = window.fetch;
|
||||
const at = { layer: "system", projectId: null, kind: "area", name: "item1", label: "item1" };
|
||||
const out = {};
|
||||
try {
|
||||
window.fetch = async (url, init) => {
|
||||
if (!String(url).includes("/templates/structure/item1")) return real(url, init);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
종류: "structure",
|
||||
이름: "item1",
|
||||
판: "1",
|
||||
문서: {
|
||||
연결표: [
|
||||
{ 연결: "T1", 시트: "s9", 범위: "A1:B2", 글자크기: 2.5, 값: {} },
|
||||
{ 연결: "T3", 시트: "s9", 범위: "C3:D5", 글자크기: 3, 값: {} },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } },
|
||||
);
|
||||
};
|
||||
const rows = await mod.sentRanges(at);
|
||||
out.rows = rows.length;
|
||||
out.tag = rows.map((r) => r.이름);
|
||||
out.tip0 = rows[0]?.설명;
|
||||
out.sheet0 = rows[0]?.시트;
|
||||
window.fetch = async () => new Response("{}", { status: 500 });
|
||||
out.failed = await mod.sentRanges(at);
|
||||
} finally {
|
||||
window.fetch = real;
|
||||
}
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
@@ -0,0 +1,52 @@
|
||||
/* 50-1 면적산출 알약 · 칸 · 보낸 범위 표시 — orca eval 한 번에 도는 시나리오(저장 · 서버 없음).
|
||||
* 시작 화면: /@fs/C:/Aislo_wt/sub_laptop_3/resources/tester/spreadsheet/harness_links.html?ghost=1&range=1 (알약 L · H · A · 연결 X=B1 · L=B2 · 범위 A1:B2).
|
||||
* 하는 일: ① 알약 줄 ② 보낸 범위 상자 ③ 칸 B1 · B2 를 눌러 그 칸 알약 강조 ④ X 의 [×] 로 연결 풀기
|
||||
* 기대(out): orphan = X 만 ⚠ · 목록 알약 L 은 아님 · rangeTag "T1 도면" · rangeFill 투명 · rangeDashed true ·
|
||||
* hereB1 = ["X"] · hereB2 = ["L"] · popOnce true(칸이 바뀐 첫 그림만) · xGone true.
|
||||
*/
|
||||
(async () => {
|
||||
const W = (t) => new Promise((r) => setTimeout(r, t));
|
||||
const q = (s) => document.querySelector(s);
|
||||
const all = (s) => [...document.querySelectorAll(s)];
|
||||
const click = (el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const at = {
|
||||
bubbles: true,
|
||||
button: 0,
|
||||
clientX: r.left + r.width / 2,
|
||||
clientY: r.top + r.height / 2,
|
||||
};
|
||||
const t = document.elementFromPoint(at.clientX, at.clientY) ?? el;
|
||||
for (const type of ["mousedown", "mouseup", "click"]) t.dispatchEvent(new MouseEvent(type, at));
|
||||
};
|
||||
await W(800);
|
||||
const out = {};
|
||||
const pill = (k) => q(`.ss-link[data-key="${k}"]`);
|
||||
out.orphan = all(".ss-link.is-orphan").map((b) => b.dataset.key);
|
||||
out.xText = pill("X")?.textContent;
|
||||
out.lOrphan = pill("L")?.classList.contains("is-orphan");
|
||||
const range = q(".ss-link-range");
|
||||
const cs = range && getComputedStyle(range);
|
||||
out.rangeTag = range?.querySelector(".ss-link-range__tag")?.textContent;
|
||||
out.rangeFill = cs?.backgroundColor;
|
||||
out.rangeDashed = cs?.borderTopStyle === "dashed";
|
||||
out.rangeSize = range && [Math.round(range.offsetWidth), Math.round(range.offsetHeight)];
|
||||
const byKey = (k) => all(".ss-link-cell").find((t) => t.dataset.key === k); // 칸 표시는 고를 때마다 새로 얹힘
|
||||
out.cellOrphan = byKey("X")?.classList.contains("is-orphan");
|
||||
const here = () => all(".ss-link.is-here").map((b) => b.dataset.key);
|
||||
click(byKey("X")); // B1
|
||||
await W(300);
|
||||
out.hereB1 = here();
|
||||
out.popOnce = all(".ss-link.is-pop").length === 1;
|
||||
click(byKey("L")); // B2
|
||||
await W(300);
|
||||
out.hereB2 = here();
|
||||
const doc0 = window.ss.getDoc();
|
||||
const n0 = doc0.시트[0].연결.length;
|
||||
click(pill("X").querySelector(".ss-link__x"));
|
||||
await W(300);
|
||||
const n1 = window.ss.getDoc().시트[0].연결.length;
|
||||
out.xGone = n1 === n0 - 1 && !pill("X");
|
||||
out.xCell = !all(".ss-link-cell").some((t) => t.dataset.key === "X");
|
||||
return out;
|
||||
})();
|
||||
@@ -0,0 +1,71 @@
|
||||
/* 50-2 원문 패널 — 마크다운 기호 벗김 · 출처 항 · 주 강조 · `orca eval --expression "$(cat 이 파일)"` 한 번에 돎 · JSON 돌려줌.
|
||||
*
|
||||
* 시작 화면: M01 마스터 데이터(#/m01-master-data · 시스템 관리자 로그인) · 아무 화면이나.
|
||||
* 로직 GF000003(산림품셈 3-4-2 1) · GF000094(산림품셈 9-7-1 주⑥)을 엶 · 고치지 않음 · 패널 취향을 처음 값으로 되돌림.
|
||||
* 기대 수치:
|
||||
* item1 = {head:"3-4-2. …", file:"1항", hit:12, first:"1. 단가산출 방법은 …", nextNotHit:true, bad:0(역슬래시 · 「<!--」 · 「>」 머리 글 수), inView:true}
|
||||
* note6 = {file:"주⑥", hit:1, first:"⑥ 굴착기의 작업능력 …", nextNotHit:true, bad:0, inView:true}
|
||||
*/
|
||||
(async () => {
|
||||
const W = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const $ = (s, root = document) => root.querySelector(s);
|
||||
const $$ = (s, root = document) => [...root.querySelectorAll(s)];
|
||||
const until = async (f, ms = 15000) => {
|
||||
for (let t = 0; t < ms; t += 200) {
|
||||
const v = f();
|
||||
if (v) return v;
|
||||
await W(200);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const PREF = "aislo:pref:m01-logic-side";
|
||||
const first = localStorage.getItem(PREF);
|
||||
const out = {};
|
||||
const openLogic = async (key) => {
|
||||
sessionStorage.setItem("m01.logic.pending", key);
|
||||
const tab = $$(".ui-collapsible__title").find((h) => h.innerText.trim() === "단가산출 로직");
|
||||
if (!tab) return false;
|
||||
if (!tab.closest(".ui-collapsible").classList.contains("is-collapsed")) tab.click();
|
||||
tab.click();
|
||||
return until(() => {
|
||||
const t = $(".m02-dfp__title")?.innerText ?? "";
|
||||
return !$(".m01lab-side")?.hidden && $(".m01lab-side .m02-dfp") && t;
|
||||
});
|
||||
};
|
||||
const shown = (n) => !!n && n.getBoundingClientRect().width > 0;
|
||||
const panel = (id) => $(`.m01lab-side .ui-side-panels__panel[data-panel="${id}"]`);
|
||||
const strip = (title) =>
|
||||
$$(".m01lab-side .ui-side-panels__handles > *").find(
|
||||
(s) => s.innerText.replace(/\s/g, "") === title,
|
||||
);
|
||||
const inView = (n) => {
|
||||
const r = n.getBoundingClientRect();
|
||||
return r.top >= 0 && r.bottom <= innerHeight && r.height > 0;
|
||||
};
|
||||
const read = async (key, wantFile) => {
|
||||
if (!(await openLogic(key))) return { error: "로직 못 엶" };
|
||||
await W(600);
|
||||
if (!shown(panel("source"))) strip("원문")?.click();
|
||||
await until(() => $$(".m01lab-src__file").some((f) => f.innerText.includes(wantFile)), 6000);
|
||||
await W(600);
|
||||
const ps = $$(".m01lab-src__body > *");
|
||||
const hits = $$(".m01lab-src__hit");
|
||||
const texts = $$(".m01lab-src__p, .m01lab-src__head").map((n) => n.innerText);
|
||||
return {
|
||||
head: $(".m01lab-src__head")?.innerText,
|
||||
file: $(".m01lab-src__file")?.innerText.split(" · ").pop(),
|
||||
hit: hits.length,
|
||||
first: hits[0]?.innerText.slice(0, 20),
|
||||
nextNotHit: !hits.includes(ps[ps.indexOf(hits[hits.length - 1]) + 1]),
|
||||
bad: texts.filter((t) => /\|<!--|^>/.test(t)).length,
|
||||
inView: hits.length > 0 && inView(hits[0]),
|
||||
};
|
||||
};
|
||||
if (!(await openLogic("GF000003"))) return JSON.stringify({ error: "로직 못 엶" });
|
||||
out.item1 = await read("GF000003", "1항");
|
||||
out.note6 = await read("GF000094", "주⑥");
|
||||
// 되돌림 — 처음 취향
|
||||
if (first === null) localStorage.removeItem(PREF);
|
||||
else localStorage.setItem(PREF, first);
|
||||
return JSON.stringify(out);
|
||||
})();
|
||||
Reference in New Issue
Block a user