@operato/scene-storage 10.0.0-beta.41 → 10.0.0-beta.43
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/CHANGELOG.md +20 -0
- package/MIGRATION-plan-a-slot-api.md +266 -0
- package/PLAN-A-rack-as-slot-holder.md +164 -0
- package/dist/crane.js +1 -1
- package/dist/crane.js.map +1 -1
- package/dist/index.d.ts +3 -4
- package/dist/index.js +1 -2
- package/dist/index.js.map +1 -1
- package/dist/parcel-3d.js +42 -9
- package/dist/parcel-3d.js.map +1 -1
- package/dist/rack-grid-3d.d.ts +18 -7
- package/dist/rack-grid-3d.js +372 -69
- package/dist/rack-grid-3d.js.map +1 -1
- package/dist/rack-grid-cell.d.ts +21 -72
- package/dist/rack-grid-cell.js +147 -243
- package/dist/rack-grid-cell.js.map +1 -1
- package/dist/rack-grid.d.ts +277 -56
- package/dist/rack-grid.js +1230 -695
- package/dist/rack-grid.js.map +1 -1
- package/dist/rack-materials.d.ts +9 -0
- package/dist/rack-materials.js +55 -0
- package/dist/rack-materials.js.map +1 -0
- package/dist/storage-rack-3d.d.ts +15 -0
- package/dist/storage-rack-3d.js +131 -30
- package/dist/storage-rack-3d.js.map +1 -1
- package/dist/storage-rack.d.ts +242 -45
- package/dist/storage-rack.js +684 -106
- package/dist/storage-rack.js.map +1 -1
- package/package.json +3 -3
- package/src/crane.ts +1 -1
- package/src/index.ts +3 -4
- package/src/parcel-3d.ts +41 -9
- package/src/rack-grid-3d.ts +383 -80
- package/src/rack-grid-cell.ts +161 -305
- package/src/rack-grid.ts +1263 -762
- package/src/rack-materials.ts +61 -0
- package/src/storage-rack-3d.ts +144 -30
- package/src/storage-rack.ts +763 -111
- package/test/test-carrier-lifecycle.ts +361 -0
- package/test/test-coord-alignment.ts +201 -0
- package/test/test-external-to-rack.ts +461 -0
- package/test/test-mover-concurrent-bug.ts +304 -0
- package/test/test-mover-rollback.ts +290 -0
- package/test/test-r19-place-absorb.ts +174 -0
- package/test/test-rack-3d-attach-real.ts +301 -0
- package/test/test-rack-concurrent.ts +254 -0
- package/test/test-rack-edge-cases.ts +323 -0
- package/test/test-rack-grid-cell.ts +318 -0
- package/test/test-rack-grid-location.ts +657 -0
- package/test/test-real-3d-positioning.ts +158 -0
- package/test/test-slot-center-convention.ts +116 -0
- package/test/test-slot-target.ts +189 -0
- package/test/test-storage-rack-batched.ts +606 -0
- package/test/test-storage-rack-click.ts +329 -0
- package/test/test-storage-rack-slot-api.ts +357 -0
- package/test/test-toscene-convention.ts +162 -0
- package/test/test-user-scenario-sequential.ts +334 -0
- package/translations/en.json +2 -0
- package/translations/ja.json +2 -0
- package/translations/ko.json +2 -0
- package/translations/ms.json +2 -0
- package/translations/zh.json +2 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/rack-column.d.ts +0 -35
- package/dist/rack-column.js +0 -258
- package/dist/rack-column.js.map +0 -1
- package/dist/rack-grid-helpers.d.ts +0 -28
- package/dist/rack-grid-helpers.js +0 -71
- package/dist/rack-grid-helpers.js.map +0 -1
- package/dist/rack-grid-location.d.ts +0 -37
- package/dist/rack-grid-location.js +0 -227
- package/dist/rack-grid-location.js.map +0 -1
- package/dist/storage-cell-3d.d.ts +0 -25
- package/dist/storage-cell-3d.js +0 -88
- package/dist/storage-cell-3d.js.map +0 -1
- package/dist/storage-cell.d.ts +0 -73
- package/dist/storage-cell.js +0 -215
- package/dist/storage-cell.js.map +0 -1
- package/src/rack-column.ts +0 -340
- package/src/rack-grid-helpers.ts +0 -77
- package/src/rack-grid-location.ts +0 -286
- package/src/storage-cell-3d.ts +0 -101
- package/src/storage-cell.ts +0 -267
- package/test/test-cell-position.ts +0 -105
- package/test/test-rack-grid.ts +0 -77
package/src/rack-grid.ts
CHANGED
|
@@ -1,264 +1,105 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Copyright © HatioLab Inc. All rights reserved.
|
|
3
|
+
*
|
|
4
|
+
* RackGrid — *grid layout 의 컨테이너*.
|
|
5
|
+
*
|
|
6
|
+
* 각 (col, row) 위치마다 *하나의 자식 컴포넌트* 보유 가능:
|
|
7
|
+
* - StorageRack (한 column 의 vertical stack — stock 보관)
|
|
8
|
+
* - Crane / AGV / forklift / 미니로드 등 *operation archetype* (이동/작업 설비)
|
|
9
|
+
* - 또는 비어 있음 (= 통로 / 작업자 공간 / 무엇이든)
|
|
10
|
+
*
|
|
11
|
+
* 외부에는 *location string* (예: "Z-A01-03-1") 으로 API 노출. 내부 cellId 와 양방향
|
|
12
|
+
* 변환은 RackGrid 가 책임.
|
|
13
|
+
*
|
|
14
|
+
* **Performance** — 각 자식 StorageRack 이 자체 InstancedMesh 로 stock 렌더링.
|
|
15
|
+
* storage-rack 의 batched 시각화 성능 그대로 계승. RackGrid 는 *얇은 wrapper*.
|
|
16
|
+
*
|
|
17
|
+
* **Transfer 컨트랙** — SlottedHolder 인터페이스 구현. 내부적으로 *자식 StorageRack*
|
|
18
|
+
* 으로 위임. Mover / Crane 등이 *RackGrid 의 slot target* 으로 인터랙트 가능.
|
|
19
|
+
*
|
|
20
|
+
* **Configuration 생산성** — rack-table 의 *location 룰* (locPattern, sectionDigits 등)
|
|
21
|
+
* 차용. cell-component 없이 *sparse cellOverrides* 만으로 동일 UX 제공.
|
|
22
|
+
*
|
|
23
|
+
* **Aisle/Empty** — `cellOverrides[posKey].isEmpty = true` 로 명시. 의미는
|
|
24
|
+
* *location 미부여 + increaseLocation 의 aisle row 인식* 만. 그 위치의 자식 컴포넌트
|
|
25
|
+
* 종류는 *자유* (Crane/AGV/forklift/그냥 비움 모두 가능).
|
|
26
|
+
*
|
|
27
|
+
* **CellId 컨벤션** (내부):
|
|
28
|
+
* - posKey = `${col-1}-${row-1}` (0-based, 2 segments) — *grid 평면 위치*
|
|
29
|
+
* - cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments) — *grid + shelf*
|
|
30
|
+
* - 자식 StorageRack 내부에서는 `0-0-${shelf-1}` (자체 cellId 형식). RackGrid 가 변환.
|
|
3
31
|
*/
|
|
4
|
-
|
|
5
32
|
import {
|
|
6
|
-
ApplicationContext,
|
|
7
|
-
|
|
8
|
-
ComponentNature,
|
|
9
|
-
ContainerAbstract,
|
|
10
|
-
Control,
|
|
11
|
-
Layout,
|
|
12
|
-
Model,
|
|
13
|
-
POINT,
|
|
14
|
-
Properties,
|
|
15
|
-
sceneComponent,
|
|
16
|
-
RealObject
|
|
33
|
+
ApplicationContext, Component, ComponentNature, ContainerAbstract, Control, Layout,
|
|
34
|
+
Model, POINT, Properties, RealObject, sceneComponent
|
|
17
35
|
} from '@hatiolab/things-scene'
|
|
18
36
|
import type { State } from '@hatiolab/things-scene'
|
|
19
37
|
import * as THREE from 'three'
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
hideRackFrame?: boolean
|
|
33
|
-
legendTarget?: string
|
|
34
|
-
hideEmptyStock?: boolean
|
|
35
|
-
widths?: number[]
|
|
36
|
-
heights?: number[]
|
|
37
|
-
}
|
|
38
|
-
import { RackGridCell } from './rack-grid-cell.js'
|
|
38
|
+
import {
|
|
39
|
+
Placeable,
|
|
40
|
+
SlotTarget,
|
|
41
|
+
type Alignment,
|
|
42
|
+
type Heights,
|
|
43
|
+
type PlacementArchetype,
|
|
44
|
+
type SlotRecord,
|
|
45
|
+
type SlottedHolder
|
|
46
|
+
} from '@operato/scene-base'
|
|
47
|
+
|
|
48
|
+
import StorageRack from './storage-rack.js'
|
|
49
|
+
import RackGridCell from './rack-grid-cell.js'
|
|
39
50
|
import { RackGrid3D } from './rack-grid-3d.js'
|
|
40
|
-
import { increaseLocation } from './rack-grid-location.js'
|
|
41
|
-
import type { StockMaterialProvider } from './stock.js'
|
|
42
|
-
|
|
43
|
-
const NATURE: ComponentNature = {
|
|
44
|
-
mutable: false,
|
|
45
|
-
resizable: true,
|
|
46
|
-
rotatable: true,
|
|
47
|
-
properties: [
|
|
48
|
-
{
|
|
49
|
-
type: 'number',
|
|
50
|
-
label: 'rows',
|
|
51
|
-
name: 'rows',
|
|
52
|
-
property: 'rows'
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
type: 'number',
|
|
56
|
-
label: 'columns',
|
|
57
|
-
name: 'columns',
|
|
58
|
-
property: 'columns'
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
type: 'string',
|
|
62
|
-
label: 'zone',
|
|
63
|
-
name: 'zone',
|
|
64
|
-
property: 'zone'
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
type: 'number',
|
|
68
|
-
label: 'shelves',
|
|
69
|
-
name: 'shelves',
|
|
70
|
-
property: 'shelves'
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
type: 'number',
|
|
74
|
-
label: 'depth',
|
|
75
|
-
name: 'depth',
|
|
76
|
-
property: 'depth'
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
type: 'string',
|
|
80
|
-
label: 'location-pattern',
|
|
81
|
-
name: 'locPattern',
|
|
82
|
-
placeholder: '{z}{s}-{u}-{sh}'
|
|
83
|
-
},
|
|
84
|
-
{
|
|
85
|
-
type: 'number',
|
|
86
|
-
label: 'section-digits',
|
|
87
|
-
name: 'sectionDigits',
|
|
88
|
-
placeholder: '1, 2, 3, ...'
|
|
89
|
-
},
|
|
90
|
-
{
|
|
91
|
-
type: 'number',
|
|
92
|
-
label: 'unit-digits',
|
|
93
|
-
name: 'unitDigits',
|
|
94
|
-
placeholder: '1, 2, 3, ...'
|
|
95
|
-
},
|
|
96
|
-
{
|
|
97
|
-
type: 'string',
|
|
98
|
-
label: 'shelf-locations',
|
|
99
|
-
name: 'shelfLocations',
|
|
100
|
-
placeholder: '1,2,3,... / ,,,04'
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
type: 'number',
|
|
104
|
-
label: 'stock-scale',
|
|
105
|
-
name: 'stockScale',
|
|
106
|
-
property: {
|
|
107
|
-
step: 0.01,
|
|
108
|
-
min: 0,
|
|
109
|
-
max: 1
|
|
110
|
-
}
|
|
111
|
-
},
|
|
112
|
-
{
|
|
113
|
-
type: 'checkbox',
|
|
114
|
-
label: 'hide-rack-frame',
|
|
115
|
-
name: 'hideRackFrame'
|
|
116
|
-
},
|
|
117
|
-
{
|
|
118
|
-
type: 'id-input',
|
|
119
|
-
label: 'legend-target',
|
|
120
|
-
name: 'legendTarget',
|
|
121
|
-
property: {
|
|
122
|
-
component: 'legend'
|
|
123
|
-
}
|
|
124
|
-
},
|
|
125
|
-
{
|
|
126
|
-
type: 'checkbox',
|
|
127
|
-
label: 'hide-empty-stock',
|
|
128
|
-
name: 'hideEmptyStock'
|
|
129
|
-
}
|
|
130
|
-
],
|
|
131
|
-
help: 'scene/component/rack-grid'
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
type SIDE_KEY = 'all' | 'out' | 'left' | 'right' | 'top' | 'bottom' | 'leftright' | 'topbottom'
|
|
135
|
-
|
|
136
|
-
const SIDES = {
|
|
137
|
-
all: ['top', 'left', 'bottom', 'right'],
|
|
138
|
-
out: ['top', 'left', 'bottom', 'right'],
|
|
139
|
-
left: ['left'],
|
|
140
|
-
right: ['right'],
|
|
141
|
-
top: ['top'],
|
|
142
|
-
bottom: ['bottom'],
|
|
143
|
-
leftright: ['left', 'right'],
|
|
144
|
-
topbottom: ['top', 'bottom']
|
|
145
|
-
} as { [key: string]: SIDE_KEY[] }
|
|
146
|
-
|
|
147
|
-
const CLEAR_STYLE = {
|
|
148
|
-
strokeStyle: '',
|
|
149
|
-
lineDash: 'solid',
|
|
150
|
-
lineWidth: 0
|
|
151
|
-
}
|
|
152
51
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
52
|
+
// RackGridCell 의 refid 충돌 회피 — load 시 동적 생성되는 cell 의 refid 가 *부모
|
|
53
|
+
// RackGrid + 모델 안 다른 컴포넌트* refid 와 겹치지 않도록 *높은 시작값 + monotonic
|
|
54
|
+
// counter*. hierarchy override 가 save 시 refid 제거 → 모델 파일엔 안 새겨짐.
|
|
55
|
+
let _cellRefidCounter = 100_000_000
|
|
56
|
+
function _nextCellRefid(): number {
|
|
57
|
+
return _cellRefidCounter++
|
|
157
58
|
}
|
|
158
59
|
|
|
159
|
-
const TABLE_LAYOUT = Layout.get('table')
|
|
160
|
-
|
|
161
60
|
function buildNewCell(app: ApplicationContext) {
|
|
162
|
-
|
|
61
|
+
const refid = _nextCellRefid()
|
|
62
|
+
const m: any = Model.compile(
|
|
163
63
|
{
|
|
164
64
|
type: 'rack-grid-cell',
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
left: 0,
|
|
168
|
-
top: 0,
|
|
169
|
-
width: 1,
|
|
170
|
-
height: 1,
|
|
171
|
-
textWrap: true,
|
|
172
|
-
isEmpty: false,
|
|
173
|
-
border: buildBorderStyle(DEFAULT_STYLE, 'all')
|
|
65
|
+
refid,
|
|
66
|
+
left: 0, top: 0, width: 1, height: 1, isEmpty: false
|
|
174
67
|
},
|
|
175
68
|
app
|
|
176
69
|
)
|
|
70
|
+
// Model.compile 이 refid 를 자동 generator 로 덮어쓸 수 있어 *강제 재 set*.
|
|
71
|
+
try {
|
|
72
|
+
if (m.refid !== refid) {
|
|
73
|
+
m.refid = refid
|
|
74
|
+
m.state && (m.state.refid = refid)
|
|
75
|
+
m._state && (m._state.refid = refid)
|
|
76
|
+
}
|
|
77
|
+
} catch {}
|
|
78
|
+
return m
|
|
177
79
|
}
|
|
178
80
|
|
|
179
|
-
|
|
180
|
-
const obj = JSON.parse(JSON.stringify(copy))
|
|
181
|
-
delete obj.text
|
|
182
|
-
|
|
183
|
-
return Model.compile(obj, app)
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function buildBorderStyle(style: any, where: SIDE_KEY): { [key: string]: any } {
|
|
187
|
-
return (SIDES[where] || []).reduce((border: { [key: string]: any }, side: string) => {
|
|
188
|
-
border[side] = style
|
|
189
|
-
return border
|
|
190
|
-
}, {} as { [key: string]: any })
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function setCellBorder(cell: RackGridCell, style: any, where: SIDE_KEY) {
|
|
194
|
-
if (!cell) {
|
|
195
|
-
return
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
cell.set('border', Object.assign({}, cell.getState('border') || {}, buildBorderStyle(style, where)))
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function isLeftMost(total: number, columns: number, indices: number[], i: number) {
|
|
202
|
-
return i == 0 || !(i % columns) || indices.indexOf(i - 1) == -1
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isRightMost(total: number, columns: number, indices: number[], i: number) {
|
|
206
|
-
return i == total - 1 || i % columns == columns - 1 || indices.indexOf(i + 1) == -1
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
function isTopMost(total: number, columns: number, indices: number[], i: number) {
|
|
210
|
-
return i < columns || indices.indexOf(i - columns) == -1
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function isBottomMost(total: number, columns: number, indices: number[], i: number) {
|
|
214
|
-
return i > total - columns - 1 || indices.indexOf(i + columns) == -1
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function above(columns: number, i: number) {
|
|
218
|
-
return i - columns
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function below(columns: number, i: number) {
|
|
222
|
-
return i + columns
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function before(columns: number, i: number) {
|
|
226
|
-
return !(i % columns) ? -1 : i - 1
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function after(columns: number, i: number) {
|
|
230
|
-
return !((i + 1) % columns) ? -1 : i + 1
|
|
231
|
-
}
|
|
81
|
+
const TABLE_LAYOUT = Layout.get('table')
|
|
232
82
|
|
|
233
|
-
function
|
|
234
|
-
const arr = []
|
|
235
|
-
for (let i = 0; i < size; i++) arr.push(
|
|
83
|
+
function arrayOf(value: number, size: number): number[] {
|
|
84
|
+
const arr: number[] = []
|
|
85
|
+
for (let i = 0; i < size; i++) arr.push(value)
|
|
236
86
|
return arr
|
|
237
87
|
}
|
|
238
88
|
|
|
239
89
|
const columnControlHandler = {
|
|
240
|
-
ondragmove
|
|
90
|
+
ondragmove(point: POINT, index: number, component: RackGrid) {
|
|
241
91
|
const { left, top, width, height } = component.textBounds
|
|
242
92
|
const widths_sum = component.widths_sum
|
|
243
|
-
|
|
244
93
|
const widths = component.widths.slice()
|
|
245
94
|
|
|
246
|
-
|
|
247
|
-
const origin_pos_unit = widths.slice(0, index + 1).reduce((sum: number, width: number) => sum + width, 0)
|
|
95
|
+
const origin_pos_unit = widths.slice(0, index + 1).reduce((sum, w) => sum + w, 0)
|
|
248
96
|
const origin_offset = left + (origin_pos_unit / widths_sum) * width
|
|
249
97
|
|
|
250
|
-
/*
|
|
251
|
-
* point의 좌표는 부모 레이어 기준의 x, y 값이다.
|
|
252
|
-
* 따라서, 도형의 회전을 감안한 좌표로의 변환이 필요하다.
|
|
253
|
-
* Transcoord시에는 point좌표가 부모까지 transcoord되어있는 상태이므로,
|
|
254
|
-
* 컴포넌트자신에 대한 transcoord만 필요하다.(마지막 파라미터를 false로).
|
|
255
|
-
*/
|
|
256
98
|
const transcoorded = component.transcoordP2S(point.x, point.y)
|
|
257
99
|
const diff = transcoorded.x - origin_offset
|
|
258
100
|
|
|
259
101
|
let diff_unit = (diff / width) * widths_sum
|
|
260
|
-
|
|
261
|
-
const min_width_unit = (widths_sum / width) * 5 // 5픽셀정도를 최소로
|
|
102
|
+
const min_width_unit = (widths_sum / width) * 5
|
|
262
103
|
|
|
263
104
|
if (diff_unit < 0) diff_unit = -Math.min(widths[index] - min_width_unit, -diff_unit)
|
|
264
105
|
else diff_unit = Math.min(widths[index + 1] - min_width_unit, diff_unit)
|
|
@@ -271,29 +112,20 @@ const columnControlHandler = {
|
|
|
271
112
|
}
|
|
272
113
|
|
|
273
114
|
const rowControlHandler = {
|
|
274
|
-
ondragmove
|
|
115
|
+
ondragmove(point: POINT, index: number, component: RackGrid) {
|
|
275
116
|
const { left, top, width, height } = component.textBounds
|
|
276
117
|
const heights_sum = component.heights_sum
|
|
277
|
-
|
|
278
118
|
const heights = component.heights.slice()
|
|
279
119
|
|
|
280
|
-
/* 컨트롤의 원래 위치를 구한다. */
|
|
281
120
|
index -= component.columns - 1
|
|
282
|
-
const origin_pos_unit = heights.slice(0, index + 1).reduce((sum
|
|
121
|
+
const origin_pos_unit = heights.slice(0, index + 1).reduce((sum, h) => sum + h, 0)
|
|
283
122
|
const origin_offset = top + (origin_pos_unit / heights_sum) * height
|
|
284
123
|
|
|
285
|
-
/*
|
|
286
|
-
* point의 좌표는 부모 레이어 기준의 x, y 값이다.
|
|
287
|
-
* 따라서, 도형의 회전을 감안한 좌표로의 변환이 필요하다.
|
|
288
|
-
* Transcoord시에는 point좌표가 부모까지 transcoord되어있는 상태이므로,
|
|
289
|
-
* 컴포넌트자신에 대한 transcoord만 필요하다.(마지막 파라미터를 false로).
|
|
290
|
-
*/
|
|
291
124
|
const transcoorded = component.transcoordP2S(point.x, point.y)
|
|
292
125
|
const diff = transcoorded.y - origin_offset
|
|
293
126
|
|
|
294
127
|
let diff_unit = (diff / height) * heights_sum
|
|
295
|
-
|
|
296
|
-
const min_height_unit = (heights_sum / height) * 5 // 5픽셀정도를 최소로
|
|
128
|
+
const min_height_unit = (heights_sum / height) * 5
|
|
297
129
|
|
|
298
130
|
if (diff_unit < 0) diff_unit = -Math.min(heights[index] - min_height_unit, -diff_unit)
|
|
299
131
|
else diff_unit = Math.min(heights[index + 1] - min_height_unit, diff_unit)
|
|
@@ -305,690 +137,1359 @@ const rowControlHandler = {
|
|
|
305
137
|
}
|
|
306
138
|
}
|
|
307
139
|
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
}
|
|
140
|
+
// ─── Cell-level metadata (sparse override) ────────────────────────────────────
|
|
141
|
+
|
|
142
|
+
/** 한 (col, row) 위치의 메타데이터. 명시되지 않은 위치는 *룰의 기본값* 적용 (= location 없음). */
|
|
143
|
+
export interface CellOverride {
|
|
144
|
+
/** Location 의 `{s}` 부분. unit 과 함께 명시되어야 location 부여됨. */
|
|
145
|
+
section?: string
|
|
146
|
+
/** Location 의 `{u}` 부분. section 과 함께 명시되어야 location 부여됨. */
|
|
147
|
+
unit?: string
|
|
148
|
+
/**
|
|
149
|
+
* 명시적 통로/공실 표시. 의미:
|
|
150
|
+
* - location 미부여 (이 cell 로는 외부 location key 매칭 불가)
|
|
151
|
+
* - increaseLocation 의 aisle row 로 인식 (skipNumbering 시 건너뜀)
|
|
152
|
+
* 자식 컴포넌트 종류는 무관 — Crane/AGV/forklift/비움 모두 OK.
|
|
153
|
+
*/
|
|
154
|
+
isEmpty?: boolean
|
|
155
|
+
/** 4-side border style (top/left/bottom/right) — modeling 시각 용. */
|
|
156
|
+
border?: any
|
|
157
|
+
/** Grid merge — 시각 표현. */
|
|
158
|
+
merged?: boolean
|
|
159
|
+
rowspan?: number
|
|
160
|
+
colspan?: number
|
|
161
|
+
}
|
|
313
162
|
|
|
314
|
-
|
|
163
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
315
164
|
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
165
|
+
export interface RackGridState extends State {
|
|
166
|
+
/** Grid 가로 칸 수 (X 축). 각 칸 ≈ 1 column 의 StorageRack (또는 Crane 등). */
|
|
167
|
+
columns?: number
|
|
168
|
+
/** Grid 세로 칸 수 (Z 축, depth). 통상 1, double-deep rack 등은 2. */
|
|
169
|
+
rows?: number
|
|
170
|
+
/** 각 (col, row) 의 수직 shelf 수 (Y 축, level). 자식 StorageRack 의 levels 와 일치. */
|
|
171
|
+
shelves?: number
|
|
321
172
|
|
|
322
|
-
|
|
323
|
-
|
|
173
|
+
/**
|
|
174
|
+
* 첫 shelf 의 시작 높이 (mm, 3D Y 축, 바닥부터). 미명시 0 (바닥 = 첫 shelf).
|
|
175
|
+
* 양수 시 그만큼 위로 올라가 *stocker port / conveyor 같은 컴포넌트가 들어갈 빈 공간*
|
|
176
|
+
* 확보. Frame uprights 는 바닥 ~ 천장 그대로.
|
|
177
|
+
*/
|
|
178
|
+
shelfBaseHeight?: number
|
|
179
|
+
|
|
180
|
+
// ── Location 룰 (rack-table 컨벤션) ──
|
|
181
|
+
zone?: string
|
|
182
|
+
/** 기본 `'{z}{s}-{u}-{sh}'`. placeholders: {z} {s} {u} {sh}. */
|
|
183
|
+
locPattern?: string
|
|
184
|
+
/** Section 숫자 자리수 (zero-pad). 기본 2. */
|
|
185
|
+
sectionDigits?: number
|
|
186
|
+
/** Unit 숫자 자리수 (zero-pad). 기본 2. */
|
|
187
|
+
unitDigits?: number
|
|
188
|
+
/** Shelf label CSV. 예: '1,2,3' / ',,,04'. 빈 자리는 1-based 인덱스 default. */
|
|
189
|
+
shelfLocations?: string
|
|
190
|
+
|
|
191
|
+
/** Sparse cell metadata. key = posKey = `${col-1}-${row-1}` (0-based, 2 segments). */
|
|
192
|
+
cellOverrides?: { [posKey: string]: CellOverride }
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Stock records — `{ cellId: 'col-row-shelf', type, ... }`. cellId 가 RackGrid 의
|
|
196
|
+
* cell 위치 (3 segments) 와 매칭되는 record 만 InstancedMesh instance 로 렌더링.
|
|
197
|
+
* Plan A 정신 — sparse 데이터, 자식 컴포넌트 없이 batched 시각화.
|
|
198
|
+
*/
|
|
199
|
+
data?: Array<{ cellId: string; [key: string]: any }>
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Popup 컴포넌트 id — 클릭 시 그 popup 을 invoke (anchor = 클릭된 cell 의 SlotTarget).
|
|
203
|
+
* 미명시 시 click event (`rack-grid-cell-click`) 만 emit.
|
|
204
|
+
*/
|
|
205
|
+
popupRef?: string
|
|
206
|
+
|
|
207
|
+
// ── 시각화 옵션 ──
|
|
208
|
+
hideRackFrame?: boolean // post + beam 모두 숨김
|
|
209
|
+
hideHorizontalFrame?: boolean // 가로 frame (beam) 만 숨김 (post 유지)
|
|
210
|
+
legendTarget?: string
|
|
211
|
+
hideEmptyStock?: boolean
|
|
212
|
+
widths?: number[]
|
|
213
|
+
heights?: number[]
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Nature ───────────────────────────────────────────────────────────────────
|
|
217
|
+
//
|
|
218
|
+
// `nature` 는 *static const* 가 아니라 *get nature()* — properties 의 widget event
|
|
219
|
+
// handler 안 `this` 가 *해당 RackGrid 인스턴스* 를 가리키도록 (화살표 함수가
|
|
220
|
+
// getter 의 `this` 를 capture). rack-table-cell 의 동일 패턴.
|
|
324
221
|
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
222
|
+
// ─── RackGrid ─────────────────────────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
@sceneComponent('rack-grid')
|
|
225
|
+
export default class RackGrid
|
|
226
|
+
extends Placeable(ContainerAbstract)
|
|
227
|
+
implements SlottedHolder
|
|
228
|
+
{
|
|
229
|
+
declare state: RackGridState
|
|
230
|
+
|
|
231
|
+
static placement: PlacementArchetype = 'floor'
|
|
232
|
+
static align: Alignment = 'bottom'
|
|
233
|
+
static defaultDepth = (h: Heights) => h.ceiling - h.floor
|
|
234
|
+
|
|
235
|
+
get nature(): ComponentNature {
|
|
236
|
+
return {
|
|
237
|
+
mutable: false,
|
|
238
|
+
resizable: true,
|
|
239
|
+
rotatable: true,
|
|
240
|
+
properties: [
|
|
241
|
+
{ type: 'number', label: 'columns', name: 'columns' },
|
|
242
|
+
{ type: 'number', label: 'rows', name: 'rows' },
|
|
243
|
+
{ type: 'number', label: 'shelves', name: 'shelves' },
|
|
244
|
+
{ type: 'number', label: 'shelf-base-height', name: 'shelfBaseHeight' },
|
|
245
|
+
{ type: 'string', label: 'zone', name: 'zone' },
|
|
246
|
+
{ type: 'string', label: 'location-pattern', name: 'locPattern', placeholder: '{z}{s}-{u}-{sh}' },
|
|
247
|
+
{ type: 'number', label: 'section-digits', name: 'sectionDigits' },
|
|
248
|
+
{ type: 'number', label: 'unit-digits', name: 'unitDigits' },
|
|
249
|
+
{ type: 'string', label: 'shelf-locations', name: 'shelfLocations', placeholder: '1,2,3 또는 ,,,04' },
|
|
250
|
+
{
|
|
251
|
+
// root.selected 의 RackGridCell 들 또는 *전체 grid* 에 채번 적용.
|
|
252
|
+
type: 'location-increase-pattern',
|
|
253
|
+
label: '',
|
|
254
|
+
name: '',
|
|
255
|
+
property: {
|
|
256
|
+
event: {
|
|
257
|
+
'increase-location-pattern': (event: CustomEvent) => {
|
|
258
|
+
const { increasingDirection, skipNumbering, startSection, startUnit } = event.detail
|
|
259
|
+
const selected = (this.root as any)?.selected as RackGridCell[] | undefined
|
|
260
|
+
const keys = (selected || [])
|
|
261
|
+
.filter(c => (c as any)?.state?.type === 'rack-grid-cell')
|
|
262
|
+
.map(c => c.state.cellId)
|
|
263
|
+
.filter((k): k is string => !!k)
|
|
264
|
+
this.increaseLocation(keys, increasingDirection, skipNumbering, startSection, startUnit)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
{ type: 'checkbox', label: 'hide-rack-frame', name: 'hideRackFrame' },
|
|
270
|
+
{ type: 'checkbox', label: 'hide-horizontal-frame', name: 'hideHorizontalFrame' },
|
|
271
|
+
{ type: 'id-input', label: 'legend-target', name: 'legendTarget', property: { component: 'legend' } },
|
|
272
|
+
{ type: 'checkbox', label: 'hide-empty-stock', name: 'hideEmptyStock' },
|
|
273
|
+
{ type: 'id-input', label: 'popup-ref', name: 'popupRef', property: { component: 'popup' } }
|
|
274
|
+
],
|
|
275
|
+
help: 'scene/component/rack-grid'
|
|
328
276
|
}
|
|
277
|
+
}
|
|
329
278
|
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
279
|
+
get anchors() {
|
|
280
|
+
return []
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Serialize 시 자식 RackGridCell 의 redundant 속성 제거:
|
|
285
|
+
* - 좌표/변환 (left/top/width/height/zPos/rotation/scale/translate) — table layout 이
|
|
286
|
+
* runtime 에 자동 결정. state 저장하면 layout 결과와 불일치 위험.
|
|
287
|
+
* - **refid** — *동적 생성* 되는 자식이라 *부모의 refid 가 그대로 들어가면 충돌*
|
|
288
|
+
* ("Refid Index replaced" 경고). 자식의 refid 는 *load 시 새로 자동 부여*. (외부
|
|
289
|
+
* 참조용 *id* 는 사용자 명시 시만 유지)
|
|
290
|
+
*
|
|
291
|
+
* 유지: cellId, section, unit, isEmpty, border, shelfLocations 등 *own 데이터*.
|
|
292
|
+
*/
|
|
293
|
+
get hierarchy(): any {
|
|
294
|
+
const base: any = super.hierarchy
|
|
295
|
+
if (base?.components && Array.isArray(base.components)) {
|
|
296
|
+
base.components = base.components.map((c: any) => {
|
|
297
|
+
if (!c || typeof c !== 'object') return c
|
|
298
|
+
const { left, top, width, height, zPos, rotation, scale, translate, refid, ...rest } = c
|
|
299
|
+
return rest
|
|
300
|
+
})
|
|
340
301
|
}
|
|
302
|
+
return base
|
|
303
|
+
}
|
|
341
304
|
|
|
342
|
-
|
|
305
|
+
/** Focusible — children (RackGridCell) 도 editor 의 selection 가능 (rack-table 동일). */
|
|
306
|
+
get focusible() {
|
|
307
|
+
return false
|
|
343
308
|
}
|
|
344
309
|
|
|
345
|
-
|
|
346
|
-
|
|
310
|
+
/** Lifecycle — children 자동 생성. columns × rows 만큼 RackGridCell 자식. */
|
|
311
|
+
created() {
|
|
312
|
+
const tobeSize = this.columns * this.rackRows
|
|
313
|
+
const gap = ((this as any).size?.() ?? this.components.length) - tobeSize
|
|
314
|
+
|
|
315
|
+
if (gap === 0) {
|
|
316
|
+
// 자식 수는 맞음 — cellId 만 sync
|
|
317
|
+
this._syncChildCellIds()
|
|
318
|
+
return
|
|
319
|
+
} else if (gap > 0) {
|
|
320
|
+
;(this as any).remove?.(this.components.slice(gap))
|
|
321
|
+
} else {
|
|
322
|
+
const newbies: any[] = []
|
|
323
|
+
for (let i = 0; i < -gap; i++) newbies.push(buildNewCell((this as any).app))
|
|
324
|
+
;(this as any).add?.(newbies)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const widths = this.getState('widths')
|
|
328
|
+
const heights = this.getState('heights')
|
|
329
|
+
if (!widths || (widths as number[]).length < this.columns) this.set('widths', this.widths)
|
|
330
|
+
if (!heights || (heights as number[]).length < this.rackRows) this.set('heights', this.heights)
|
|
331
|
+
|
|
332
|
+
this._syncChildCellIds()
|
|
347
333
|
}
|
|
348
334
|
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
335
|
+
/** columns/rows 변경 시 children 재구성. rack-table.buildCells 패턴. */
|
|
336
|
+
onchange(after: Properties, _before: Properties) {
|
|
337
|
+
super.onchange?.(after, _before)
|
|
338
|
+
if ('columns' in after || 'rows' in after) {
|
|
339
|
+
const oldCols = (_before as any).columns ?? this.columns
|
|
340
|
+
const oldRows = (_before as any).rows ?? this.rackRows
|
|
341
|
+
this._buildCells(this.rackRows, this.columns, oldRows, oldCols)
|
|
342
|
+
}
|
|
343
|
+
// 룰 관련 변경 → location 역인덱스 무효화
|
|
344
|
+
if ('locPattern' in after || 'sectionDigits' in after || 'unitDigits' in after ||
|
|
345
|
+
'shelfLocations' in after || 'zone' in after || 'shelves' in after) {
|
|
346
|
+
this.invalidateLocationIndex()
|
|
347
|
+
}
|
|
348
|
+
// stock 시각 영향 속성 → InstancedMesh 재빌드
|
|
349
|
+
if ('data' in after || 'hideEmptyStock' in after || 'shelves' in after ||
|
|
350
|
+
'shelfBaseHeight' in after || 'width' in after || 'depth' in after ||
|
|
351
|
+
'height' in after || 'columns' in after || 'rows' in after) {
|
|
352
|
+
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
353
|
+
}
|
|
354
|
+
// hideRackFrame / hideHorizontalFrame 변경 → frame visibility 즉시 토글
|
|
355
|
+
if ('hideRackFrame' in after || 'hideHorizontalFrame' in after) {
|
|
356
|
+
;(this._realObject as any)?.applyFrameVisibility?.()
|
|
357
|
+
}
|
|
352
358
|
}
|
|
353
359
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
this.
|
|
357
|
-
delete this._default_material
|
|
358
|
-
delete this._empty_material
|
|
360
|
+
/** state.data 변경 시 호출 (things-scene 의 onchange<PropName>). */
|
|
361
|
+
onchangeData(): void {
|
|
362
|
+
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
359
363
|
}
|
|
360
|
-
|
|
361
|
-
|
|
364
|
+
|
|
365
|
+
/** state.data 의 records — Plan A 의 stock 보관소. */
|
|
366
|
+
get records(): Array<{ cellId: string; [key: string]: any }> {
|
|
367
|
+
return (this.state.data as any) ?? []
|
|
362
368
|
}
|
|
363
369
|
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
this._resetMaterials()
|
|
370
|
+
// ── Legend integration ──────────────────────────────────
|
|
371
|
+
|
|
372
|
+
private _legendTarget?: Component
|
|
368
373
|
|
|
369
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Legend 컴포넌트 lookup. 우선순위:
|
|
376
|
+
* 1) state.legendTarget id 명시
|
|
377
|
+
* 2) scene 전체 의 type='legend' 첫 번째 컴포넌트 (자동 발견)
|
|
378
|
+
*/
|
|
379
|
+
get legendTarget(): Component | undefined {
|
|
380
|
+
if (this._legendTarget) return this._legendTarget
|
|
381
|
+
|
|
382
|
+
const id = this.state.legendTarget
|
|
383
|
+
if (id) {
|
|
384
|
+
const found = (this.root as any)?.findById?.(id) as Component | undefined
|
|
385
|
+
if (found) {
|
|
386
|
+
this._legendTarget = found
|
|
387
|
+
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
388
|
+
return found
|
|
389
|
+
}
|
|
390
|
+
}
|
|
370
391
|
|
|
371
|
-
|
|
392
|
+
const visit = (node: any): Component | undefined => {
|
|
393
|
+
if (!node) return undefined
|
|
394
|
+
if (node.state?.type === 'legend') return node as Component
|
|
395
|
+
const children = node.components as Component[] | undefined
|
|
396
|
+
if (children) {
|
|
397
|
+
for (const c of children) {
|
|
398
|
+
const r = visit(c)
|
|
399
|
+
if (r) return r
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return undefined
|
|
403
|
+
}
|
|
404
|
+
const found = visit(this.root)
|
|
405
|
+
if (found) {
|
|
406
|
+
this._legendTarget = found
|
|
407
|
+
;(found as any).on?.('change', this._onLegendChanged, this)
|
|
408
|
+
}
|
|
409
|
+
return found
|
|
372
410
|
}
|
|
373
411
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
412
|
+
private _onLegendChanged = (): void => {
|
|
413
|
+
;(this._realObject as any)?.rebuildStockMesh?.()
|
|
414
|
+
}
|
|
377
415
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
416
|
+
/**
|
|
417
|
+
* record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
|
|
418
|
+
* - `range.value === recordValue` (카테고리 일치)
|
|
419
|
+
* - `range.min ≤ Number(v) < range.max` (수치 범위)
|
|
420
|
+
* - 매칭 없으면 `defaultColor` 또는 undefined
|
|
421
|
+
*/
|
|
422
|
+
// ── View-mode click → rack-grid-cell-click event + popup ──
|
|
385
423
|
|
|
386
|
-
|
|
424
|
+
get eventMap() {
|
|
425
|
+
return {
|
|
426
|
+
'(self)': {
|
|
427
|
+
'(self)': {
|
|
428
|
+
click: this._onViewClick
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
387
433
|
|
|
388
|
-
|
|
434
|
+
private _onViewClick = (mouseEvent: MouseEvent) => {
|
|
435
|
+
if (!(this as any).app?.isViewMode) return
|
|
436
|
+
const ro: any = (this as any)._realObject
|
|
437
|
+
if (!ro?.object3d) return
|
|
438
|
+
|
|
439
|
+
const hit = this._raycastHit(mouseEvent)
|
|
440
|
+
if (!hit) return
|
|
441
|
+
|
|
442
|
+
const stockMesh: THREE.InstancedMesh | undefined = ro.stockMesh
|
|
443
|
+
let cellId: string | undefined
|
|
444
|
+
let record: any
|
|
445
|
+
let isStock = false
|
|
446
|
+
let instanceId: number | undefined
|
|
447
|
+
|
|
448
|
+
if (hit.object === stockMesh) {
|
|
449
|
+
const records: any[] | undefined = (stockMesh as any).userData?._records
|
|
450
|
+
record = records?.[hit.instanceId ?? -1]
|
|
451
|
+
cellId = record?.cellId
|
|
452
|
+
isStock = true
|
|
453
|
+
instanceId = hit.instanceId
|
|
454
|
+
} else {
|
|
455
|
+
const cid = this._cellIdFromWorldPoint(hit.point)
|
|
456
|
+
if (!cid || cid.startsWith('out-of-bounds')) return
|
|
457
|
+
cellId = cid
|
|
458
|
+
const data = this.state.data as Array<any> | undefined
|
|
459
|
+
record = Array.isArray(data) ? data.find(r => r?.cellId === cid) : undefined
|
|
389
460
|
}
|
|
390
461
|
|
|
391
|
-
const
|
|
392
|
-
|
|
462
|
+
const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
|
|
463
|
+
this.trigger('rack-grid-cell-click', payload)
|
|
464
|
+
this._invokePopup(cellId, record)
|
|
393
465
|
|
|
394
|
-
|
|
395
|
-
|
|
466
|
+
// popup 외부 click 으로 인식되어 자동 close 되는 회귀 차단
|
|
467
|
+
mouseEvent.stopPropagation?.()
|
|
396
468
|
}
|
|
397
469
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
470
|
+
/** state.popupRef Popup 컴포넌트 invoke. anchor = 클릭된 cell 의 SlotTarget. */
|
|
471
|
+
private _invokePopup(cellId: string | undefined, record: any): void {
|
|
472
|
+
const popupRefId = this.state.popupRef
|
|
473
|
+
if (!popupRefId || !cellId) return
|
|
474
|
+
const popupComp: any = (this.root as any)?.findById?.(popupRefId)
|
|
475
|
+
if (!popupComp || typeof popupComp.openPopup !== 'function') {
|
|
476
|
+
console.warn(`[rack-grid] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
|
|
477
|
+
return
|
|
478
|
+
}
|
|
479
|
+
const anchor = this.slotTargetAt(cellId)
|
|
480
|
+
popupComp.openPopup(record ?? { cellId }, { anchor })
|
|
401
481
|
}
|
|
402
482
|
|
|
403
|
-
|
|
404
|
-
|
|
483
|
+
/** raycast → 우리 RackGrid 의 어떤 mesh 가 closest hit 인지. */
|
|
484
|
+
private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
|
|
485
|
+
const ro: any = (this as any)._realObject
|
|
486
|
+
if (!ro?.object3d) return undefined
|
|
487
|
+
const tc: any = ro.threeContainer
|
|
488
|
+
if (!tc) return undefined
|
|
489
|
+
|
|
490
|
+
const cap: any = tc._threeCapability ?? tc._capability
|
|
491
|
+
let intersects: THREE.Intersection[] | undefined
|
|
492
|
+
if (cap?.getObjectsByRaycast) {
|
|
493
|
+
intersects = cap.getObjectsByRaycast() as THREE.Intersection[] | undefined
|
|
494
|
+
}
|
|
405
495
|
|
|
406
|
-
if (!
|
|
496
|
+
if (!intersects || intersects.length === 0) {
|
|
497
|
+
const scene = tc.scene3d as THREE.Scene | undefined
|
|
498
|
+
const renderer = tc.renderer3d as THREE.WebGLRenderer | undefined
|
|
499
|
+
const camera =
|
|
500
|
+
(tc.activeCamera3d as THREE.Camera | undefined) ??
|
|
501
|
+
(cap?.activeCamera as THREE.Camera | undefined) ??
|
|
502
|
+
(cap?.camera as THREE.Camera | undefined)
|
|
503
|
+
const canvas = renderer?.domElement
|
|
504
|
+
if (!scene || !canvas || !camera) return undefined
|
|
505
|
+
const rect = canvas.getBoundingClientRect()
|
|
506
|
+
if (rect.width === 0 || rect.height === 0) return undefined
|
|
507
|
+
const ndc = new THREE.Vector2(
|
|
508
|
+
((mouseEvent.clientX - rect.left) / rect.width) * 2 - 1,
|
|
509
|
+
-((mouseEvent.clientY - rect.top) / rect.height) * 2 + 1
|
|
510
|
+
)
|
|
511
|
+
const raycaster = new THREE.Raycaster()
|
|
512
|
+
raycaster.setFromCamera(ndc, camera)
|
|
513
|
+
intersects = raycaster.intersectObjects(scene.children, true)
|
|
514
|
+
}
|
|
515
|
+
if (!intersects || intersects.length === 0) return undefined
|
|
407
516
|
|
|
408
|
-
|
|
409
|
-
|
|
517
|
+
const closest = intersects[0]
|
|
518
|
+
let obj: THREE.Object3D | null = closest.object
|
|
519
|
+
while (obj) {
|
|
520
|
+
if (obj.userData?.context === ro) return closest
|
|
521
|
+
obj = obj.parent
|
|
522
|
+
}
|
|
523
|
+
return undefined
|
|
524
|
+
}
|
|
410
525
|
|
|
411
|
-
|
|
526
|
+
/** world point → cellId (col-row-shelf) 역산. */
|
|
527
|
+
private _cellIdFromWorldPoint(worldPoint: THREE.Vector3): string | null {
|
|
528
|
+
const ro: any = (this as any)._realObject
|
|
529
|
+
if (!ro?.object3d) return null
|
|
530
|
+
|
|
531
|
+
const local = new THREE.Vector3().copy(worldPoint)
|
|
532
|
+
const mInv = new THREE.Matrix4().copy(ro.object3d.matrixWorld).invert()
|
|
533
|
+
local.applyMatrix4(mInv)
|
|
534
|
+
|
|
535
|
+
const cols = this.columns
|
|
536
|
+
const rows = this.rackRows
|
|
537
|
+
const shelves = this.shelves
|
|
538
|
+
const width = (this.state.width as number) || 1000
|
|
539
|
+
const height = (this.state.depth as number) || 2000 // 3D Y
|
|
540
|
+
const depth = (this.state.height as number) || 200 // 3D Z
|
|
541
|
+
const shelfBase = (this.state.shelfBaseHeight as number) || 0
|
|
542
|
+
const shelfZone = height - shelfBase
|
|
543
|
+
|
|
544
|
+
const bayW = width / cols
|
|
545
|
+
const bayD = depth / rows
|
|
546
|
+
const cellY = shelfZone / shelves
|
|
547
|
+
|
|
548
|
+
// center-based: X [-width/2, +width/2], Y [-height/2, +height/2], Z [-depth/2, +depth/2]
|
|
549
|
+
const col = Math.floor((local.x + width / 2) / bayW)
|
|
550
|
+
const yFromBottom = local.y + height / 2 - shelfBase
|
|
551
|
+
const shelf = Math.floor(yFromBottom / cellY)
|
|
552
|
+
const row = Math.floor((local.z + depth / 2) / bayD)
|
|
553
|
+
|
|
554
|
+
if (col < 0 || col >= cols || row < 0 || row >= rows || shelf < 0 || shelf >= shelves) {
|
|
555
|
+
return `out-of-bounds(col=${col},row=${row},shelf=${shelf})`
|
|
556
|
+
}
|
|
557
|
+
return `${col}-${row}-${shelf}`
|
|
412
558
|
}
|
|
413
559
|
|
|
414
|
-
|
|
415
|
-
const
|
|
560
|
+
resolveLegendColor(record: any): string | undefined {
|
|
561
|
+
const legend = this.legendTarget
|
|
562
|
+
if (!legend) return undefined
|
|
563
|
+
const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
|
|
564
|
+
if (!status) return undefined
|
|
416
565
|
|
|
417
|
-
|
|
566
|
+
const field = status.field as string | undefined
|
|
567
|
+
const ranges = status.ranges as any[] | undefined
|
|
568
|
+
if (!field || !Array.isArray(ranges)) return undefined
|
|
418
569
|
|
|
419
|
-
|
|
420
|
-
|
|
570
|
+
const value = record?.[field]
|
|
571
|
+
if (value === undefined || value === null) return status.defaultColor
|
|
421
572
|
|
|
422
|
-
|
|
573
|
+
for (const range of ranges) {
|
|
574
|
+
if (!range) continue
|
|
575
|
+
if (range.value !== undefined) {
|
|
576
|
+
if (range.value === value) return range.color
|
|
577
|
+
continue
|
|
578
|
+
}
|
|
579
|
+
const num = Number(value)
|
|
580
|
+
if (!Number.isFinite(num)) continue
|
|
581
|
+
const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
|
|
582
|
+
const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
|
|
583
|
+
const minOk = min === undefined || num >= min
|
|
584
|
+
const maxOk = max === undefined || num < max
|
|
585
|
+
if (minOk && maxOk) return range.color
|
|
586
|
+
}
|
|
587
|
+
return status.defaultColor as string | undefined
|
|
423
588
|
}
|
|
424
589
|
|
|
425
|
-
|
|
590
|
+
/**
|
|
591
|
+
* 새 (rows × columns) 에 맞춰 children 재구성. rack-table.buildCells 정확 클론.
|
|
592
|
+
*/
|
|
593
|
+
private _buildCells(newrows: number, newcolumns: number, oldrows: number, oldcolumns: number) {
|
|
594
|
+
const app = (this as any).app
|
|
426
595
|
if (newrows < oldrows) {
|
|
427
|
-
|
|
428
|
-
this.remove(removals)
|
|
596
|
+
const removals = this.components.slice(oldcolumns * newrows)
|
|
597
|
+
;(this as any).remove?.(removals)
|
|
429
598
|
}
|
|
430
|
-
|
|
431
599
|
const minrows = Math.min(newrows, oldrows)
|
|
432
|
-
|
|
433
600
|
if (newcolumns > oldcolumns) {
|
|
434
601
|
for (let r = 0; r < minrows; r++) {
|
|
435
602
|
for (let c = oldcolumns; c < newcolumns; c++) {
|
|
436
|
-
this.insertComponentAt(buildNewCell(
|
|
603
|
+
;(this as any).insertComponentAt?.(buildNewCell(app), r * newcolumns + c)
|
|
437
604
|
}
|
|
438
605
|
}
|
|
439
606
|
} else if (newcolumns < oldcolumns) {
|
|
440
|
-
|
|
441
|
-
|
|
607
|
+
const removals: any[] = []
|
|
442
608
|
for (let r = 0; r < minrows; r++) {
|
|
443
609
|
for (let c = newcolumns; c < oldcolumns; c++) {
|
|
444
610
|
removals.push(this.components[r * oldcolumns + c])
|
|
445
611
|
}
|
|
446
612
|
}
|
|
447
|
-
this.remove(removals)
|
|
613
|
+
;(this as any).remove?.(removals)
|
|
448
614
|
}
|
|
449
|
-
|
|
450
615
|
if (newrows > oldrows) {
|
|
451
|
-
|
|
452
|
-
|
|
616
|
+
const newbies: any[] = []
|
|
453
617
|
for (let r = oldrows; r < newrows; r++) {
|
|
454
|
-
for (let i = 0; i < newcolumns; i++)
|
|
455
|
-
newbies.push(buildNewCell(this.app))
|
|
456
|
-
}
|
|
618
|
+
for (let i = 0; i < newcolumns; i++) newbies.push(buildNewCell(app))
|
|
457
619
|
}
|
|
458
|
-
this.add(newbies)
|
|
620
|
+
;(this as any).add?.(newbies)
|
|
459
621
|
}
|
|
622
|
+
this.set({ widths: this.widths, heights: this.heights })
|
|
623
|
+
this._syncChildCellIds()
|
|
624
|
+
}
|
|
460
625
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
626
|
+
/** children 의 state.cellId 가 *순서 기반 bayKey* 와 일치하도록 동기화. */
|
|
627
|
+
private _syncChildCellIds() {
|
|
628
|
+
const cols = this.columns
|
|
629
|
+
const children = this.components
|
|
630
|
+
for (let i = 0; i < children.length; i++) {
|
|
631
|
+
const col = i % cols
|
|
632
|
+
const row = Math.floor(i / cols)
|
|
633
|
+
const bayKey = `${col}-${row}`
|
|
634
|
+
const c: any = children[i]
|
|
635
|
+
if (c?.state?.cellId !== bayKey) c?.set?.('cellId', bayKey)
|
|
636
|
+
}
|
|
465
637
|
}
|
|
466
638
|
|
|
467
|
-
|
|
468
|
-
|
|
639
|
+
/** Row 인덱스의 cell 들. rack-table-cell 의 rowCells 가 사용. */
|
|
640
|
+
getCellsByRow(row: number): Component[] {
|
|
641
|
+
return this.components.slice(row * this.columns, (row + 1) * this.columns)
|
|
642
|
+
}
|
|
643
|
+
/** Column 인덱스의 cell 들. */
|
|
644
|
+
getCellsByColumn(column: number): Component[] {
|
|
645
|
+
const out: Component[] = []
|
|
646
|
+
for (let i = 0; i < this.rackRows; i++) out.push(this.components[this.columns * i + column])
|
|
647
|
+
return out
|
|
469
648
|
}
|
|
470
649
|
|
|
471
|
-
|
|
472
|
-
|
|
650
|
+
/** (col, row) 위치의 cell-component. */
|
|
651
|
+
cellAt(col: number, row: number = 0): RackGridCell | null {
|
|
652
|
+
const idx = row * this.columns + col
|
|
653
|
+
return (this.components[idx] as any) ?? null
|
|
473
654
|
}
|
|
474
655
|
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
const
|
|
478
|
-
|
|
656
|
+
/** bayKey 의 cell-component. */
|
|
657
|
+
cellAtBayKey(bayKey: string): RackGridCell | null {
|
|
658
|
+
const parsed = this.parsePosKey(bayKey)
|
|
659
|
+
if (!parsed) return null
|
|
660
|
+
return this.cellAt(parsed.col, parsed.row)
|
|
661
|
+
}
|
|
479
662
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
}
|
|
491
|
-
})
|
|
663
|
+
/**
|
|
664
|
+
* (col, row) bay 가 *isEmpty* 인가. cell-component 의 state.isEmpty 가 source of
|
|
665
|
+
* truth, 없으면 cellOverrides[posKey].isEmpty fallback.
|
|
666
|
+
*/
|
|
667
|
+
isBayEmpty(col: number, row: number = 0): boolean {
|
|
668
|
+
const cell = this.cellAt(col, row)
|
|
669
|
+
if (cell) return !!cell.state.isEmpty
|
|
670
|
+
const posKey = `${col}-${row}`
|
|
671
|
+
return !!this.cellOverrides[posKey]?.isEmpty
|
|
672
|
+
}
|
|
492
673
|
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
const cell = components[i] as RackGridCell
|
|
496
|
-
|
|
497
|
-
switch (where) {
|
|
498
|
-
case 'all':
|
|
499
|
-
setCellBorder(cell, style, where)
|
|
500
|
-
|
|
501
|
-
if (isLeftMost(total, columns, indices, i))
|
|
502
|
-
setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
|
|
503
|
-
if (isRightMost(total, columns, indices, i))
|
|
504
|
-
setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
|
|
505
|
-
if (isTopMost(total, columns, indices, i))
|
|
506
|
-
setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
|
|
507
|
-
if (isBottomMost(total, columns, indices, i))
|
|
508
|
-
setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
|
|
509
|
-
break
|
|
510
|
-
case 'in':
|
|
511
|
-
if (!isLeftMost(total, columns, indices, i)) {
|
|
512
|
-
setCellBorder(cell, style, 'left')
|
|
513
|
-
}
|
|
514
|
-
if (!isRightMost(total, columns, indices, i)) {
|
|
515
|
-
setCellBorder(cell, style, 'right')
|
|
516
|
-
}
|
|
517
|
-
if (!isTopMost(total, columns, indices, i)) {
|
|
518
|
-
setCellBorder(cell, style, 'top')
|
|
519
|
-
}
|
|
520
|
-
if (!isBottomMost(total, columns, indices, i)) {
|
|
521
|
-
setCellBorder(cell, style, 'bottom')
|
|
522
|
-
}
|
|
523
|
-
break
|
|
524
|
-
case 'out':
|
|
525
|
-
if (isLeftMost(total, columns, indices, i)) {
|
|
526
|
-
setCellBorder(cell, style, 'left')
|
|
527
|
-
setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
|
|
528
|
-
}
|
|
529
|
-
if (isRightMost(total, columns, indices, i)) {
|
|
530
|
-
setCellBorder(cell, style, 'right')
|
|
531
|
-
setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
|
|
532
|
-
}
|
|
533
|
-
if (isTopMost(total, columns, indices, i)) {
|
|
534
|
-
setCellBorder(cell, style, 'top')
|
|
535
|
-
setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
|
|
536
|
-
}
|
|
537
|
-
if (isBottomMost(total, columns, indices, i)) {
|
|
538
|
-
setCellBorder(cell, style, 'bottom')
|
|
539
|
-
setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
|
|
540
|
-
}
|
|
541
|
-
break
|
|
542
|
-
case 'left':
|
|
543
|
-
if (isLeftMost(total, columns, indices, i)) {
|
|
544
|
-
setCellBorder(cell, style, 'left')
|
|
545
|
-
setCellBorder(components[before(columns, i)] as RackGridCell, style, 'right')
|
|
546
|
-
}
|
|
547
|
-
break
|
|
548
|
-
case 'right':
|
|
549
|
-
if (isRightMost(total, columns, indices, i)) {
|
|
550
|
-
setCellBorder(cell, style, 'right')
|
|
551
|
-
setCellBorder(components[after(columns, i)] as RackGridCell, style, 'left')
|
|
552
|
-
}
|
|
553
|
-
break
|
|
554
|
-
case 'center':
|
|
555
|
-
if (!isLeftMost(total, columns, indices, i)) {
|
|
556
|
-
setCellBorder(cell, style, 'left')
|
|
557
|
-
}
|
|
558
|
-
if (!isRightMost(total, columns, indices, i)) {
|
|
559
|
-
setCellBorder(cell, style, 'right')
|
|
560
|
-
}
|
|
561
|
-
break
|
|
562
|
-
case 'middle':
|
|
563
|
-
if (!isTopMost(total, columns, indices, i)) {
|
|
564
|
-
setCellBorder(cell, style, 'top')
|
|
565
|
-
}
|
|
566
|
-
if (!isBottomMost(total, columns, indices, i)) {
|
|
567
|
-
setCellBorder(cell, style, 'bottom')
|
|
568
|
-
}
|
|
569
|
-
break
|
|
570
|
-
case 'top':
|
|
571
|
-
if (isTopMost(total, columns, indices, i)) {
|
|
572
|
-
setCellBorder(cell, style, 'top')
|
|
573
|
-
setCellBorder(components[above(columns, i)] as RackGridCell, style, 'bottom')
|
|
574
|
-
}
|
|
575
|
-
break
|
|
576
|
-
case 'bottom':
|
|
577
|
-
if (isBottomMost(total, columns, indices, i)) {
|
|
578
|
-
setCellBorder(cell, style, 'bottom')
|
|
579
|
-
setCellBorder(components[below(columns, i)] as RackGridCell, style, 'top')
|
|
580
|
-
}
|
|
581
|
-
break
|
|
582
|
-
case 'clear':
|
|
583
|
-
setCellBorder(cell, CLEAR_STYLE, 'all')
|
|
584
|
-
|
|
585
|
-
if (isLeftMost(total, columns, indices, i))
|
|
586
|
-
setCellBorder(components[before(columns, i)] as RackGridCell, CLEAR_STYLE, 'right')
|
|
587
|
-
if (isRightMost(total, columns, indices, i))
|
|
588
|
-
setCellBorder(components[after(columns, i)] as RackGridCell, CLEAR_STYLE, 'left')
|
|
589
|
-
if (isTopMost(total, columns, indices, i))
|
|
590
|
-
setCellBorder(components[above(columns, i)] as RackGridCell, CLEAR_STYLE, 'bottom')
|
|
591
|
-
if (isBottomMost(total, columns, indices, i))
|
|
592
|
-
setCellBorder(components[below(columns, i)] as RackGridCell, CLEAR_STYLE, 'top')
|
|
593
|
-
}
|
|
594
|
-
})
|
|
674
|
+
buildRealObject(): RealObject | undefined {
|
|
675
|
+
return new RackGrid3D(this)
|
|
595
676
|
}
|
|
596
677
|
|
|
597
|
-
|
|
598
|
-
const idx = this.components.indexOf(cell)
|
|
678
|
+
// ── Table layout — 자식 자동 배치 + columns/rows 의 가변 widths/heights ──
|
|
599
679
|
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
row: Math.floor(idx / this.columns)
|
|
603
|
-
}
|
|
680
|
+
get layout(): any {
|
|
681
|
+
return TABLE_LAYOUT
|
|
604
682
|
}
|
|
605
683
|
|
|
606
|
-
|
|
607
|
-
|
|
684
|
+
get widths(): number[] {
|
|
685
|
+
const widths = this.getState('widths') as number[] | undefined
|
|
686
|
+
if (!widths) return arrayOf(1, this.columns)
|
|
687
|
+
if (widths.length < this.columns) return widths.concat(arrayOf(1, this.columns - widths.length))
|
|
688
|
+
if (widths.length > this.columns) return widths.slice(0, this.columns)
|
|
689
|
+
return widths
|
|
608
690
|
}
|
|
609
691
|
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
return
|
|
692
|
+
get heights(): number[] {
|
|
693
|
+
const heights = this.getState('heights') as number[] | undefined
|
|
694
|
+
if (!heights) return arrayOf(1, this.rackRows)
|
|
695
|
+
if (heights.length < this.rackRows) return heights.concat(arrayOf(1, this.rackRows - heights.length))
|
|
696
|
+
if (heights.length > this.rackRows) return heights.slice(0, this.rackRows)
|
|
697
|
+
return heights
|
|
615
698
|
}
|
|
616
699
|
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
700
|
+
get widths_sum(): number {
|
|
701
|
+
return this.widths.reduce((sum, w) => sum + w, 0) || this.columns
|
|
702
|
+
}
|
|
620
703
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
rows.push(row)
|
|
625
|
-
}
|
|
626
|
-
})
|
|
704
|
+
get heights_sum(): number {
|
|
705
|
+
return this.heights.reduce((sum, h) => sum + h, 0) || this.rackRows
|
|
706
|
+
}
|
|
627
707
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
708
|
+
/** Column / Row 사이 드래그 핸들 — editor 의 widths/heights 가변 조절. */
|
|
709
|
+
get controls(): Array<Control> | undefined {
|
|
710
|
+
const widths = this.widths
|
|
711
|
+
const heights = this.heights
|
|
712
|
+
const inside = this.textBounds
|
|
713
|
+
const width_unit = inside.width / this.widths_sum
|
|
714
|
+
const height_unit = inside.height / this.heights_sum
|
|
631
715
|
|
|
632
|
-
|
|
716
|
+
const controls: Array<Control> = []
|
|
633
717
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
718
|
+
let x = inside.left
|
|
719
|
+
widths.slice(0, this.columns - 1).forEach(w => {
|
|
720
|
+
x += w * width_unit
|
|
721
|
+
controls.push({ x, y: inside.top, handler: columnControlHandler })
|
|
637
722
|
})
|
|
638
723
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
deleteColumns(cells: RackGridCell[]) {
|
|
646
|
-
// 먼저 cells 위치의 열을 구한다.
|
|
647
|
-
let columns = [] as number[]
|
|
648
|
-
cells.forEach(cell => {
|
|
649
|
-
let column = this.getRowColumn(cell).column
|
|
650
|
-
if (-1 == columns.indexOf(column)) columns.push(column)
|
|
651
|
-
})
|
|
652
|
-
columns.sort((a, b) => {
|
|
653
|
-
return a - b
|
|
654
|
-
})
|
|
655
|
-
columns.reverse()
|
|
656
|
-
|
|
657
|
-
columns.forEach(column => {
|
|
658
|
-
const widths = this.widths.slice()
|
|
659
|
-
this.remove(this.getCellsByColumn(column))
|
|
660
|
-
widths.splice(column, 1)
|
|
661
|
-
this.model.columns -= 1 // 고의적으로, change 이벤트가 발생하지 않도록 set(..)을 사용하지 않음.
|
|
662
|
-
this.set('widths', widths)
|
|
724
|
+
let y = inside.top
|
|
725
|
+
heights.slice(0, this.rackRows - 1).forEach(h => {
|
|
726
|
+
y += h * height_unit
|
|
727
|
+
controls.push({ x: inside.left, y, handler: rowControlHandler })
|
|
663
728
|
})
|
|
664
|
-
}
|
|
665
729
|
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
let rows = [] as number[]
|
|
669
|
-
cells.forEach(cell => {
|
|
670
|
-
let row = this.getRowColumn(cell).row
|
|
671
|
-
if (-1 == rows.indexOf(row)) rows.push(row)
|
|
672
|
-
})
|
|
673
|
-
rows.sort((a, b) => {
|
|
674
|
-
return a - b
|
|
675
|
-
})
|
|
676
|
-
rows.reverse()
|
|
677
|
-
// 행 2개 이상은 추가 안함. 임시로 막아놓음
|
|
678
|
-
if (rows.length >= 2) return false
|
|
679
|
-
let insertionRowPosition = rows[0]
|
|
680
|
-
let newbieRowHeights = [] as number[]
|
|
681
|
-
let newbieCells = [] as RackGridCell[]
|
|
682
|
-
rows.forEach(row => {
|
|
683
|
-
for (let i = 0; i < this.columns; i++)
|
|
684
|
-
newbieCells.push(buildCopiedCell(this.components[row * this.columns + i].model, this.app) as RackGridCell)
|
|
685
|
-
|
|
686
|
-
newbieRowHeights.push(this.heights[row])
|
|
687
|
-
|
|
688
|
-
newbieCells.reverse().forEach(cell => {
|
|
689
|
-
this.insertComponentAt(cell, insertionRowPosition * this.columns)
|
|
690
|
-
})
|
|
730
|
+
return controls
|
|
731
|
+
}
|
|
691
732
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
733
|
+
// ── 2D 렌더링 — table layout 의 widths/heights 기반 격자 ─────
|
|
734
|
+
//
|
|
735
|
+
// 자식 컴포넌트 가 자기 렌더링하지만 *비어있는 grid 도 modeling 시 가시화*. 격자
|
|
736
|
+
// 좌표는 widths/heights (table layout 의 가변 분할) 기반. cellOverrides 의
|
|
737
|
+
// isEmpty (사선) + border (4-side) 도 표시.
|
|
695
738
|
|
|
696
|
-
|
|
739
|
+
render(ctx: CanvasRenderingContext2D) {
|
|
740
|
+
// 2D 에서는 hideRackFrame 이어도 *외곽 + 격자선 + cell 마커* 는 항상 그림.
|
|
741
|
+
// (2D 의 frame 은 시각적 *분할선* 이라 frame mesh 와 의미가 다름)
|
|
742
|
+
const inside = this.textBounds
|
|
743
|
+
const left = inside.left
|
|
744
|
+
const top = inside.top
|
|
745
|
+
const width = inside.width
|
|
746
|
+
const height = inside.height
|
|
747
|
+
const cols = this.columns
|
|
748
|
+
const rows = this.rackRows
|
|
749
|
+
const widths = this.widths
|
|
750
|
+
const heights = this.heights
|
|
751
|
+
const wSum = this.widths_sum
|
|
752
|
+
const hSum = this.heights_sum
|
|
753
|
+
const fill = (this.state.fillStyle as string) || '#e8e8ec'
|
|
754
|
+
const stroke = (this.state.strokeStyle as string) || '#888'
|
|
755
|
+
const lineWidth = (this.state.lineWidth as number) || 1
|
|
756
|
+
const overrides = this.cellOverrides
|
|
757
|
+
|
|
758
|
+
// column 별 X 좌표 (좌→우 누적)
|
|
759
|
+
const xs: number[] = [left]
|
|
760
|
+
for (let c = 0; c < cols; c++) xs.push(xs[c] + (widths[c] / wSum) * width)
|
|
761
|
+
// row 별 Y 좌표 (상→하 누적)
|
|
762
|
+
const ys: number[] = [top]
|
|
763
|
+
for (let r = 0; r < rows; r++) ys.push(ys[r] + (heights[r] / hSum) * height)
|
|
764
|
+
|
|
765
|
+
// Fill (반투명) — 외곽 영역
|
|
766
|
+
ctx.save()
|
|
767
|
+
ctx.fillStyle = fill
|
|
768
|
+
ctx.globalAlpha = 0.2
|
|
769
|
+
ctx.fillRect(left, top, width, height)
|
|
770
|
+
ctx.restore()
|
|
771
|
+
|
|
772
|
+
// isEmpty cell 표시 (사선 + 회색 fill)
|
|
773
|
+
ctx.save()
|
|
774
|
+
for (const [posKey, ov] of Object.entries(overrides)) {
|
|
775
|
+
if (!ov.isEmpty) continue
|
|
776
|
+
const parsed = this.parsePosKey(posKey)
|
|
777
|
+
if (!parsed) continue
|
|
778
|
+
if (parsed.col < 0 || parsed.col >= cols || parsed.row < 0 || parsed.row >= rows) continue
|
|
779
|
+
const cl = xs[parsed.col]
|
|
780
|
+
const ct = ys[parsed.row]
|
|
781
|
+
const cw = xs[parsed.col + 1] - cl
|
|
782
|
+
const ch = ys[parsed.row + 1] - ct
|
|
783
|
+
ctx.fillStyle = '#cccccc'
|
|
784
|
+
ctx.globalAlpha = 0.5
|
|
785
|
+
ctx.fillRect(cl, ct, cw, ch)
|
|
786
|
+
ctx.globalAlpha = 1
|
|
787
|
+
ctx.strokeStyle = '#999'
|
|
788
|
+
ctx.lineWidth = 1
|
|
789
|
+
ctx.beginPath()
|
|
790
|
+
ctx.moveTo(cl, ct); ctx.lineTo(cl + cw, ct + ch)
|
|
791
|
+
ctx.moveTo(cl + cw, ct); ctx.lineTo(cl, ct + ch)
|
|
792
|
+
ctx.stroke()
|
|
793
|
+
}
|
|
794
|
+
ctx.restore()
|
|
795
|
+
|
|
796
|
+
// 외곽 + 격자
|
|
797
|
+
ctx.save()
|
|
798
|
+
ctx.strokeStyle = stroke
|
|
799
|
+
ctx.lineWidth = lineWidth
|
|
800
|
+
ctx.strokeRect(left, top, width, height)
|
|
801
|
+
ctx.beginPath()
|
|
802
|
+
for (let c = 1; c < cols; c++) {
|
|
803
|
+
ctx.moveTo(xs[c], top); ctx.lineTo(xs[c], top + height)
|
|
804
|
+
}
|
|
805
|
+
for (let r = 1; r < rows; r++) {
|
|
806
|
+
ctx.moveTo(left, ys[r]); ctx.lineTo(left + width, ys[r])
|
|
807
|
+
}
|
|
808
|
+
ctx.stroke()
|
|
809
|
+
ctx.restore()
|
|
810
|
+
|
|
811
|
+
// cell 별 명시 border
|
|
812
|
+
for (const [posKey, ov] of Object.entries(overrides)) {
|
|
813
|
+
if (!ov.border) continue
|
|
814
|
+
const parsed = this.parsePosKey(posKey)
|
|
815
|
+
if (!parsed) continue
|
|
816
|
+
if (parsed.col < 0 || parsed.col >= cols || parsed.row < 0 || parsed.row >= rows) continue
|
|
817
|
+
const cl = xs[parsed.col]
|
|
818
|
+
const ct = ys[parsed.row]
|
|
819
|
+
const cr = xs[parsed.col + 1]
|
|
820
|
+
const cb = ys[parsed.row + 1]
|
|
821
|
+
const b = ov.border
|
|
822
|
+
const drawSide = (style: any, x1: number, y1: number, x2: number, y2: number) => {
|
|
823
|
+
if (!style?.strokeStyle) return
|
|
824
|
+
ctx.save()
|
|
825
|
+
ctx.strokeStyle = style.strokeStyle
|
|
826
|
+
ctx.lineWidth = style.lineWidth ?? 1
|
|
827
|
+
ctx.beginPath()
|
|
828
|
+
ctx.moveTo(x1, y1); ctx.lineTo(x2, y2)
|
|
829
|
+
ctx.stroke()
|
|
830
|
+
ctx.restore()
|
|
831
|
+
}
|
|
832
|
+
drawSide(b.top, cl, ct, cr, ct)
|
|
833
|
+
drawSide(b.left, cl, ct, cl, cb)
|
|
834
|
+
drawSide(b.bottom, cl, cb, cr, cb)
|
|
835
|
+
drawSide(b.right, cr, ct, cr, cb)
|
|
836
|
+
}
|
|
697
837
|
|
|
698
|
-
|
|
699
|
-
|
|
838
|
+
// Selected bay 강조는 *rack-grid-cell 자체* 의 render 가 처리 (framework 의
|
|
839
|
+
// selection 시스템이 cell 의 outline 자동 그림). RackGrid 는 grid 골격만.
|
|
700
840
|
}
|
|
701
841
|
|
|
702
|
-
|
|
703
|
-
// 먼저 cells 위치의 행을 구한다.
|
|
704
|
-
let rows = [] as number[]
|
|
705
|
-
cells.forEach(cell => {
|
|
706
|
-
let row = this.getRowColumn(cell).row
|
|
707
|
-
if (-1 == rows.indexOf(row)) rows.push(row)
|
|
708
|
-
})
|
|
709
|
-
rows.sort((a, b) => {
|
|
710
|
-
return a - b
|
|
711
|
-
})
|
|
712
|
-
rows.reverse()
|
|
713
|
-
// 행 2개 이상은 추가 안함. 임시로 막아놓음
|
|
714
|
-
if (rows.length >= 2) return false
|
|
715
|
-
let insertionRowPosition = rows[rows.length - 1] + 1
|
|
716
|
-
let newbieRowHeights = [] as number[]
|
|
717
|
-
let newbieCells = [] as RackGridCell[]
|
|
718
|
-
rows.forEach(row => {
|
|
719
|
-
for (let i = 0; i < this.columns; i++)
|
|
720
|
-
newbieCells.push(buildCopiedCell(this.components[row * this.columns + i].model, this.app) as RackGridCell)
|
|
721
|
-
newbieRowHeights.push(this.heights[row])
|
|
722
|
-
|
|
723
|
-
newbieCells.reverse().forEach(cell => {
|
|
724
|
-
this.insertComponentAt(cell, insertionRowPosition * this.columns)
|
|
725
|
-
})
|
|
726
|
-
|
|
727
|
-
let heights = this.heights.slice()
|
|
728
|
-
heights.splice(insertionRowPosition, 0, ...newbieRowHeights)
|
|
729
|
-
this.set('heights', heights)
|
|
842
|
+
// ── Grid layout ─────────────────────────────────────────
|
|
730
843
|
|
|
731
|
-
|
|
844
|
+
get columns(): number {
|
|
845
|
+
return Math.max(1, Math.floor(this.state.columns ?? 5))
|
|
846
|
+
}
|
|
732
847
|
|
|
733
|
-
|
|
734
|
-
|
|
848
|
+
get rackRows(): number {
|
|
849
|
+
return Math.max(1, Math.floor(this.state.rows ?? 1))
|
|
735
850
|
}
|
|
736
851
|
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
cells.forEach(cell => {
|
|
741
|
-
let column = this.getRowColumn(cell).column
|
|
742
|
-
if (-1 == columns.indexOf(column)) columns.push(column)
|
|
743
|
-
})
|
|
744
|
-
columns.sort((a, b) => {
|
|
745
|
-
return a - b
|
|
746
|
-
})
|
|
747
|
-
columns.reverse()
|
|
748
|
-
// 열 2개 이상은 추가 안함. 임시로 막아놓음
|
|
749
|
-
if (columns.length >= 2) return false
|
|
750
|
-
let insertionColumnPosition = columns[0]
|
|
751
|
-
let newbieColumnWidths = [] as number[]
|
|
752
|
-
let newbieCells = [] as RackGridCell[]
|
|
753
|
-
columns.forEach(column => {
|
|
754
|
-
for (let i = 0; i < this.rows; i++)
|
|
755
|
-
newbieCells.push(buildCopiedCell(this.components[column + this.columns * i].model, this.app) as RackGridCell)
|
|
756
|
-
newbieColumnWidths.push(this.widths[column])
|
|
757
|
-
|
|
758
|
-
let increasedColumns = this.columns
|
|
759
|
-
let index = this.rows
|
|
760
|
-
newbieCells.reverse().forEach(cell => {
|
|
761
|
-
if (index == 0) {
|
|
762
|
-
index = this.rows
|
|
763
|
-
increasedColumns++
|
|
764
|
-
}
|
|
852
|
+
get shelves(): number {
|
|
853
|
+
return Math.max(1, Math.floor(this.state.shelves ?? 4))
|
|
854
|
+
}
|
|
765
855
|
|
|
766
|
-
|
|
767
|
-
this.insertComponentAt(cell, insertionColumnPosition + index * increasedColumns)
|
|
768
|
-
})
|
|
856
|
+
// ── cellId / posKey 변환 ────────────────────────────────
|
|
769
857
|
|
|
770
|
-
|
|
771
|
-
|
|
858
|
+
/** posKey = `${col-1}-${row-1}` (0-based, 2 segments). 1-based input. */
|
|
859
|
+
posKeyOf(col: number, row: number = 1): string {
|
|
860
|
+
return `${col - 1}-${row - 1}`
|
|
861
|
+
}
|
|
772
862
|
|
|
773
|
-
|
|
863
|
+
/** cellId = `${col-1}-${row-1}-${shelf-1}` (0-based, 3 segments). 1-based input. */
|
|
864
|
+
cellIdOf(col: number, row: number = 1, shelf: number = 1): string {
|
|
865
|
+
return `${col - 1}-${row - 1}-${shelf - 1}`
|
|
866
|
+
}
|
|
774
867
|
|
|
775
|
-
|
|
776
|
-
|
|
868
|
+
parseCellId(cellId: string): { col: number; row: number; shelf: number } | null {
|
|
869
|
+
const parts = cellId.split('-')
|
|
870
|
+
if (parts.length !== 3) return null
|
|
871
|
+
const [c, r, s] = parts.map(Number)
|
|
872
|
+
if (!Number.isFinite(c) || !Number.isFinite(r) || !Number.isFinite(s)) return null
|
|
873
|
+
return { col: c, row: r, shelf: s }
|
|
777
874
|
}
|
|
778
875
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
columns.sort((a, b) => {
|
|
787
|
-
return a - b
|
|
788
|
-
})
|
|
789
|
-
columns.reverse()
|
|
790
|
-
// 열 2개 이상은 추가 안함. 임시로 막아놓음
|
|
791
|
-
if (columns.length >= 2) return false
|
|
792
|
-
let insertionColumnPosition = columns[columns.length - 1] + 1
|
|
793
|
-
let newbieColumnWidths = [] as number[]
|
|
794
|
-
let newbieCells = [] as RackGridCell[]
|
|
795
|
-
columns.forEach(column => {
|
|
796
|
-
for (let i = 0; i < this.rows; i++)
|
|
797
|
-
newbieCells.push(buildCopiedCell(this.components[column + this.columns * i].model, this.app) as RackGridCell)
|
|
798
|
-
newbieColumnWidths.push(this.widths[column])
|
|
799
|
-
|
|
800
|
-
let increasedColumns = this.columns
|
|
801
|
-
let index = this.rows
|
|
802
|
-
newbieCells.reverse().forEach(cell => {
|
|
803
|
-
if (index == 0) {
|
|
804
|
-
index = this.rows
|
|
805
|
-
increasedColumns++
|
|
806
|
-
}
|
|
876
|
+
parsePosKey(posKey: string): { col: number; row: number } | null {
|
|
877
|
+
const parts = posKey.split('-')
|
|
878
|
+
if (parts.length !== 2) return null
|
|
879
|
+
const [c, r] = parts.map(Number)
|
|
880
|
+
if (!Number.isFinite(c) || !Number.isFinite(r)) return null
|
|
881
|
+
return { col: c, row: r }
|
|
882
|
+
}
|
|
807
883
|
|
|
808
|
-
|
|
809
|
-
this.insertComponentAt(cell, insertionColumnPosition + index * increasedColumns)
|
|
810
|
-
})
|
|
884
|
+
// ── cellOverrides accessor ─────────────────────────────
|
|
811
885
|
|
|
812
|
-
|
|
813
|
-
|
|
886
|
+
get cellOverrides(): { [posKey: string]: CellOverride } {
|
|
887
|
+
return (this.state.cellOverrides ?? {}) as { [posKey: string]: CellOverride }
|
|
888
|
+
}
|
|
814
889
|
|
|
815
|
-
|
|
890
|
+
getCellOverride(posKey: string): CellOverride | undefined {
|
|
891
|
+
return this.cellOverrides[posKey]
|
|
892
|
+
}
|
|
816
893
|
|
|
817
|
-
|
|
818
|
-
|
|
894
|
+
// ── Shelf labels ───────────────────────────────────────
|
|
895
|
+
|
|
896
|
+
/**
|
|
897
|
+
* Parse `state.shelfLocations` 를 levels 길이 array 로. 빈 슬롯은 1-based index default.
|
|
898
|
+
* '1,2,3' → ['1', '2', '3', ...]
|
|
899
|
+
* ',,,04' → ['1', '2', '3', '04']
|
|
900
|
+
* undefined → ['1', '2', '3', '4', ...]
|
|
901
|
+
*/
|
|
902
|
+
get shelfLabels(): string[] {
|
|
903
|
+
const input = this.state.shelfLocations
|
|
904
|
+
const levels = this.shelves
|
|
905
|
+
const parts = (input || '').split(',')
|
|
906
|
+
const out: string[] = []
|
|
907
|
+
for (let i = 0; i < levels; i++) {
|
|
908
|
+
const p = parts[i]
|
|
909
|
+
out[i] = p && p.trim().length > 0 ? p.trim() : String(i + 1)
|
|
910
|
+
}
|
|
911
|
+
return out
|
|
819
912
|
}
|
|
820
913
|
|
|
821
|
-
|
|
822
|
-
|
|
914
|
+
// ── Location 변환 (양방향) ──────────────────────────────
|
|
915
|
+
|
|
916
|
+
/**
|
|
917
|
+
* cellId → location string. 우선순위:
|
|
918
|
+
* 1. cell-component (state.section/unit) — source of truth
|
|
919
|
+
* 2. cellOverrides[posKey] — 호환성 fallback
|
|
920
|
+
* section/unit 둘 다 있고 isEmpty=false 일 때만 부여. 미부여 시 null.
|
|
921
|
+
*/
|
|
922
|
+
locationOf(cellId: string): string | null {
|
|
923
|
+
const parsed = this.parseCellId(cellId)
|
|
924
|
+
if (!parsed) return null
|
|
925
|
+
const posKey = `${parsed.col}-${parsed.row}`
|
|
926
|
+
|
|
927
|
+
let section: string | undefined
|
|
928
|
+
let unit: string | undefined
|
|
929
|
+
let isEmpty: boolean | undefined
|
|
930
|
+
let cellShelfLocations: string | undefined
|
|
931
|
+
|
|
932
|
+
const cell = this.cellAt(parsed.col, parsed.row)
|
|
933
|
+
if (cell) {
|
|
934
|
+
section = cell.state.section
|
|
935
|
+
unit = cell.state.unit
|
|
936
|
+
isEmpty = cell.state.isEmpty
|
|
937
|
+
cellShelfLocations = cell.state.shelfLocations
|
|
938
|
+
} else {
|
|
939
|
+
const override = this.cellOverrides[posKey]
|
|
940
|
+
section = override?.section
|
|
941
|
+
unit = override?.unit
|
|
942
|
+
isEmpty = override?.isEmpty
|
|
943
|
+
}
|
|
823
944
|
|
|
824
|
-
|
|
825
|
-
let rowcolumn = this.getRowColumn(cell)
|
|
945
|
+
if (!section || !unit || isEmpty) return null
|
|
826
946
|
|
|
827
|
-
|
|
828
|
-
|
|
947
|
+
const pattern = this.state.locPattern ?? '{z}{s}-{u}-{sh}'
|
|
948
|
+
const zone = this.state.zone ?? ''
|
|
949
|
+
const shelfLabel = this._shelfLabelsFromCell(cellShelfLocations)[parsed.shelf] ?? String(parsed.shelf + 1)
|
|
829
950
|
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
951
|
+
return pattern
|
|
952
|
+
.replace(/\{z\}/g, zone)
|
|
953
|
+
.replace(/\{s\}/g, section)
|
|
954
|
+
.replace(/\{u\}/g, unit)
|
|
955
|
+
.replace(/\{sh\}/g, shelfLabel)
|
|
956
|
+
}
|
|
833
957
|
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
958
|
+
/** cell.state.shelfLocations 명시 시 그것, 아니면 RackGrid 의 shelfLabels. */
|
|
959
|
+
private _shelfLabelsFromCell(cellShelfLocations: string | undefined): string[] {
|
|
960
|
+
const input = cellShelfLocations ?? this.state.shelfLocations
|
|
961
|
+
const levels = this.shelves
|
|
962
|
+
const parts = (input || '').split(',')
|
|
963
|
+
const out: string[] = []
|
|
964
|
+
for (let i = 0; i < levels; i++) {
|
|
965
|
+
const p = parts[i]
|
|
966
|
+
out[i] = p && p.trim().length > 0 ? p.trim() : String(i + 1)
|
|
967
|
+
}
|
|
968
|
+
return out
|
|
969
|
+
}
|
|
839
970
|
|
|
840
|
-
|
|
971
|
+
/** location string → cellId. sparse 역인덱스 lookup. 없으면 null. */
|
|
972
|
+
cellIdOfLocation(location: string): string | null {
|
|
973
|
+
return this._locationIndex.get(location) ?? null
|
|
841
974
|
}
|
|
842
975
|
|
|
843
|
-
|
|
844
|
-
|
|
976
|
+
/** 모든 (override 명시된) cell 의 location 목록. inspection 용. */
|
|
977
|
+
get allLocations(): Array<{ cellId: string; location: string }> {
|
|
978
|
+
const result: Array<{ cellId: string; location: string }> = []
|
|
979
|
+
for (const [location, cellId] of this._locationIndex.entries()) {
|
|
980
|
+
result.push({ cellId, location })
|
|
981
|
+
}
|
|
982
|
+
return result
|
|
983
|
+
}
|
|
845
984
|
|
|
846
|
-
|
|
847
|
-
|
|
985
|
+
// ── Location 역인덱스 (sparse cache) ────────────────────
|
|
986
|
+
|
|
987
|
+
private _locationIndexCache?: Map<string, string>
|
|
988
|
+
|
|
989
|
+
private get _locationIndex(): Map<string, string> {
|
|
990
|
+
if (this._locationIndexCache) return this._locationIndexCache
|
|
991
|
+
const map = new Map<string, string>()
|
|
992
|
+
const shelves = this.shelves
|
|
993
|
+
|
|
994
|
+
// 1. cell-component 들 (source of truth) — 모든 bay 순회
|
|
995
|
+
const cells = this.components as Component[] | undefined
|
|
996
|
+
if (cells && cells.length > 0) {
|
|
997
|
+
for (let i = 0; i < cells.length; i++) {
|
|
998
|
+
const cell: any = cells[i]
|
|
999
|
+
if (cell?.state?.type !== 'rack-grid-cell') continue
|
|
1000
|
+
const bayKey = cell.state.cellId
|
|
1001
|
+
if (!bayKey) continue
|
|
1002
|
+
const parsed = this.parsePosKey(bayKey)
|
|
1003
|
+
if (!parsed) continue
|
|
1004
|
+
for (let shelfIdx = 0; shelfIdx < shelves; shelfIdx++) {
|
|
1005
|
+
const cellId = `${parsed.col}-${parsed.row}-${shelfIdx}`
|
|
1006
|
+
const loc = this.locationOf(cellId)
|
|
1007
|
+
if (loc) map.set(loc, cellId)
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
848
1011
|
|
|
849
|
-
|
|
850
|
-
|
|
1012
|
+
// 2. cellOverrides fallback (cell-component 없는 시점 — 호환)
|
|
1013
|
+
const overrides = this.cellOverrides
|
|
1014
|
+
for (const posKey of Object.keys(overrides)) {
|
|
1015
|
+
const override = overrides[posKey]
|
|
1016
|
+
if (!override.section || !override.unit || override.isEmpty) continue
|
|
1017
|
+
const parsed = this.parsePosKey(posKey)
|
|
1018
|
+
if (!parsed) continue
|
|
1019
|
+
for (let shelfIdx = 0; shelfIdx < shelves; shelfIdx++) {
|
|
1020
|
+
const cellId = `${parsed.col}-${parsed.row}-${shelfIdx}`
|
|
1021
|
+
if (!map.has(this.locationOf(cellId) || '')) {
|
|
1022
|
+
const loc = this.locationOf(cellId)
|
|
1023
|
+
if (loc && !map.has(loc)) map.set(loc, cellId)
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
851
1027
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
1028
|
+
this._locationIndexCache = map
|
|
1029
|
+
return map
|
|
1030
|
+
}
|
|
855
1031
|
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
})
|
|
1032
|
+
/** 룰 / cellOverrides 변경 시 호출. things-scene 의 onchange* 가 자동 호출. */
|
|
1033
|
+
invalidateLocationIndex(): void {
|
|
1034
|
+
this._locationIndexCache = undefined
|
|
1035
|
+
}
|
|
861
1036
|
|
|
862
|
-
|
|
1037
|
+
onchangeCellOverrides(): void { this.invalidateLocationIndex() }
|
|
1038
|
+
onchangeLocPattern(): void { this.invalidateLocationIndex() }
|
|
1039
|
+
onchangeShelfLocations(): void { this.invalidateLocationIndex() }
|
|
1040
|
+
onchangeZone(): void { this.invalidateLocationIndex() }
|
|
1041
|
+
onchangeSectionDigits(): void { this.invalidateLocationIndex() }
|
|
1042
|
+
onchangeUnitDigits(): void { this.invalidateLocationIndex() }
|
|
1043
|
+
onchangeShelves(): void { this.invalidateLocationIndex() }
|
|
1044
|
+
|
|
1045
|
+
// ── 자식 lookup ────────────────────────────────────────
|
|
1046
|
+
|
|
1047
|
+
/**
|
|
1048
|
+
* (col, row) 위치의 자식 StorageRack. 자식이 StorageRack 이 아닌 경우 (Crane, AGV 등)
|
|
1049
|
+
* 는 null — Carrier 보관은 StorageRack 만.
|
|
1050
|
+
*
|
|
1051
|
+
* 자식의 grid 위치 식별: 자식의 state.column / state.row (1-based) 명시. RackGrid 가
|
|
1052
|
+
* 자식 추가 시 자동 할당 또는 사용자가 명시.
|
|
1053
|
+
*/
|
|
1054
|
+
private _childRackAt(posKey: string): StorageRack | null {
|
|
1055
|
+
const parsed = this.parsePosKey(posKey)
|
|
1056
|
+
if (!parsed) return null
|
|
1057
|
+
const children = (this.components as Component[] | undefined) ?? []
|
|
1058
|
+
for (const c of children) {
|
|
1059
|
+
const type = (c.state as any)?.type
|
|
1060
|
+
if (type !== 'storage-rack') continue
|
|
1061
|
+
const childCol = ((c.state as any)?.column ?? 1) - 1
|
|
1062
|
+
const childRow = ((c.state as any)?.row ?? 1) - 1
|
|
1063
|
+
if (childCol === parsed.col && childRow === parsed.row) {
|
|
1064
|
+
return c as unknown as StorageRack
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
return null
|
|
863
1068
|
}
|
|
864
1069
|
|
|
865
|
-
|
|
866
|
-
const { sectionDigits = 2, unitDigits = 2 } = this.state
|
|
867
|
-
const selectedCells = this.root.selected as RackGridCell[]
|
|
1070
|
+
// ── SlottedHolder 컨트랙 — 자식 StorageRack 으로 위임 ────
|
|
868
1071
|
|
|
869
|
-
|
|
1072
|
+
hasCarrierAt(slotId: string): boolean {
|
|
1073
|
+
const parsed = this.parseCellId(slotId)
|
|
1074
|
+
if (!parsed) return false
|
|
1075
|
+
const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
|
|
1076
|
+
return child?.hasCarrierAt(`0-0-${parsed.shelf}`) ?? false
|
|
870
1077
|
}
|
|
871
1078
|
|
|
872
|
-
|
|
873
|
-
|
|
1079
|
+
obtainCarrier(slotIdOrLocation: string): Component | null {
|
|
1080
|
+
const slotId = this._resolveToCellId(slotIdOrLocation)
|
|
1081
|
+
if (!slotId) return null
|
|
1082
|
+
const parsed = this.parseCellId(slotId)
|
|
1083
|
+
if (!parsed) return null
|
|
1084
|
+
const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
|
|
1085
|
+
return child?.obtainCarrier(`0-0-${parsed.shelf}`) ?? null
|
|
874
1086
|
}
|
|
875
1087
|
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1088
|
+
canReceiveAt(slotIdOrLocation: string, carrier?: Component): boolean {
|
|
1089
|
+
const slotId = this._resolveToCellId(slotIdOrLocation)
|
|
1090
|
+
if (!slotId) return false
|
|
1091
|
+
const parsed = this.parseCellId(slotId)
|
|
1092
|
+
if (!parsed) return false
|
|
1093
|
+
// isEmpty 위치 는 carrier 못 받음 (location-less, modeling 차원)
|
|
1094
|
+
const override = this.cellOverrides[`${parsed.col}-${parsed.row}`]
|
|
1095
|
+
if (override?.isEmpty) return false
|
|
1096
|
+
const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
|
|
1097
|
+
return child?.canReceiveAt(`0-0-${parsed.shelf}`, carrier) ?? false
|
|
880
1098
|
}
|
|
881
1099
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
1100
|
+
async receiveAt(slotIdOrLocation: string, carrier: Component, options?: any): Promise<void> {
|
|
1101
|
+
const slotId = this._resolveToCellId(slotIdOrLocation)
|
|
1102
|
+
if (!slotId) throw new Error(`RackGrid.receiveAt: cannot resolve "${slotIdOrLocation}"`)
|
|
1103
|
+
const parsed = this.parseCellId(slotId)
|
|
1104
|
+
if (!parsed) throw new Error(`RackGrid.receiveAt: invalid cellId "${slotId}"`)
|
|
1105
|
+
const child = this._childRackAt(`${parsed.col}-${parsed.row}`)
|
|
1106
|
+
if (!child) {
|
|
1107
|
+
throw new Error(`RackGrid.receiveAt: no StorageRack at posKey=${parsed.col}-${parsed.row}`)
|
|
1108
|
+
}
|
|
1109
|
+
return child.receiveAt(`0-0-${parsed.shelf}`, carrier, options)
|
|
886
1110
|
}
|
|
887
1111
|
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
})
|
|
1112
|
+
recordFromCarrier(carrier: Component, slotId: string): SlotRecord {
|
|
1113
|
+
const { id, refid, transform, ...rest } = ((carrier.state as any) || {}) as any
|
|
1114
|
+
return { ...rest, cellId: slotId }
|
|
892
1115
|
}
|
|
893
1116
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
1117
|
+
/**
|
|
1118
|
+
* Slot 의 attach object3d — Stock InstancedMesh 의 instance 와 *같은 world 위치* 에
|
|
1119
|
+
* 위치한 invisible Object3D. popup tether / Carriable.applyHolderAttachPoint 가 이
|
|
1120
|
+
* object3d 의 matrixWorld 를 사용. lazy 생성 + cache.
|
|
1121
|
+
*/
|
|
1122
|
+
private _attachAnchorByCell: Map<string, THREE.Object3D> = new Map()
|
|
1123
|
+
|
|
1124
|
+
getSlotAttachObject3d(slotId: string): THREE.Object3D | undefined {
|
|
1125
|
+
const parsed = this.parseCellId(slotId)
|
|
1126
|
+
if (!parsed) return undefined
|
|
1127
|
+
if (parsed.col < 0 || parsed.col >= this.columns) return undefined
|
|
1128
|
+
if (parsed.row < 0 || parsed.row >= this.rackRows) return undefined
|
|
1129
|
+
if (parsed.shelf < 0 || parsed.shelf >= this.shelves) return undefined
|
|
1130
|
+
|
|
1131
|
+
const ro: any = (this as any)._realObject
|
|
1132
|
+
if (!ro?.object3d) return undefined
|
|
1133
|
+
|
|
1134
|
+
let obj = this._attachAnchorByCell.get(slotId)
|
|
1135
|
+
if (!obj) {
|
|
1136
|
+
obj = new THREE.Object3D()
|
|
1137
|
+
obj.name = `rack-grid-anchor:${slotId}`
|
|
1138
|
+
ro.object3d.add(obj)
|
|
1139
|
+
this._attachAnchorByCell.set(slotId, obj)
|
|
1140
|
+
}
|
|
897
1141
|
|
|
898
|
-
|
|
899
|
-
|
|
1142
|
+
// 위치 갱신 — Stock InstancedMesh 의 instance 위치 와 동일 공식
|
|
1143
|
+
const rs: any = this.state
|
|
1144
|
+
const cols = this.columns
|
|
1145
|
+
const rows = this.rackRows
|
|
1146
|
+
const shelves = this.shelves
|
|
1147
|
+
const width = (rs?.width as number) ?? 400
|
|
1148
|
+
const height = (rs?.depth as number) ?? 2000
|
|
1149
|
+
const depth = (rs?.height as number) ?? 200
|
|
1150
|
+
const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
|
|
1151
|
+
const shelfZone = height - shelfBase
|
|
1152
|
+
const bayW = width / cols
|
|
1153
|
+
const bayD = depth / rows
|
|
1154
|
+
const cellY = shelfZone / shelves
|
|
1155
|
+
const baseY = -height / 2
|
|
1156
|
+
const shelfBaseY = baseY + shelfBase
|
|
1157
|
+
const stockD = cellY * 0.7
|
|
1158
|
+
|
|
1159
|
+
const cx = (parsed.col - cols / 2 + 0.5) * bayW
|
|
1160
|
+
const cellBottomY = shelfBaseY + parsed.shelf * cellY
|
|
1161
|
+
const cy = cellBottomY + stockD / 2
|
|
1162
|
+
const cz = (parsed.row - rows / 2 + 0.5) * bayD
|
|
1163
|
+
obj.position.set(cx, cy, cz)
|
|
1164
|
+
// *parent 의 matrixWorld 가 dirty 면 자식 matrixWorld 도 dirty* — 강제 갱신.
|
|
1165
|
+
ro.object3d.updateMatrixWorld(true)
|
|
1166
|
+
obj.updateMatrixWorld(true)
|
|
1167
|
+
return obj
|
|
900
1168
|
}
|
|
901
1169
|
|
|
902
|
-
|
|
903
|
-
|
|
1170
|
+
getSlotSize(slotId: string): { width: number; height: number; depth: number } | undefined {
|
|
1171
|
+
const parsed = this.parseCellId(slotId)
|
|
1172
|
+
if (!parsed) return undefined
|
|
1173
|
+
const rs: any = this.state
|
|
1174
|
+
const width = (rs?.width as number) ?? 400
|
|
1175
|
+
const height = (rs?.depth as number) ?? 2000
|
|
1176
|
+
const depth = (rs?.height as number) ?? 200
|
|
1177
|
+
const shelfBase = Math.max(0, Math.min((rs?.shelfBaseHeight as number) || 0, height * 0.9))
|
|
1178
|
+
const shelfZone = height - shelfBase
|
|
1179
|
+
const cellY = shelfZone / this.shelves
|
|
1180
|
+
return {
|
|
1181
|
+
width: (width / this.columns) * 0.85,
|
|
1182
|
+
height: (depth / this.rackRows) * 0.85, // 2D height = 3D Z 폭
|
|
1183
|
+
depth: cellY * 0.7 // 3D Y 폭 (= stockD)
|
|
1184
|
+
}
|
|
904
1185
|
}
|
|
905
1186
|
|
|
906
|
-
|
|
907
|
-
|
|
1187
|
+
cellCenter2D(slotId: string): { x: number; y: number } | null {
|
|
1188
|
+
const parsed = this.parseCellId(slotId)
|
|
1189
|
+
if (!parsed) return null
|
|
1190
|
+
const rs: any = this.state
|
|
1191
|
+
const left = (rs?.left as number) ?? 0
|
|
1192
|
+
const top = (rs?.top as number) ?? 0
|
|
1193
|
+
const width = (rs?.width as number) ?? 400
|
|
1194
|
+
const height = (rs?.height as number) ?? 200
|
|
1195
|
+
// table layout 의 widths/heights 비례 위치
|
|
1196
|
+
const widths = this.widths
|
|
1197
|
+
const heights = this.heights
|
|
1198
|
+
const wSum = this.widths_sum
|
|
1199
|
+
const hSum = this.heights_sum
|
|
1200
|
+
let cumW = 0
|
|
1201
|
+
for (let c = 0; c < parsed.col; c++) cumW += widths[c]
|
|
1202
|
+
let cumH = 0
|
|
1203
|
+
for (let r = 0; r < parsed.row; r++) cumH += heights[r]
|
|
1204
|
+
const x = left + (cumW + widths[parsed.col] / 2) / wSum * width
|
|
1205
|
+
const y = top + (cumH + heights[parsed.row] / 2) / hSum * height
|
|
1206
|
+
return { x, y }
|
|
908
1207
|
}
|
|
909
1208
|
|
|
910
|
-
|
|
911
|
-
const
|
|
912
|
-
|
|
1209
|
+
slotTargetAt(slotIdOrLocation: string): SlotTarget {
|
|
1210
|
+
const slotId = this._resolveToCellId(slotIdOrLocation)
|
|
1211
|
+
if (!slotId) throw new Error(`RackGrid.slotTargetAt: cannot resolve "${slotIdOrLocation}"`)
|
|
1212
|
+
return new SlotTarget(this, slotId)
|
|
913
1213
|
}
|
|
914
1214
|
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1215
|
+
// ── Helpers ──────────────────────────────────────────
|
|
1216
|
+
|
|
1217
|
+
/**
|
|
1218
|
+
* 외부 입력 → 내부 cellId 변환:
|
|
1219
|
+
* - "N-N-N" (3 segments, 모두 숫자) → cellId 그대로 (dual-accept)
|
|
1220
|
+
* - 그 외 → location 으로 간주, locationIndex lookup
|
|
1221
|
+
*/
|
|
1222
|
+
private _resolveToCellId(input: string): string | null {
|
|
1223
|
+
if (this._isCellIdFormat(input)) return input
|
|
1224
|
+
return this.cellIdOfLocation(input)
|
|
918
1225
|
}
|
|
919
1226
|
|
|
920
|
-
|
|
921
|
-
|
|
1227
|
+
private _isCellIdFormat(s: string): boolean {
|
|
1228
|
+
const parts = s.split('-')
|
|
1229
|
+
return parts.length === 3 && parts.every(p => /^\d+$/.test(p))
|
|
922
1230
|
}
|
|
923
1231
|
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
1232
|
+
// ── Bulk 편집 API ────────────────────────────────────────
|
|
1233
|
+
//
|
|
1234
|
+
// 모두 *single setState* 로 cellOverrides 갱신 → 단일 'change' 이벤트만 발사.
|
|
1235
|
+
// 매핑 cascade 회피 위해 silent 갱신 메서드 제공 (`_setCellOverridesSilently`).
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* 한 위치의 cellOverride 를 갱신 (merge). 기존 필드는 유지, 새 필드만 덮어씀.
|
|
1239
|
+
* `partial` 의 필드가 *undefined* 면 *그 필드 삭제* (override 키에서 제거).
|
|
1240
|
+
*/
|
|
1241
|
+
setCellOverride(posKey: string, partial: Partial<CellOverride>): void {
|
|
1242
|
+
const current = this.cellOverrides
|
|
1243
|
+
const existing = current[posKey] ?? {}
|
|
1244
|
+
const merged: any = { ...existing }
|
|
1245
|
+
for (const [k, v] of Object.entries(partial)) {
|
|
1246
|
+
if (v === undefined) delete merged[k]
|
|
1247
|
+
else merged[k] = v
|
|
1248
|
+
}
|
|
1249
|
+
const next = { ...current }
|
|
1250
|
+
if (Object.keys(merged).length === 0) delete next[posKey]
|
|
1251
|
+
else next[posKey] = merged
|
|
1252
|
+
this.setState({ cellOverrides: next })
|
|
1253
|
+
}
|
|
928
1254
|
|
|
929
|
-
|
|
930
|
-
|
|
1255
|
+
/** 한 위치의 override 전체 삭제. */
|
|
1256
|
+
clearCellOverride(posKey: string): void {
|
|
1257
|
+
const current = this.cellOverrides
|
|
1258
|
+
if (!(posKey in current)) return
|
|
1259
|
+
const next = { ...current }
|
|
1260
|
+
delete next[posKey]
|
|
1261
|
+
this.setState({ cellOverrides: next })
|
|
1262
|
+
}
|
|
931
1263
|
|
|
932
|
-
|
|
933
|
-
|
|
1264
|
+
/**
|
|
1265
|
+
* 여러 위치의 isEmpty 일괄 토글. cell-component 있으면 cell.set(), 없으면 cellOverrides.
|
|
1266
|
+
*/
|
|
1267
|
+
setIsEmpty(posKeys: string[], isEmpty: boolean): void {
|
|
1268
|
+
const hasCells = this.components.some((c: Component) => (c as any)?.state?.type === 'rack-grid-cell')
|
|
1269
|
+
if (hasCells) {
|
|
1270
|
+
for (const posKey of posKeys) {
|
|
1271
|
+
const cell = this.cellAtBayKey(posKey)
|
|
1272
|
+
cell?.set?.('isEmpty', isEmpty)
|
|
1273
|
+
}
|
|
1274
|
+
this.invalidateLocationIndex()
|
|
1275
|
+
return
|
|
1276
|
+
}
|
|
1277
|
+
// Fallback: cellOverrides
|
|
1278
|
+
const current = this.cellOverrides
|
|
1279
|
+
const next = { ...current }
|
|
1280
|
+
for (const posKey of posKeys) {
|
|
1281
|
+
const existing = next[posKey] ?? {}
|
|
1282
|
+
const merged: any = { ...existing, isEmpty }
|
|
1283
|
+
if (!isEmpty && !existing.section && !existing.unit && !existing.border && !existing.merged) {
|
|
1284
|
+
delete next[posKey]
|
|
1285
|
+
} else {
|
|
1286
|
+
next[posKey] = merged
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
this.setState({ cellOverrides: next })
|
|
1290
|
+
}
|
|
934
1291
|
|
|
935
|
-
|
|
1292
|
+
/**
|
|
1293
|
+
* 여러 위치의 border 스타일 일괄 설정. `where` ('top'|'left'|'bottom'|'right'|'all').
|
|
1294
|
+
* - style = null → 해당 side 삭제
|
|
1295
|
+
* - where = 'all' → 4면 모두 동일 style
|
|
1296
|
+
*/
|
|
1297
|
+
setBorder(posKeys: string[], style: any, where: 'all' | 'top' | 'left' | 'bottom' | 'right' = 'all'): void {
|
|
1298
|
+
const sides = where === 'all' ? ['top', 'left', 'bottom', 'right'] : [where]
|
|
1299
|
+
const current = this.cellOverrides
|
|
1300
|
+
const next = { ...current }
|
|
1301
|
+
for (const posKey of posKeys) {
|
|
1302
|
+
const existing = next[posKey] ?? {}
|
|
1303
|
+
const border = { ...(existing.border ?? {}) }
|
|
1304
|
+
for (const side of sides) {
|
|
1305
|
+
if (style == null) delete border[side]
|
|
1306
|
+
else border[side] = style
|
|
1307
|
+
}
|
|
1308
|
+
const merged: any = { ...existing, border }
|
|
1309
|
+
if (Object.keys(border).length === 0) delete merged.border
|
|
1310
|
+
next[posKey] = merged
|
|
1311
|
+
}
|
|
1312
|
+
this.setState({ cellOverrides: next })
|
|
1313
|
+
}
|
|
936
1314
|
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1315
|
+
/**
|
|
1316
|
+
* 자동 채번 — 선택된 cell 들에 section/unit 자동 부여.
|
|
1317
|
+
*
|
|
1318
|
+
* @param posKeys 채번 대상 (선택 영역). 빈 array 면 *전체 grid*.
|
|
1319
|
+
* @param direction 순회 방향:
|
|
1320
|
+
* 'cw' — 한 row 내 col 증가 → 다음 row 는 반대 방향 (boustrophedon)
|
|
1321
|
+
* 'ccw' — 반대 방향
|
|
1322
|
+
* 'zigzag' — col 우선 증가 (cw 의 90° 회전)
|
|
1323
|
+
* 'zigzag-reverse' — 반대
|
|
1324
|
+
* @param skipNumbering true 면 isEmpty cell 건너뜀 (단위 번호 안 증가).
|
|
1325
|
+
* @param startSection 첫 section 번호 (default 1)
|
|
1326
|
+
* @param startUnit 첫 unit 번호 (default 1)
|
|
1327
|
+
*
|
|
1328
|
+
* Aisle row (모든 cell isEmpty) 는 section 경계로 인식 — section 번호가 다음 row 에서 +1.
|
|
1329
|
+
*
|
|
1330
|
+
* setState 한 번 (cellOverrides 일괄 갱신) → location 역인덱스 1회 무효화.
|
|
1331
|
+
*/
|
|
1332
|
+
increaseLocation(
|
|
1333
|
+
posKeys: string[],
|
|
1334
|
+
direction: 'cw' | 'ccw' | 'zigzag' | 'zigzag-reverse' = 'cw',
|
|
1335
|
+
skipNumbering: boolean = false,
|
|
1336
|
+
startSection: number = 1,
|
|
1337
|
+
startUnit: number = 1
|
|
1338
|
+
): void {
|
|
1339
|
+
const sectionDigits = Math.max(1, Math.floor(this.state.sectionDigits ?? 2))
|
|
1340
|
+
const unitDigits = Math.max(1, Math.floor(this.state.unitDigits ?? 2))
|
|
1341
|
+
|
|
1342
|
+
// 대상 결정: posKeys 비었으면 전체 grid
|
|
1343
|
+
const targets: string[] = posKeys.length > 0
|
|
1344
|
+
? [...posKeys]
|
|
1345
|
+
: this._allPosKeys()
|
|
1346
|
+
|
|
1347
|
+
// (1) row 별 분류 (2D 배열, [row][col] = posKey)
|
|
1348
|
+
const byRow: (string | null)[][] = []
|
|
1349
|
+
let maxRow = -1
|
|
1350
|
+
let maxCol = -1
|
|
1351
|
+
for (const posKey of targets) {
|
|
1352
|
+
const parsed = this.parsePosKey(posKey)
|
|
1353
|
+
if (!parsed) continue
|
|
1354
|
+
if (!byRow[parsed.row]) byRow[parsed.row] = []
|
|
1355
|
+
byRow[parsed.row][parsed.col] = posKey
|
|
1356
|
+
if (parsed.row > maxRow) maxRow = parsed.row
|
|
1357
|
+
if (parsed.col > maxCol) maxCol = parsed.col
|
|
1358
|
+
}
|
|
945
1359
|
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
})
|
|
1360
|
+
// (2) aisle row 식별 — row 의 모든 (선택된) cell 이 isEmpty
|
|
1361
|
+
const overrides = this.cellOverrides
|
|
1362
|
+
const isAisleRow = (row: number): boolean => {
|
|
1363
|
+
const cells = byRow[row]
|
|
1364
|
+
if (!cells || cells.length === 0) return true
|
|
1365
|
+
return cells.every(p => p == null || overrides[p]?.isEmpty === true)
|
|
1366
|
+
}
|
|
954
1367
|
|
|
955
|
-
|
|
956
|
-
|
|
1368
|
+
// (3) section 별 묶기 (aisle 사이의 연속 row 들)
|
|
1369
|
+
const sections: number[][] = []
|
|
1370
|
+
let currentSection: number[] = []
|
|
1371
|
+
for (let r = 0; r <= maxRow; r++) {
|
|
1372
|
+
if (!byRow[r]) continue
|
|
1373
|
+
if (isAisleRow(r)) {
|
|
1374
|
+
if (currentSection.length > 0) {
|
|
1375
|
+
sections.push(currentSection)
|
|
1376
|
+
currentSection = []
|
|
1377
|
+
}
|
|
1378
|
+
} else {
|
|
1379
|
+
currentSection.push(r)
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
if (currentSection.length > 0) sections.push(currentSection)
|
|
1383
|
+
|
|
1384
|
+
// (4) 방향별 순회로 (section, unit) 부여
|
|
1385
|
+
const hasCells = this.components.some((c: Component) => (c as any)?.state?.type === 'rack-grid-cell')
|
|
1386
|
+
const next = { ...overrides }
|
|
1387
|
+
let sectionNum = Number(startSection) || 1
|
|
1388
|
+
|
|
1389
|
+
const setSU = (posKey: string, section: number, unit: number) => {
|
|
1390
|
+
const sStr = String(section).padStart(sectionDigits, '0')
|
|
1391
|
+
const uStr = String(unit).padStart(unitDigits, '0')
|
|
1392
|
+
if (hasCells) {
|
|
1393
|
+
const cell = this.cellAtBayKey(posKey)
|
|
1394
|
+
cell?.set?.('section', sStr)
|
|
1395
|
+
cell?.set?.('unit', uStr)
|
|
1396
|
+
} else {
|
|
1397
|
+
const existing = next[posKey] ?? {}
|
|
1398
|
+
next[posKey] = { ...existing, section: sStr, unit: uStr }
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
957
1401
|
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
1402
|
+
const clearSU = (posKey: string) => {
|
|
1403
|
+
if (hasCells) {
|
|
1404
|
+
const cell = this.cellAtBayKey(posKey)
|
|
1405
|
+
cell?.set?.('section', null)
|
|
1406
|
+
cell?.set?.('unit', null)
|
|
1407
|
+
} else {
|
|
1408
|
+
const existing = next[posKey]
|
|
1409
|
+
if (!existing) return
|
|
1410
|
+
const { section, unit, ...rest } = existing
|
|
1411
|
+
if (Object.keys(rest).length === 0) delete next[posKey]
|
|
1412
|
+
else next[posKey] = rest
|
|
1413
|
+
}
|
|
963
1414
|
}
|
|
964
1415
|
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1416
|
+
for (const sectionRows of sections) {
|
|
1417
|
+
for (const row of sectionRows) {
|
|
1418
|
+
const cells = byRow[row]
|
|
1419
|
+
if (!cells) continue
|
|
1420
|
+
// 방향별 cell 순회 순서 결정
|
|
1421
|
+
const orderedCols = this._orderCols(row, sectionRows, direction, maxCol)
|
|
1422
|
+
let unitNum = Number(startUnit) || 1
|
|
1423
|
+
for (const col of orderedCols) {
|
|
1424
|
+
const posKey = cells[col]
|
|
1425
|
+
if (!posKey) continue
|
|
1426
|
+
const isEmpty = overrides[posKey]?.isEmpty === true
|
|
1427
|
+
if (isEmpty) {
|
|
1428
|
+
clearSU(posKey)
|
|
1429
|
+
if (!skipNumbering) unitNum++
|
|
1430
|
+
} else {
|
|
1431
|
+
setSU(posKey, sectionNum, unitNum)
|
|
1432
|
+
unitNum++
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
// row 마다 section 증가? 아님 — section 은 *aisle 로 구분된 묶음 단위*.
|
|
1436
|
+
// 동일 section 내 다음 row 는 unit 번호 다시 startUnit 부터.
|
|
1437
|
+
// rack-table 의 원본 동작과 동일.
|
|
1438
|
+
}
|
|
1439
|
+
sectionNum++
|
|
972
1440
|
}
|
|
973
1441
|
|
|
974
|
-
|
|
1442
|
+
// cell-component 가 있으면 cell.set() 으로 이미 갱신됨 (setSU/clearSU 안에서).
|
|
1443
|
+
// 없으면 cellOverrides fallback 적용.
|
|
1444
|
+
if (!hasCells) {
|
|
1445
|
+
this.setState({ cellOverrides: next })
|
|
1446
|
+
} else {
|
|
1447
|
+
this.invalidateLocationIndex()
|
|
1448
|
+
}
|
|
975
1449
|
}
|
|
976
1450
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
1451
|
+
/**
|
|
1452
|
+
* 모든 grid 위치 (전체 columns × rows) 의 posKey 목록.
|
|
1453
|
+
*/
|
|
1454
|
+
private _allPosKeys(): string[] {
|
|
1455
|
+
const cols = this.columns
|
|
1456
|
+
const rows = this.rackRows
|
|
1457
|
+
const out: string[] = []
|
|
1458
|
+
for (let r = 0; r < rows; r++) {
|
|
1459
|
+
for (let c = 0; c < cols; c++) {
|
|
1460
|
+
out.push(`${c}-${r}`)
|
|
983
1461
|
}
|
|
984
1462
|
}
|
|
1463
|
+
return out
|
|
985
1464
|
}
|
|
986
1465
|
|
|
987
|
-
|
|
988
|
-
|
|
1466
|
+
/**
|
|
1467
|
+
* Row 의 col 순회 순서 결정. boustrophedon (S 자형) 패턴 지원.
|
|
1468
|
+
* cw : 짝수 row 는 좌→우, 홀수 row 는 우→좌
|
|
1469
|
+
* ccw : 짝수 row 는 우→좌, 홀수 row 는 좌→우
|
|
1470
|
+
* zigzag : 모든 row 좌→우 (단순)
|
|
1471
|
+
* zigzag-reverse : 모든 row 우→좌
|
|
1472
|
+
*/
|
|
1473
|
+
private _orderCols(
|
|
1474
|
+
row: number,
|
|
1475
|
+
sectionRows: number[],
|
|
1476
|
+
direction: 'cw' | 'ccw' | 'zigzag' | 'zigzag-reverse',
|
|
1477
|
+
maxCol: number
|
|
1478
|
+
): number[] {
|
|
1479
|
+
// section 내 row 의 *상대 인덱스* — section 경계에서 패턴이 리셋되도록.
|
|
1480
|
+
const idxInSection = sectionRows.indexOf(row)
|
|
1481
|
+
const cols: number[] = []
|
|
1482
|
+
for (let c = 0; c <= maxCol; c++) cols.push(c)
|
|
1483
|
+
|
|
1484
|
+
switch (direction) {
|
|
1485
|
+
case 'cw':
|
|
1486
|
+
return idxInSection % 2 === 0 ? cols : cols.slice().reverse()
|
|
1487
|
+
case 'ccw':
|
|
1488
|
+
return idxInSection % 2 === 0 ? cols.slice().reverse() : cols
|
|
1489
|
+
case 'zigzag':
|
|
1490
|
+
return cols
|
|
1491
|
+
case 'zigzag-reverse':
|
|
1492
|
+
return cols.slice().reverse()
|
|
1493
|
+
}
|
|
989
1494
|
}
|
|
990
1495
|
}
|
|
991
|
-
|
|
992
|
-
;['rows', 'columns', 'widths', 'heights', 'widths_sum', 'heights_sum', 'controls'].forEach(getter =>
|
|
993
|
-
Component.memoize(RackGrid.prototype, getter, false)
|
|
994
|
-
)
|