@dashboardity/layout-core 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # layout-core
@@ -0,0 +1,35 @@
1
+ export type GridItem = {
2
+ id: string;
3
+ x: number;
4
+ y: number;
5
+ w: number;
6
+ h: number;
7
+ };
8
+ export type LayoutAction = {
9
+ type: "add";
10
+ item: GridItem;
11
+ } | {
12
+ type: "move";
13
+ id: string;
14
+ x: number;
15
+ y: number;
16
+ } | {
17
+ type: "resize";
18
+ id: string;
19
+ w: number;
20
+ h: number;
21
+ } | {
22
+ type: "remove";
23
+ id: string;
24
+ };
25
+ export type ComputeLayoutInput = {
26
+ items: GridItem[];
27
+ action: LayoutAction;
28
+ columns: number;
29
+ };
30
+ export declare const collides: (a: GridItem, b: GridItem) => boolean;
31
+ export declare const getCollidingItems: (item: GridItem, items: GridItem[]) => GridItem[];
32
+ export declare const pushDown: (item: GridItem, items: GridItem[], columns: number) => GridItem[];
33
+ export declare const packUp: (items: GridItem[], removedId: string, columns: number) => GridItem[];
34
+ export declare const computeLayout: (input: ComputeLayoutInput) => GridItem[];
35
+ //# sourceMappingURL=layout.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.d.ts","sourceRoot":"","sources":["../src/layout.ts"],"names":[],"mappings":"AACA,MAAM,MAAM,QAAQ,GAAG;IACrB,EAAE,EAAE,MAAM,CAAC;IACX,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX,CAAC;AAEF,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAA;CAAE,GAC/B;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GAClD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,GACpD;IAAE,IAAI,EAAE,QAAQ,CAAC;IAAC,EAAE,EAAE,MAAM,CAAA;CAAE,CAAC;AAEnC,MAAM,MAAM,kBAAkB,GAAG;IAC/B,KAAK,EAAE,QAAQ,EAAE,CAAC;IAClB,MAAM,EAAE,YAAY,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;CACjB,CAAC;AAEF,eAAO,MAAM,QAAQ,GAAI,GAAG,QAAQ,EAAE,GAAG,QAAQ,KAAG,OAKnD,CAAC;AAEF,eAAO,MAAM,iBAAiB,GAC5B,MAAM,QAAQ,EACd,OAAO,QAAQ,EAAE,KAChB,QAAQ,EAAoD,CAAC;AAYhE,eAAO,MAAM,QAAQ,GACnB,MAAM,QAAQ,EACd,OAAO,QAAQ,EAAE,EACjB,SAAS,MAAM,KACd,QAAQ,EAkCV,CAAC;AAGF,eAAO,MAAM,MAAM,GACjB,OAAO,QAAQ,EAAE,EACjB,WAAW,MAAM,EACjB,SAAS,MAAM,KACd,QAAQ,EAiBV,CAAC;AAcF,eAAO,MAAM,aAAa,GAAI,OAAO,kBAAkB,KAAG,QAAQ,EAoCjE,CAAC"}
package/dist/layout.js ADDED
@@ -0,0 +1,126 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.computeLayout = exports.packUp = exports.pushDown = exports.getCollidingItems = exports.collides = void 0;
4
+ // collides: 충돌 여부 확인
5
+ const collides = (a, b) => {
6
+ if (a.id === b.id)
7
+ return false;
8
+ const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
9
+ const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
10
+ return xOverlap && yOverlap;
11
+ };
12
+ exports.collides = collides;
13
+ // getCollidingItems: 충돌 여부 확인
14
+ const getCollidingItems = (item, items) => items.filter((other) => (0, exports.collides)(item, other));
15
+ exports.getCollidingItems = getCollidingItems;
16
+ // clampItem: 그리드 아이템 위치 제한
17
+ const clampItem = (item, columns) => {
18
+ const x = Math.max(0, Math.min(item.x, columns - 1));
19
+ const w = Math.max(1, Math.min(item.w, columns - x));
20
+ const y = Math.max(0, item.y);
21
+ const h = Math.max(1, item.h);
22
+ return { ...item, x, y, w, h };
23
+ };
24
+ // pushDown: 그리드 아이템 이동
25
+ const pushDown = (item, items, columns) => {
26
+ const placed = clampItem(item, columns);
27
+ const others = items.filter((i) => i.id !== placed.id);
28
+ const moved = new Map();
29
+ moved.set(placed.id, placed);
30
+ const sortedOthers = [...others].sort((a, b) => {
31
+ if (a.y !== b.y)
32
+ return a.y - b.y;
33
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
34
+ });
35
+ for (const other of sortedOthers) {
36
+ let current = { ...other };
37
+ const allPlaced = () => [
38
+ placed,
39
+ ...Array.from(moved.values()).filter((i) => i.id !== placed.id),
40
+ ];
41
+ for (;;) {
42
+ const othersPlaced = allPlaced().filter((i) => i.id !== current.id);
43
+ const collision = othersPlaced.find((p) => (0, exports.collides)(current, p));
44
+ if (!collision) {
45
+ moved.set(current.id, current);
46
+ break;
47
+ }
48
+ current = { ...current, y: collision.y + collision.h };
49
+ }
50
+ }
51
+ const result = [placed];
52
+ for (const o of sortedOthers) {
53
+ result.push(moved.get(o.id));
54
+ }
55
+ return result;
56
+ };
57
+ exports.pushDown = pushDown;
58
+ // packUp: 그리드 아이템 제거
59
+ const packUp = (items, removedId, columns) => {
60
+ const removed = items.find((i) => i.id === removedId);
61
+ if (!removed)
62
+ return items;
63
+ const rest = items.filter((i) => i.id !== removedId);
64
+ const removedBottom = removed.y + removed.h;
65
+ const removedLeft = removed.x;
66
+ const removedRight = removed.x + removed.w;
67
+ const overlapsColumn = (item) => item.x < removedRight && item.x + item.w > removedLeft;
68
+ const isBelowRemoved = (item) => item.y >= removedBottom;
69
+ return rest.map((item) => {
70
+ if (!overlapsColumn(item) || !isBelowRemoved(item))
71
+ return item;
72
+ return { ...item, y: Math.max(0, item.y - removed.h) };
73
+ });
74
+ };
75
+ exports.packUp = packUp;
76
+ // findMinNonCollidingY: 충돌 없는 최소 y 찾기
77
+ const findMinNonCollidingY = (item, items) => {
78
+ let y = item.y;
79
+ for (;;) {
80
+ const candidate = { ...item, y };
81
+ const hits = (0, exports.getCollidingItems)(candidate, items);
82
+ if (hits.length === 0)
83
+ return y;
84
+ y = Math.max(...hits.map((c) => c.y + c.h));
85
+ }
86
+ };
87
+ // computeLayout: 그리드 레이아웃 계산
88
+ const computeLayout = (input) => {
89
+ const { items, action, columns } = input;
90
+ switch (action.type) {
91
+ case "add": {
92
+ const added = clampItem(action.item, columns);
93
+ const existing = items.filter((i) => i.id !== added.id);
94
+ const y = findMinNonCollidingY(added, existing);
95
+ return [...existing, { ...added, y }];
96
+ }
97
+ case "move": {
98
+ const target = items.find((i) => i.id === action.id);
99
+ if (!target)
100
+ return items;
101
+ // 위치만 변경, 크기(w,h)는 유지. x는 그리드 밖으로 나가지 않도록 clamp
102
+ const x = Math.max(0, Math.min(action.x, columns - target.w));
103
+ const y = Math.max(0, action.y);
104
+ const moved = { ...target, x, y };
105
+ const others = items.filter((i) => i.id !== action.id);
106
+ return (0, exports.pushDown)(moved, others, columns);
107
+ }
108
+ case "resize": {
109
+ const target = items.find((i) => i.id === action.id);
110
+ if (!target)
111
+ return items;
112
+ const w = Math.max(1, Math.min(action.w, columns - target.x));
113
+ const h = Math.max(1, action.h);
114
+ const resized = { ...target, w, h };
115
+ const others = items.filter((i) => i.id !== action.id);
116
+ return (0, exports.pushDown)(resized, others, columns);
117
+ }
118
+ case "remove":
119
+ return (0, exports.packUp)(items, action.id, columns);
120
+ default: {
121
+ const _ = action;
122
+ return items;
123
+ }
124
+ }
125
+ };
126
+ exports.computeLayout = computeLayout;
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=layout.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout.test.d.ts","sourceRoot":"","sources":["../src/layout.test.ts"],"names":[],"mappings":""}
@@ -0,0 +1,156 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ /**
7
+ * layout 엔진 단위 테스트
8
+ */
9
+ const node_assert_1 = __importDefault(require("node:assert"));
10
+ const node_test_1 = require("node:test");
11
+ const layout_js_1 = require("./layout.js");
12
+ const cols = 12;
13
+ (0, node_test_1.describe)("collides", () => {
14
+ (0, node_test_1.it)("x만 겹치고 y 안 겹치면 충돌 아님", () => {
15
+ const a = { id: "a", x: 0, y: 0, w: 6, h: 2 };
16
+ const b = { id: "b", x: 0, y: 2, w: 6, h: 2 };
17
+ node_assert_1.default.strictEqual((0, layout_js_1.collides)(a, b), false);
18
+ });
19
+ (0, node_test_1.it)("x 겹치고 y 겹치면 충돌", () => {
20
+ const a = { id: "a", x: 0, y: 0, w: 6, h: 2 };
21
+ const b = { id: "b", x: 4, y: 1, w: 4, h: 2 };
22
+ node_assert_1.default.strictEqual((0, layout_js_1.collides)(a, b), true);
23
+ });
24
+ (0, node_test_1.it)("x 인접(y만 겹침)이면 충돌 아님", () => {
25
+ const a = { id: "a", x: 0, y: 0, w: 6, h: 2 };
26
+ const b = { id: "b", x: 6, y: 0, w: 6, h: 2 };
27
+ node_assert_1.default.strictEqual((0, layout_js_1.collides)(a, b), false);
28
+ });
29
+ (0, node_test_1.it)("같은 id는 충돌 아님", () => {
30
+ const a = { id: "a", x: 0, y: 0, w: 6, h: 2 };
31
+ node_assert_1.default.strictEqual((0, layout_js_1.collides)(a, { ...a }), false);
32
+ });
33
+ });
34
+ (0, node_test_1.describe)("pushDown", () => {
35
+ (0, node_test_1.it)("충돌 시 한 개 아래로 밀기", () => {
36
+ const items = [{ id: "a", x: 0, y: 0, w: 6, h: 2 }];
37
+ const placed = { id: "b", x: 0, y: 0, w: 6, h: 2 };
38
+ const out = (0, layout_js_1.pushDown)(placed, items, cols);
39
+ node_assert_1.default.strictEqual(out.length, 2);
40
+ const a = out.find((i) => i.id === "a");
41
+ const b = out.find((i) => i.id === "b");
42
+ node_assert_1.default.strictEqual(b.y, 0);
43
+ node_assert_1.default.strictEqual(a.y, 2);
44
+ });
45
+ (0, node_test_1.it)("연쇄 밀기", () => {
46
+ const items = [
47
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
48
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
49
+ ];
50
+ const placed = { id: "c", x: 0, y: 2, w: 6, h: 2 };
51
+ const out = (0, layout_js_1.pushDown)(placed, items, cols);
52
+ const a = out.find((i) => i.id === "a");
53
+ const b = out.find((i) => i.id === "b");
54
+ const c = out.find((i) => i.id === "c");
55
+ node_assert_1.default.strictEqual(a.y, 0);
56
+ node_assert_1.default.strictEqual(c.y, 2);
57
+ node_assert_1.default.strictEqual(b.y, 4);
58
+ });
59
+ });
60
+ (0, node_test_1.describe)("packUp", () => {
61
+ (0, node_test_1.it)("제거 후 같은 열 아래 아이템만 위로 당김", () => {
62
+ const items = [
63
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
64
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
65
+ { id: "c", x: 0, y: 4, w: 6, h: 2 },
66
+ ];
67
+ const out = (0, layout_js_1.packUp)(items, "b", cols);
68
+ node_assert_1.default.strictEqual(out.length, 2);
69
+ const a = out.find((i) => i.id === "a");
70
+ const c = out.find((i) => i.id === "c");
71
+ node_assert_1.default.strictEqual(a.y, 0);
72
+ node_assert_1.default.strictEqual(c.y, 2); // 4 - 2 = 2
73
+ });
74
+ (0, node_test_1.it)("열이 안 겹치면 이동 없음", () => {
75
+ const items = [
76
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
77
+ { id: "b", x: 6, y: 2, w: 6, h: 2 },
78
+ ];
79
+ const out = (0, layout_js_1.packUp)(items, "a", cols);
80
+ const b = out.find((i) => i.id === "b");
81
+ node_assert_1.default.strictEqual(b.y, 2);
82
+ });
83
+ });
84
+ (0, node_test_1.describe)("computeLayout", () => {
85
+ (0, node_test_1.it)("add: 기존 아이템 안 움직이고 새 아이템만 최소 y에 배치", () => {
86
+ const items = [{ id: "a", x: 0, y: 0, w: 6, h: 2 }];
87
+ const out = (0, layout_js_1.computeLayout)({
88
+ items,
89
+ action: { type: "add", item: { id: "b", x: 0, y: 0, w: 6, h: 2 } },
90
+ columns: cols,
91
+ });
92
+ node_assert_1.default.strictEqual(out.length, 2);
93
+ const a = out.find((i) => i.id === "a");
94
+ const b = out.find((i) => i.id === "b");
95
+ node_assert_1.default.strictEqual(a.y, 0);
96
+ node_assert_1.default.strictEqual(b.y, 2);
97
+ });
98
+ (0, node_test_1.it)("move: 대상만 이동, 충돌 시 아래로 밀기", () => {
99
+ const items = [
100
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
101
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
102
+ ];
103
+ const out = (0, layout_js_1.computeLayout)({
104
+ items,
105
+ action: { type: "move", id: "b", x: 0, y: 0 },
106
+ columns: cols,
107
+ });
108
+ const a = out.find((i) => i.id === "a");
109
+ const b = out.find((i) => i.id === "b");
110
+ node_assert_1.default.strictEqual(b.y, 0);
111
+ node_assert_1.default.strictEqual(a.y, 2);
112
+ });
113
+ (0, node_test_1.it)("resize: w/h 변경, 충돌 시 아래로 밀기", () => {
114
+ const items = [
115
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
116
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
117
+ ];
118
+ const out = (0, layout_js_1.computeLayout)({
119
+ items,
120
+ action: { type: "resize", id: "b", w: 6, h: 4 },
121
+ columns: cols,
122
+ });
123
+ const b = out.find((i) => i.id === "b");
124
+ node_assert_1.default.strictEqual(b.h, 4);
125
+ });
126
+ (0, node_test_1.it)("remove: 제거 후 pack-up", () => {
127
+ const items = [
128
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
129
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
130
+ ];
131
+ const out = (0, layout_js_1.computeLayout)({
132
+ items,
133
+ action: { type: "remove", id: "b" },
134
+ columns: cols,
135
+ });
136
+ node_assert_1.default.strictEqual(out.length, 1);
137
+ node_assert_1.default.strictEqual(out[0].id, "a");
138
+ });
139
+ (0, node_test_1.it)("동일 입력 → 동일 출력 (deterministic)", () => {
140
+ const items = [
141
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
142
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
143
+ ];
144
+ const out1 = (0, layout_js_1.computeLayout)({
145
+ items,
146
+ action: { type: "move", id: "a", x: 0, y: 2 },
147
+ columns: cols,
148
+ });
149
+ const out2 = (0, layout_js_1.computeLayout)({
150
+ items,
151
+ action: { type: "move", id: "a", x: 0, y: 2 },
152
+ columns: cols,
153
+ });
154
+ node_assert_1.default.deepStrictEqual(out1, out2);
155
+ });
156
+ });
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@dashboardity/layout-core",
3
+ "version": "1.0.0",
4
+ "author": "Kim. MinHyuck <kimminhyug29@gmail.com>",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "keywords": [
10
+ "grid",
11
+ "layout",
12
+ "engine",
13
+ "pure",
14
+ "coordinate",
15
+ "computation"
16
+ ],
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "https://github.com/kimminhyug/layout-core"
20
+ },
21
+ "description": "Grafana-style grid layout engine (pure coordinate computation)",
22
+ "main": "dist/layout.js",
23
+ "types": "dist/layout.d.ts",
24
+ "scripts": {
25
+ "build": "tsc",
26
+ "test": "node --test dist/layout.test.js"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^20.10.0",
30
+ "typescript": "^5.3.0"
31
+ }
32
+ }
@@ -0,0 +1,171 @@
1
+ /**
2
+ * layout 엔진 단위 테스트
3
+ */
4
+ import assert from "node:assert";
5
+ import { describe, it } from "node:test";
6
+ import {
7
+ collides,
8
+ computeLayout,
9
+ packUp,
10
+ pushDown,
11
+ type GridItem,
12
+ } from "./layout.js";
13
+
14
+ const cols = 12;
15
+
16
+ describe("collides", () => {
17
+ it("x만 겹치고 y 안 겹치면 충돌 아님", () => {
18
+ const a: GridItem = { id: "a", x: 0, y: 0, w: 6, h: 2 };
19
+ const b: GridItem = { id: "b", x: 0, y: 2, w: 6, h: 2 };
20
+ assert.strictEqual(collides(a, b), false);
21
+ });
22
+
23
+ it("x 겹치고 y 겹치면 충돌", () => {
24
+ const a: GridItem = { id: "a", x: 0, y: 0, w: 6, h: 2 };
25
+ const b: GridItem = { id: "b", x: 4, y: 1, w: 4, h: 2 };
26
+ assert.strictEqual(collides(a, b), true);
27
+ });
28
+
29
+ it("x 인접(y만 겹침)이면 충돌 아님", () => {
30
+ const a: GridItem = { id: "a", x: 0, y: 0, w: 6, h: 2 };
31
+ const b: GridItem = { id: "b", x: 6, y: 0, w: 6, h: 2 };
32
+ assert.strictEqual(collides(a, b), false);
33
+ });
34
+
35
+ it("같은 id는 충돌 아님", () => {
36
+ const a: GridItem = { id: "a", x: 0, y: 0, w: 6, h: 2 };
37
+ assert.strictEqual(collides(a, { ...a }), false);
38
+ });
39
+ });
40
+
41
+ describe("pushDown", () => {
42
+ it("충돌 시 한 개 아래로 밀기", () => {
43
+ const items: GridItem[] = [{ id: "a", x: 0, y: 0, w: 6, h: 2 }];
44
+ const placed: GridItem = { id: "b", x: 0, y: 0, w: 6, h: 2 };
45
+ const out = pushDown(placed, items, cols);
46
+ assert.strictEqual(out.length, 2);
47
+ const a = out.find((i) => i.id === "a")!;
48
+ const b = out.find((i) => i.id === "b")!;
49
+ assert.strictEqual(b.y, 0);
50
+ assert.strictEqual(a.y, 2);
51
+ });
52
+
53
+ it("연쇄 밀기", () => {
54
+ const items: GridItem[] = [
55
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
56
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
57
+ ];
58
+ const placed: GridItem = { id: "c", x: 0, y: 2, w: 6, h: 2 };
59
+ const out = pushDown(placed, items, cols);
60
+ const a = out.find((i) => i.id === "a")!;
61
+ const b = out.find((i) => i.id === "b")!;
62
+ const c = out.find((i) => i.id === "c")!;
63
+ assert.strictEqual(a.y, 0);
64
+ assert.strictEqual(c.y, 2);
65
+ assert.strictEqual(b.y, 4);
66
+ });
67
+ });
68
+
69
+ describe("packUp", () => {
70
+ it("제거 후 같은 열 아래 아이템만 위로 당김", () => {
71
+ const items: GridItem[] = [
72
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
73
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
74
+ { id: "c", x: 0, y: 4, w: 6, h: 2 },
75
+ ];
76
+ const out = packUp(items, "b", cols);
77
+ assert.strictEqual(out.length, 2);
78
+ const a = out.find((i) => i.id === "a")!;
79
+ const c = out.find((i) => i.id === "c")!;
80
+ assert.strictEqual(a.y, 0);
81
+ assert.strictEqual(c.y, 2); // 4 - 2 = 2
82
+ });
83
+
84
+ it("열이 안 겹치면 이동 없음", () => {
85
+ const items: GridItem[] = [
86
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
87
+ { id: "b", x: 6, y: 2, w: 6, h: 2 },
88
+ ];
89
+ const out = packUp(items, "a", cols);
90
+ const b = out.find((i) => i.id === "b")!;
91
+ assert.strictEqual(b.y, 2);
92
+ });
93
+ });
94
+
95
+ describe("computeLayout", () => {
96
+ it("add: 기존 아이템 안 움직이고 새 아이템만 최소 y에 배치", () => {
97
+ const items: GridItem[] = [{ id: "a", x: 0, y: 0, w: 6, h: 2 }];
98
+ const out = computeLayout({
99
+ items,
100
+ action: { type: "add", item: { id: "b", x: 0, y: 0, w: 6, h: 2 } },
101
+ columns: cols,
102
+ });
103
+ assert.strictEqual(out.length, 2);
104
+ const a = out.find((i) => i.id === "a")!;
105
+ const b = out.find((i) => i.id === "b")!;
106
+ assert.strictEqual(a.y, 0);
107
+ assert.strictEqual(b.y, 2);
108
+ });
109
+
110
+ it("move: 대상만 이동, 충돌 시 아래로 밀기", () => {
111
+ const items: GridItem[] = [
112
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
113
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
114
+ ];
115
+ const out = computeLayout({
116
+ items,
117
+ action: { type: "move", id: "b", x: 0, y: 0 },
118
+ columns: cols,
119
+ });
120
+ const a = out.find((i) => i.id === "a")!;
121
+ const b = out.find((i) => i.id === "b")!;
122
+ assert.strictEqual(b.y, 0);
123
+ assert.strictEqual(a.y, 2);
124
+ });
125
+
126
+ it("resize: w/h 변경, 충돌 시 아래로 밀기", () => {
127
+ const items: GridItem[] = [
128
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
129
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
130
+ ];
131
+ const out = computeLayout({
132
+ items,
133
+ action: { type: "resize", id: "b", w: 6, h: 4 },
134
+ columns: cols,
135
+ });
136
+ const b = out.find((i) => i.id === "b")!;
137
+ assert.strictEqual(b.h, 4);
138
+ });
139
+
140
+ it("remove: 제거 후 pack-up", () => {
141
+ const items: GridItem[] = [
142
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
143
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
144
+ ];
145
+ const out = computeLayout({
146
+ items,
147
+ action: { type: "remove", id: "b" },
148
+ columns: cols,
149
+ });
150
+ assert.strictEqual(out.length, 1);
151
+ assert.strictEqual(out[0].id, "a");
152
+ });
153
+
154
+ it("동일 입력 → 동일 출력 (deterministic)", () => {
155
+ const items: GridItem[] = [
156
+ { id: "a", x: 0, y: 0, w: 6, h: 2 },
157
+ { id: "b", x: 0, y: 2, w: 6, h: 2 },
158
+ ];
159
+ const out1 = computeLayout({
160
+ items,
161
+ action: { type: "move", id: "a", x: 0, y: 2 },
162
+ columns: cols,
163
+ });
164
+ const out2 = computeLayout({
165
+ items,
166
+ action: { type: "move", id: "a", x: 0, y: 2 },
167
+ columns: cols,
168
+ });
169
+ assert.deepStrictEqual(out1, out2);
170
+ });
171
+ });
package/src/layout.ts ADDED
@@ -0,0 +1,156 @@
1
+ // GridItem: 그리드 아이템 정보
2
+ export type GridItem = {
3
+ id: string;
4
+ x: number;
5
+ y: number;
6
+ w: number;
7
+ h: number;
8
+ };
9
+ // LayoutAction: 그리드 아이템 추가, 이동, 크기 변경, 제거 등의 작업
10
+ export type LayoutAction =
11
+ | { type: "add"; item: GridItem }
12
+ | { type: "move"; id: string; x: number; y: number }
13
+ | { type: "resize"; id: string; w: number; h: number }
14
+ | { type: "remove"; id: string };
15
+
16
+ export type ComputeLayoutInput = {
17
+ items: GridItem[];
18
+ action: LayoutAction;
19
+ columns: number;
20
+ };
21
+ // collides: 충돌 여부 확인
22
+ export const collides = (a: GridItem, b: GridItem): boolean => {
23
+ if (a.id === b.id) return false;
24
+ const xOverlap = a.x < b.x + b.w && b.x < a.x + a.w;
25
+ const yOverlap = a.y < b.y + b.h && b.y < a.y + a.h;
26
+ return xOverlap && yOverlap;
27
+ };
28
+ // getCollidingItems: 충돌 여부 확인
29
+ export const getCollidingItems = (
30
+ item: GridItem,
31
+ items: GridItem[]
32
+ ): GridItem[] => items.filter((other) => collides(item, other));
33
+
34
+ // clampItem: 그리드 아이템 위치 제한
35
+ const clampItem = (item: GridItem, columns: number): GridItem => {
36
+ const x = Math.max(0, Math.min(item.x, columns - 1));
37
+ const w = Math.max(1, Math.min(item.w, columns - x));
38
+ const y = Math.max(0, item.y);
39
+ const h = Math.max(1, item.h);
40
+ return { ...item, x, y, w, h };
41
+ };
42
+
43
+ // pushDown: 그리드 아이템 이동
44
+ export const pushDown = (
45
+ item: GridItem,
46
+ items: GridItem[],
47
+ columns: number
48
+ ): GridItem[] => {
49
+ const placed = clampItem(item, columns);
50
+ const others = items.filter((i) => i.id !== placed.id);
51
+ const moved = new Map<string, GridItem>();
52
+ moved.set(placed.id, placed);
53
+
54
+ const sortedOthers = [...others].sort((a, b) => {
55
+ if (a.y !== b.y) return a.y - b.y;
56
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
57
+ });
58
+
59
+ for (const other of sortedOthers) {
60
+ let current = { ...other };
61
+ const allPlaced = (): GridItem[] => [
62
+ placed,
63
+ ...Array.from(moved.values()).filter((i) => i.id !== placed.id),
64
+ ];
65
+
66
+ for (;;) {
67
+ const othersPlaced = allPlaced().filter((i) => i.id !== current.id);
68
+ const collision = othersPlaced.find((p) => collides(current, p));
69
+ if (!collision) {
70
+ moved.set(current.id, current);
71
+ break;
72
+ }
73
+ current = { ...current, y: collision.y + collision.h };
74
+ }
75
+ }
76
+
77
+ const result: GridItem[] = [placed];
78
+ for (const o of sortedOthers) {
79
+ result.push(moved.get(o.id)!);
80
+ }
81
+ return result;
82
+ };
83
+
84
+ // packUp: 그리드 아이템 제거
85
+ export const packUp = (
86
+ items: GridItem[],
87
+ removedId: string,
88
+ columns: number
89
+ ): GridItem[] => {
90
+ const removed = items.find((i) => i.id === removedId);
91
+ if (!removed) return items;
92
+
93
+ const rest = items.filter((i) => i.id !== removedId);
94
+ const removedBottom = removed.y + removed.h;
95
+ const removedLeft = removed.x;
96
+ const removedRight = removed.x + removed.w;
97
+
98
+ const overlapsColumn = (item: GridItem): boolean =>
99
+ item.x < removedRight && item.x + item.w > removedLeft;
100
+ const isBelowRemoved = (item: GridItem): boolean => item.y >= removedBottom;
101
+
102
+ return rest.map((item) => {
103
+ if (!overlapsColumn(item) || !isBelowRemoved(item)) return item;
104
+ return { ...item, y: Math.max(0, item.y - removed.h) };
105
+ });
106
+ };
107
+
108
+ // findMinNonCollidingY: 충돌 없는 최소 y 찾기
109
+ const findMinNonCollidingY = (item: GridItem, items: GridItem[]): number => {
110
+ let y = item.y;
111
+ for (;;) {
112
+ const candidate = { ...item, y };
113
+ const hits = getCollidingItems(candidate, items);
114
+ if (hits.length === 0) return y;
115
+ y = Math.max(...hits.map((c) => c.y + c.h));
116
+ }
117
+ };
118
+
119
+ // computeLayout: 그리드 레이아웃 계산
120
+ export const computeLayout = (input: ComputeLayoutInput): GridItem[] => {
121
+ const { items, action, columns } = input;
122
+
123
+ switch (action.type) {
124
+ case "add": {
125
+ const added = clampItem(action.item, columns);
126
+ const existing = items.filter((i) => i.id !== added.id);
127
+ const y = findMinNonCollidingY(added, existing);
128
+ return [...existing, { ...added, y }];
129
+ }
130
+ case "move": {
131
+ const target = items.find((i) => i.id === action.id);
132
+ if (!target) return items;
133
+ // 위치만 변경, 크기(w,h)는 유지. x는 그리드 밖으로 나가지 않도록 clamp
134
+ const x = Math.max(0, Math.min(action.x, columns - target.w));
135
+ const y = Math.max(0, action.y);
136
+ const moved = { ...target, x, y };
137
+ const others = items.filter((i) => i.id !== action.id);
138
+ return pushDown(moved, others, columns);
139
+ }
140
+ case "resize": {
141
+ const target = items.find((i) => i.id === action.id);
142
+ if (!target) return items;
143
+ const w = Math.max(1, Math.min(action.w, columns - target.x));
144
+ const h = Math.max(1, action.h);
145
+ const resized = { ...target, w, h };
146
+ const others = items.filter((i) => i.id !== action.id);
147
+ return pushDown(resized, others, columns);
148
+ }
149
+ case "remove":
150
+ return packUp(items, action.id, columns);
151
+ default: {
152
+ const _: never = action;
153
+ return items;
154
+ }
155
+ }
156
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "commonjs",
5
+ "lib": ["ES2020"],
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true,
11
+ "declarationMap": true,
12
+ "skipLibCheck": true
13
+ },
14
+ "include": ["src/**/*.ts"],
15
+ "exclude": ["node_modules", "dist"]
16
+ }