feat(spreadsheet): 알약 끌어 칸에 놓기 · 이은 알약 색 자동 — 키로 정해지는 색(24 색 · 두 테마 밝기) · 이은 칸 배경 같은 색 옅게 · 다른 알약 칸에 놓으면 바꿀지 물음 · 반짝임 그대로 (PLAN 40-3 ② ③)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W4uKaV4ZBhsJN4GHG7yRj4
This commit is contained in:
@@ -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<string>): Map<string, number> {
|
||||
const out = new Map<string, number>();
|
||||
const used = new Set<number>();
|
||||
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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<void> => {
|
||||
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();
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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 # 반짝임 그대로
|
||||
|
||||
Reference in New Issue
Block a user