5 Commits
6 changed files with 221 additions and 21 deletions
@@ -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;
}
+48 -7
View File
@@ -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;
}
+90 -13
View File
@@ -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();
@@ -19,3 +19,7 @@
3. 면적산출 상세
- 알약지정은 현재 방법 + 그래그 & 드랍 방식을 추가
- 지정된 알약정보를 색상 자동지정 및 지정된 셀은 색상으로 구분을 할수 있게 반영, 반짝임은 유지
## 추가 기능 (10-4 밤 · 원문 그대로)
추가기능 부여. 면적산출에서 특정 영역을 선정하고 도면영역으로 전달을 할 경우 도면에 테이블을 자동으로 넣고 셀의 내용을 싱크하는 기능을 부여. 만약 동기화가 어려운 경우 업데이트 버튼을 추가해도 무관, 복수개의 테이블이 넘어갈수도 잇으며 위치는 도면에서 사용자가 드래그하여 지정
@@ -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 }));
+17 -1
View File
@@ -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 # 반짝임 그대로