From 3ad007683ced9f382af2180d4761b9b3d24cfbdc Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 4 Oct 2026 22:29:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(spreadsheet):=20=EC=95=8C=EC=95=BD=20?= =?UTF-8?q?=EB=81=8C=EC=96=B4=20=EC=B9=B8=EC=97=90=20=EB=86=93=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EC=9D=80=20=EC=95=8C=EC=95=BD=20=EC=83=89=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=E2=80=94=20=ED=82=A4=EB=A1=9C=20=EC=A0=95?= =?UTF-8?q?=ED=95=B4=EC=A7=80=EB=8A=94=20=EC=83=89(24=20=EC=83=89=20=C2=B7?= =?UTF-8?q?=20=EB=91=90=20=ED=85=8C=EB=A7=88=20=EB=B0=9D=EA=B8=B0)=20?= =?UTF-8?q?=C2=B7=20=EC=9D=B4=EC=9D=80=20=EC=B9=B8=20=EB=B0=B0=EA=B2=BD=20?= =?UTF-8?q?=EA=B0=99=EC=9D=80=20=EC=83=89=20=EC=98=85=EA=B2=8C=20=C2=B7=20?= =?UTF-8?q?=EB=8B=A4=EB=A5=B8=20=EC=95=8C=EC=95=BD=20=EC=B9=B8=EC=97=90=20?= =?UTF-8?q?=EB=86=93=EC=9C=BC=EB=A9=B4=20=EB=B0=94=EA=BF=80=EC=A7=80=20?= =?UTF-8?q?=EB=AC=BC=EC=9D=8C=20=C2=B7=20=EB=B0=98=EC=A7=9D=EC=9E=84=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=2040-3=20=E2=91=A1=20?= =?UTF-8?q?=E2=91=A2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4 --- .../spreadsheet/spreadsheet_link_color.ts | 38 +++++++ A00_Common/spreadsheet/spreadsheet_links.css | 55 ++++++++-- A00_Common/spreadsheet/spreadsheet_links.ts | 103 +++++++++++++++--- .../spreadsheet/spreadsheet_link_set_entry.ts | 24 ++++ resources/tester/test_spreadsheet_set_link.py | 18 ++- 5 files changed, 217 insertions(+), 21 deletions(-) create mode 100644 A00_Common/spreadsheet/spreadsheet_link_color.ts diff --git a/A00_Common/spreadsheet/spreadsheet_link_color.ts b/A00_Common/spreadsheet/spreadsheet_link_color.ts new file mode 100644 index 000000000..7e77b8a02 --- /dev/null +++ b/A00_Common/spreadsheet/spreadsheet_link_color.ts @@ -0,0 +1,38 @@ +/* ============================================================================= + * spreadsheet_link_color.ts (주인 D) + * 이은 알약 색 — 알약 키로 정해지는 색상(hue). 알약 · 이은 칸 배경 · 칸 세모가 같은 색(PLAN 40-3). + * 칸 24 개(30° 바퀴 12 + 15° 어긋난 바퀴 12) — 키 해시로 첫 자리 · 겹치면 다음 빈 자리(같은 문서 안에서 서로 다르게). + * 밝기 · 채도는 테마마다 CSS(`spreadsheet_links.css` `--ss-link-l`) · 여기는 색상만 · DOM 모름(시험이 Node 로 부름). + * ========================================================================== */ + +const SLOTS = 24; + +/** FNV-1a 32비트 — 키 글자로 늘 같은 수 */ +function hash(text: string): number { + let h = 0x811c9dc5; + for (let i = 0; i < text.length; i += 1) { + h ^= text.charCodeAt(i); + h = Math.imul(h, 0x01000193) >>> 0; + } + return h; +} + +/** 자리 → 색상(°) — 0~11 = 30° 간격 · 12~23 = 그 사이(15° 어긋남) */ +const hueOf = (slot: number): number => (slot % 12) * 30 + (slot >= 12 ? 15 : 0); + +/** 이은 알약 키들 → 키마다 색상(°) — 키 차례와 무관(정렬해 자리 배정) · 24 개 넘으면 다시 돎 */ +export function linkHues(keys: Iterable): Map { + const out = new Map(); + const used = new Set(); + for (const key of [...new Set(keys)].sort()) { + if (used.size >= SLOTS) used.clear(); + const start = hash(key) % 12; + // 첫 바퀴(30°)를 다 쓴 뒤에야 둘째 바퀴 — 적을 때 서로 30° 넘게 벌어짐 + let slot = start; + for (let i = 1; i < SLOTS && used.has(slot); i += 1) + slot = (i < 12 ? 0 : 12) + ((start + i) % 12); + used.add(slot); + out.set(key, hueOf(slot)); + } + return out; +} diff --git a/A00_Common/spreadsheet/spreadsheet_links.css b/A00_Common/spreadsheet/spreadsheet_links.css index b61fdf11e..edecb144f 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.css +++ b/A00_Common/spreadsheet/spreadsheet_links.css @@ -33,14 +33,55 @@ color: var(--color-accent); } -/* 연결됨 — 흐림(연결 현황) */ -.ss-link.is-linked { - opacity: 0.5; - border-style: dashed; +/* 이은 알약 색(40-3) — 색상 = 알약 키(`--ss-link-h` · spreadsheet_link_color) · 밝기 · 옅기 = 테마 */ +.ss { + --ss-link-s: 70%; + --ss-link-l: 31%; + --ss-link-fill: 0.16; } -.ss-link.is-linked:hover { - opacity: 0.8; +:root[data-theme="dark"] .ss { + --ss-link-l: 66%; + --ss-link-fill: 0.26; +} + +@media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) .ss { + --ss-link-l: 66%; + --ss-link-fill: 0.26; + } +} + +/* 연결됨 — 알약 색 테두리 · 왼쪽 띠 · 옅은 배경(글은 본문 색 그대로 · 읽힘) */ +.ss-link.is-linked { + border-color: hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill)); + box-shadow: inset 4px 0 0 hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); +} + +.ss-link.is-linked code { + color: inherit; +} + +.ss-link[draggable="true"] { + cursor: grab; +} + +/* 이은 칸 — 알약 색 옅게(칸 글 위 · 누름은 칸으로 지나감) */ +.ss-link-cell { + position: absolute; + z-index: 2; + background: hsl(var(--ss-link-h, 0) var(--ss-link-s) 50% / var(--ss-link-fill)); + pointer-events: none; +} + +/* 알약을 끌 때 놓일 칸 */ +.ss-link-drop { + position: absolute; + z-index: 4; + box-sizing: border-box; + border: 2px dashed var(--color-accent); + pointer-events: none; } .ss-link--add { @@ -55,7 +96,7 @@ width: 0; height: 0; border-top: 6px solid transparent; - border-left: 6px solid var(--color-accent); + border-left: 6px solid hsl(var(--ss-link-h, 0) var(--ss-link-s) var(--ss-link-l)); pointer-events: none; } diff --git a/A00_Common/spreadsheet/spreadsheet_links.ts b/A00_Common/spreadsheet/spreadsheet_links.ts index 2890bef60..45e9a1572 100644 --- a/A00_Common/spreadsheet/spreadsheet_links.ts +++ b/A00_Common/spreadsheet/spreadsheet_links.ts @@ -1,8 +1,9 @@ /* ============================================================================= * spreadsheet_links.ts (주인 D) * 아래 알약 막대(표 · 탭 · 합계 줄 아래 맨 끝) — 면적산출(입력값) · 재료산출(자재) 알약을 칸에 연결(PLAN 13장). - * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결(알약 흐려짐 + 칸 주소 · 칸 왼아래 작은 세모) · - * 흐린 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. + * 칸을 고른 채 밝은 알약을 누르면 그 칸에 연결 · 알약을 끌어 칸에 놓아도 연결(놓은 칸에 다른 알약이 있으면 + * 바꿀지 물음 · 40-3) · 이은 알약 = 키로 정해지는 색(`spreadsheet_link_color`) · 이은 칸 배경 같은 색 옅게 + + * 칸 왼아래 작은 세모 · 이은 알약을 누르면 연결 칸으로 · 우클릭 [연결 풀기] · 자재 모드는 [자재 더하기] → 고르기 창 → 새 알약. * 자재 모드 알약 목록 = 호스트(M02)가 `항목` 으로 줌(자재 정보 포함 · 저장은 마스터 자재 조합) — [자재 더하기] · * 우클릭 [목록에서 빼기] 때 `onItemsChange(목록)` 으로 알림(부른 쪽이 마스터에 적음 · 여기는 안 적음). * 저장 = 시트 `연결`(계약) · 바꿈은 `시트통째` 명령(되돌리기 짝) · 행열 넣기 · 지우기는 `applyLinked` 가 @@ -11,9 +12,10 @@ import "./spreadsheet_links.css"; import { createMapContextMenu } from "@ui/ui_template_context_menu"; -import { el, showToast } from "@ui/ui_template_elements"; +import { el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { parseA1, quoteSheet, toA1 } from "./spreadsheet_address"; import { applyCommand } from "./spreadsheet_commands"; +import { linkHues } from "./spreadsheet_link_color"; import type { slotOf } from "./spreadsheet_editor"; import { shiftSpan } from "./spreadsheet_refshift"; import { selectCell } from "./spreadsheet_selection"; @@ -55,8 +57,10 @@ export interface Links extends PartHandle { } const TEXT = { - bar: "알약 — 칸을 고른 채 누르면 연결 · 흐린 알약은 누르면 연결 칸으로 · 우클릭 풀기", - free: "칸을 고른 채 누르면 이 칸에 연결", + bar: "알약 — 칸을 고른 채 누르거나 끌어 칸에 놓으면 연결 · 색 알약은 누르면 연결 칸으로 · 우클릭 풀기", + free: "칸을 고른 채 누르거나 끌어 칸에 놓으면 이 칸에 연결", + swap: "{cell} 은 이미 「{name}」 알약 칸 — 이 알약으로 바꿈?", + swapOk: "바꿈", linked: "누르면 연결 칸으로 · 우클릭 연결 풀기", unlink: "연결 풀기", remove: "목록에서 빼기", @@ -136,6 +140,9 @@ export function mountLinks( /** 알약 목록 — 호스트가 준 항목 + 더한 자재(자재 모드 · 바뀌면 호스트에 알림) */ const items: LinkItem[] = opts ? [...opts.항목] : []; let dots: HTMLElement[] = []; + /** 끄는 중인 알약 · 놓을 칸 표시 */ + let dragging: LinkItem | null = null; + const dropMark = el("div", { className: "ss-link-drop" }); let pick: HTMLElement | null = null; /** 키 → 연결(시트 · 줄) */ @@ -153,10 +160,11 @@ export function mountLinks( ctx.dispatch({ 종류: "시트통째", 시트: next }); } - function link(item: LinkItem): void { + /** 알약을 칸에 이음 — `at` 없으면 지금 고른 칸 */ + function link(item: LinkItem, at?: string): void { if (ctx.readOnly) return; const sheet = ctx.sheet(); - const 칸 = toA1(ctx.selection.활성.r, ctx.selection.활성.c); + const 칸 = at ?? toA1(ctx.selection.활성.r, ctx.selection.활성.c); const rest = (sheet.연결 ?? []).filter((l) => l.칸 !== 칸 && l.키 !== item.키); const one: CellLink = { 키: item.키, 이름: item.이름, 칸 }; if (item.단위) one.단위 = item.단위; @@ -184,7 +192,11 @@ export function mountLinks( const commit = (): void => opts?.onItemsChange?.(items.map((i) => ({ ...i }))); - function pill(item: LinkItem, hit: { sheet: Sheet; link: CellLink } | undefined) { + function pill( + item: LinkItem, + hit: { sheet: Sheet; link: CellLink } | undefined, + hue: number | undefined, + ) { const where = hit ? hit.sheet.id === ctx.selection.시트 ? hit.link.칸 @@ -199,7 +211,19 @@ export function mountLinks( ...(where ? [el("code", { text: where })] : []), ], }); - b.addEventListener("mousedown", (e) => e.preventDefault()); // 초점은 격자에 둠 + if (hue !== undefined) b.style.setProperty("--ss-link-h", String(hue)); + b.draggable = !ctx.readOnly; + b.addEventListener("dragstart", (e) => { + dragging = item; + e.dataTransfer?.setData("text/plain", item.키); + if (e.dataTransfer) e.dataTransfer.effectAllowed = "link"; + }); + b.addEventListener("dragend", () => { + dragging = null; + dropMark.remove(); + }); + // 초점은 격자에 둠 — 끌 수 있는 알약은 막으면 끌기가 안 돼 그대로(누른 뒤 `back` 이 격자로 돌림) + if (!b.draggable) b.addEventListener("mousedown", (e) => e.preventDefault()); b.addEventListener("click", () => (hit ? goTo(hit.sheet, hit.link.칸) : link(item))); const unlink = (): void => { if (hit) @@ -274,7 +298,8 @@ export function mountLinks( ? ofMaterial(link.자재) : { 키: link.키, 이름: link.이름 ?? link.키, 단위: link.단위 }, ); - const pills = shown.map((item) => pill(item, map.get(item.키))); + const hues = linkHues(map.keys()); + const pills = shown.map((item) => pill(item, map.get(item.키), hues.get(item.키))); if (opts.모드 === "자재" && opts.자재찾기 && !ctx.readOnly) { const more = el("button", { className: "ss-link ss-link--add", @@ -287,23 +312,67 @@ export function mountLinks( bar.replaceChildren(...pills); } - /** 칸 왼아래 작은 세모 — 칸이 든 판 층에 얹어 스크롤을 따라감 */ + /** 이은 칸 — 알약 색 옅은 배경 + 왼아래 작은 세모 · 칸이 든 판 층에 얹어 스크롤을 따라감 */ function placeDots(): void { for (const d of dots) d.remove(); dots = []; + const hues = linkHues(found().keys()); for (const l of ctx.sheet().연결 ?? []) { const p = parseA1(l.칸); if (!p) continue; const { layer, box } = slot(ctx, p.r, p.c); if (!box.w || !box.h) continue; // 숨긴 행열 + const tint = el("div", { className: "ss-link-cell", attrs: { "data-key": l.키 } }); + tint.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; const dot = el("div", { className: "ss-link-dot" }); dot.style.left = `${box.x}px`; dot.style.top = `${box.y + box.h - 7}px`; - layer.append(dot); - dots.push(dot); + for (const d of [tint, dot]) d.style.setProperty("--ss-link-h", String(hues.get(l.키) ?? 0)); + layer.append(tint, dot); + dots.push(tint, dot); } } + // ── 알약 끌어 칸에 놓기(40-3) ────────────────────────────────────────── + /** 끄는 알약 아래 칸(A1) — 머리 · 격자 밖이면 null */ + function cellAt(e: DragEvent): { r: number; c: number } | null { + const hit = ctx.grid.hitTest(e.clientX, e.clientY); + return hit && hit.r >= 0 && hit.c >= 0 ? { r: hit.r, c: hit.c } : null; + } + const onDragOver = (e: DragEvent): void => { + const at = dragging && !ctx.readOnly ? cellAt(e) : null; + if (!at) return void dropMark.remove(); + e.preventDefault(); // 놓을 수 있음 + if (e.dataTransfer) e.dataTransfer.dropEffect = "link"; + const { layer, box } = slot(ctx, at.r, at.c); + dropMark.style.cssText = `left:${box.x}px;top:${box.y}px;width:${box.w}px;height:${box.h}px`; + if (dropMark.parentElement !== layer) layer.append(dropMark); + }; + const onDragLeave = (e: DragEvent): void => { + if (!ctx.grid.root.contains(e.relatedTarget as Node | null)) dropMark.remove(); + }; + const onDrop = async (e: DragEvent): Promise => { + const item = dragging; + const at = item && !ctx.readOnly ? cellAt(e) : null; + dropMark.remove(); + dragging = null; + if (!item || !at) return; + e.preventDefault(); + const 칸 = toA1(at.r, at.c); + const other = (ctx.sheet().연결 ?? []).find((l) => l.칸 === 칸 && l.키 !== item.키); + const name = other?.이름 ?? other?.키 ?? ""; + if ( + other && + !(await showConfirmDialog( + TEXT.swap.replace("{cell}", 칸).replace("{name}", name), + TEXT.swapOk, + )) + ) + return back(); + ctx.select(selectCell(ctx.sheet(), at.r, at.c), true); + link(item, 칸); + }; + // ── 자재 고르기 창 ────────────────────────────────────────────────────── function closePick(): void { pick?.remove(); @@ -384,6 +453,10 @@ export function mountLinks( const onZoom = (): void => placeDots(); ctx.grid.root.addEventListener("aislo-grid-zoom", onZoom); + const onDropEvent = (e: DragEvent): void => void onDrop(e); + ctx.grid.root.addEventListener("dragover", onDragOver); + ctx.grid.root.addEventListener("dragleave", onDragLeave); + ctx.grid.root.addEventListener("drop", onDropEvent); return { root: opts ? bar : null, @@ -394,6 +467,10 @@ export function mountLinks( }, destroy() { ctx.grid.root.removeEventListener("aislo-grid-zoom", onZoom); + ctx.grid.root.removeEventListener("dragover", onDragOver); + ctx.grid.root.removeEventListener("dragleave", onDragLeave); + ctx.grid.root.removeEventListener("drop", onDropEvent); + dropMark.remove(); closePick(); for (const d of dots) d.remove(); menu.element.remove(); diff --git a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts index 032bfb659..a2ba19fb1 100644 --- a/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts +++ b/resources/tester/spreadsheet/spreadsheet_link_set_entry.ts @@ -1,6 +1,7 @@ /* setLink 순수 길(`spreadsheet_link_set.ts`) 시험 진입 — vite --ssr 로 번들해 Node 로 돌림. * 결과 = JSON {pass, fail, failures} 한 줄(`test_spreadsheet_set_link.py` 가 읽음) */ import { applyCommand } from "../../../A00_Common/spreadsheet/spreadsheet_commands"; +import { linkHues } from "../../../A00_Common/spreadsheet/spreadsheet_link_color"; import { linkAreaCell, linkCommand } from "../../../A00_Common/spreadsheet/spreadsheet_link_set"; import type { Command, Workbook } from "../../../A00_Common/spreadsheet/spreadsheet_types"; @@ -100,4 +101,27 @@ eq("틀린 주소", linkAreaCell(b, "철근|ton", "9C"), null); eq("풀기 · 시트 넘어", links(linkAreaCell(b, "치기|㎥", null)!), [links(book())[0], null]); eq("바뀔 것 없음 = 사본", JSON.stringify(linkAreaCell(b, "깔기|㎡", "B6")), before); +// linkHues — 키로 정해짐 · 차례 무관 · 24 개까지 서로 다름 · 12 개까지 30° 바퀴 · 15° 눈금 +const keys = Array.from({ length: 24 }, (_, i) => `알약${i}|㎡`); +const h1 = linkHues(keys); +const h2 = linkHues([...keys].reverse()); +eq("차례 무관", [...h1.entries()].sort(), [...h2.entries()].sort()); +eq("24 개 서로 다름", new Set(h1.values()).size, 24); +eq( + "15° 눈금", + [...h1.values()].every((h) => h % 15 === 0 && h >= 0 && h < 360), + true, +); +const few = linkHues(keys.slice(0, 12)); +eq( + "12 개 = 30° 바퀴", + [...few.values()].every((h) => h % 30 === 0), + true, +); +eq( + "같은 키 같은 색", + linkHues(["철근|ton", "깔기|㎡"]).get("철근|ton"), + linkHues(["철근|ton"]).get("철근|ton"), +); + console.log(JSON.stringify({ pass, fail: failures.length, failures })); diff --git a/resources/tester/test_spreadsheet_set_link.py b/resources/tester/test_spreadsheet_set_link.py index 000430baf..554ca3aa9 100644 --- a/resources/tester/test_spreadsheet_set_link.py +++ b/resources/tester/test_spreadsheet_set_link.py @@ -55,7 +55,7 @@ def test_setLink_순수_길(): assert run.stdout.strip(), run.stderr result = json.loads(run.stdout) assert result["fail"] == 0, result["failures"] - assert result["pass"] == 19 + assert result["pass"] == 24 def test_손잡이_setLink_몸(): @@ -66,3 +66,19 @@ def test_손잡이_setLink_몸(): assert "linkCommand(ctx.book, ctx.sheet().id, key, cell, info)" in body assert "if (command) dispatch(command);" in body # 되돌리기 이력에 쌓임(setDoc 아님) assert "loadLinks();" in body + + +def test_알약_끌어_놓기_색(): + """40-3 ② ③ — 알약 끌어 칸에 놓기(다른 알약 칸이면 물음) · 이은 알약 · 칸 = 키 색 · 두 테마 밝기.""" + src = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.ts").read_text(encoding="utf-8") + assert "b.draggable = !ctx.readOnly;" in src and 'addEventListener("drop", onDropEvent)' in src + assert "showConfirmDialog(" in src and "link(item, 칸);" in src + assert 'if (!b.draggable) b.addEventListener("mousedown"' in src # 끌기 막지 않음 + assert "const hues = linkHues(found().keys());" in src and 'className: "ss-link-cell"' in src + css = (ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_links.css").read_text( + encoding="utf-8" + ) + assert ( + "--ss-link-l: 31%;" in css and css.count("--ss-link-l: 66%;") == 2 + ) # 밝음 · 어두움(설정 · 시스템) + assert "ss-link-flash" in css # 반짝임 그대로