@operato/scene-storage 10.0.0-beta.50 → 10.0.0-beta.54

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/src/box.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'
14
14
  import {
15
15
  Carriable,
16
+ Identifiable,
16
17
  Legendable,
17
18
  Placeable,
18
19
  type Alignment,
@@ -65,6 +66,12 @@ const NATURE: ComponentNature = {
65
66
  { display: 'Plastic', value: 'plastic' }
66
67
  ]
67
68
  }
69
+ },
70
+ {
71
+ type: 'textarea',
72
+ label: 'data (JSON)',
73
+ name: 'data',
74
+ placeholder: '{ "sku": "P-1234", "dest": "zone-A", "priority": 1 }'
68
75
  }
69
76
  ],
70
77
  help: 'scene/component/box'
@@ -81,7 +88,7 @@ const NATURE: ComponentNature = {
81
88
  * scene-tree). If a future use case needs nested boxes, extend Container.
82
89
  */
83
90
  @sceneComponent('box')
84
- export default class Box extends Carriable(Legendable(Placeable(RectPath(Shape)))) {
91
+ export default class Box extends Identifiable(Carriable(Legendable(Placeable(RectPath(Shape))))) {
85
92
  declare state: BoxState
86
93
 
87
94
  static legends: Record<string, LegendBinding> = {
package/src/pallet.ts CHANGED
@@ -15,6 +15,7 @@ import {
15
15
  import type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'
16
16
  import {
17
17
  Carriable,
18
+ Identifiable,
18
19
  Legendable,
19
20
  Placeable,
20
21
  type Alignment,
@@ -122,7 +123,7 @@ const NATURE: ComponentNature = {
122
123
  * detection.
123
124
  */
124
125
  @sceneComponent('pallet')
125
- export default class Pallet extends Carriable(Legendable(Placeable(ContainerAbstract))) {
126
+ export default class Pallet extends Identifiable(Carriable(Legendable(Placeable(ContainerAbstract)))) {
126
127
  declare state: PalletState
127
128
 
128
129
  static legends: Record<string, LegendBinding> = {
package/src/parcel.ts CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  import type { State, Material3D, PickupFrame, PoseSerialized } from '@hatiolab/things-scene'
14
14
  import {
15
15
  Carriable,
16
+ Identifiable,
16
17
  Placeable,
17
18
  type Alignment,
18
19
  type PlacementArchetype
@@ -38,6 +39,12 @@ const NATURE: ComponentNature = {
38
39
  type: 'string',
39
40
  label: 'tracking-id',
40
41
  name: 'trackingId'
42
+ },
43
+ {
44
+ type: 'textarea',
45
+ label: 'data (JSON)',
46
+ name: 'data',
47
+ placeholder: '{ "sku": "P-1234", "dest": "chute-A" }'
41
48
  }
42
49
  ],
43
50
  help: 'scene/component/parcel'
@@ -62,7 +69,7 @@ const NATURE: ComponentNature = {
62
69
  * inspected indicators would add a status legend then.
63
70
  */
64
71
  @sceneComponent('parcel')
65
- export default class Parcel extends Carriable(Placeable(RectPath(Shape))) {
72
+ export default class Parcel extends Identifiable(Carriable(Placeable(RectPath(Shape)))) {
66
73
  declare state: ParcelState
67
74
 
68
75
  static placement: PlacementArchetype = 'operation'
@@ -14,11 +14,12 @@ import type { State, Material3D } from '@hatiolab/things-scene'
14
14
  import {
15
15
  CarrierHolder,
16
16
  Placeable,
17
- SlotTarget,
17
+ SingleSlotHolder,
18
18
  type AttachFrame,
19
19
  type Alignment,
20
20
  type Heights,
21
- type PlacementArchetype
21
+ type PlacementArchetype,
22
+ type ProcessStep
22
23
  } from '@operato/scene-base'
23
24
 
24
25
  import { PickingStation3D } from './picking-station-3d.js'
@@ -52,7 +53,10 @@ const NATURE: ComponentNature = {
52
53
  }
53
54
 
54
55
  @sceneComponent('picking-station')
55
- export default class PickingStation extends CarrierHolder(Placeable(ContainerAbstract)) {
56
+ export default class PickingStation
57
+ extends SingleSlotHolder()(CarrierHolder(Placeable(ContainerAbstract)))
58
+ implements ProcessStep
59
+ {
56
60
  declare state: PickingStationState
57
61
  declare _realObject?: PickingStation3D
58
62
 
@@ -63,57 +67,30 @@ export default class PickingStation extends CarrierHolder(Placeable(ContainerAbs
63
67
  get nature(): ComponentNature { return NATURE }
64
68
  get anchors() { return [] }
65
69
 
66
- // ── SlottedHolder duck-type (단일 slot 'station') ─────────────
67
- slotIds(): ReadonlyArray<string> { return [SLOT_ID] }
68
-
69
- hasCarrierAt(slotId: string): boolean {
70
- return slotId === SLOT_ID &&
71
- ((this as any).components ?? []).some((c: any) => c?.isCarriable)
72
- }
73
- canReceiveAt(slotId: string, _carrier?: Component): boolean {
74
- return slotId === SLOT_ID && !this.hasCarrierAt(slotId)
75
- }
76
- occupiedSlotIds(): ReadonlyArray<string> {
77
- return this.hasCarrierAt(SLOT_ID) ? [SLOT_ID] : []
78
- }
79
- emptySlotIds(): ReadonlyArray<string> {
80
- return this.hasCarrierAt(SLOT_ID) ? [] : [SLOT_ID]
81
- }
82
- obtainCarrier(slotId: string): Component | null {
83
- if (slotId !== SLOT_ID) return null
84
- return ((this as any).components ?? []).find((c: any) => c?.isCarriable) ?? null
85
- }
70
+ // SingleSlotHolder hook overrides ───────────────────────────────────────────
71
+ _singleSlotId() { return SLOT_ID }
86
72
 
87
73
  /**
88
- * carrier 도착 components + 3D reparent (Spot 패턴). processingTimeMs > 0
89
- * 이면 status='processing' 으로 전환했다가 timer 'idle' 복귀.
74
+ * SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds
75
+ * / emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt
76
+ * / getSlotAttachObject3d) — SingleSlotHolder mixin 제공.
90
77
  *
91
- * 단순 setTimeout frameClock 통합(view-mode/일시정지 동기) 추후.
78
+ * receiveAt 부가 dwell 동작은 `_onCarrierReceived` hook 으로 위임.
92
79
  */
93
- async receiveAt(_slotId: string, carrier: Component, options?: any): Promise<void> {
94
- ;(this as any).reparent?.(carrier, { ...(options ?? {}), animated: false })
80
+ _onCarrierReceived(_carrier: Component, _options?: any) {
95
81
  const procMs = this.state.processingTimeMs ?? 0
96
- if (procMs > 0) {
97
- this.setState({ status: 'processing' as PickingStationStatus })
98
- setTimeout(() => {
99
- if ((this.state.status as PickingStationStatus) === 'processing') {
100
- this.setState({ status: 'idle' as PickingStationStatus })
101
- }
102
- }, procMs)
103
- }
104
- }
105
-
106
- async accept(carrier: Component, options?: any): Promise<void> {
107
- return this.receiveAt(SLOT_ID, carrier, options)
108
- }
109
- async receive(carrier: Component, options?: any): Promise<void> {
110
- return this.receiveAt(SLOT_ID, carrier, options)
111
- }
112
-
113
- slotTargetAt(slotId: string): SlotTarget { return new SlotTarget(this as any, slotId) }
114
- getSlotAttachObject3d(_slotId: string): any {
115
- return (this as any)._realObject?.getAttachFrame?.()
116
- }
82
+ if (procMs <= 0) return
83
+ this.setState({ status: 'processing' as PickingStationStatus })
84
+ setTimeout(() => {
85
+ if ((this.state.status as PickingStationStatus) === 'processing') {
86
+ this.setState({ status: 'idle' as PickingStationStatus })
87
+ }
88
+ }, procMs)
89
+ }
90
+
91
+ // Phase ZT-V PR-6 ProcessStep ─────────────────────────────────────────────
92
+ readonly isProcessStep = true as const
93
+ get processingTimeMs() { return this.state.processingTimeMs ?? 0 }
117
94
 
118
95
  /** carrier 를 작업대(table) 상단에 안착. */
119
96
  attachPointFor(carrier: Component): AttachFrame | null {
package/src/rack-grid.ts CHANGED
@@ -38,6 +38,7 @@ import * as THREE from 'three'
38
38
  import {
39
39
  CarrierHolder,
40
40
  Placeable,
41
+ RecordStorage,
41
42
  SlotTarget,
42
43
  componentBoundingBox,
43
44
  type AttachFrame,
@@ -268,11 +269,25 @@ export interface RackGridState extends State {
268
269
 
269
270
  @sceneComponent('rack-grid')
270
271
  export default class RackGrid
271
- extends CarrierHolder(Placeable(ContainerAbstract))
272
+ extends RecordStorage<{ cellId: string; [key: string]: any }>()(
273
+ CarrierHolder(Placeable(ContainerAbstract))
274
+ )
272
275
  implements SlottedHolder
273
276
  {
274
277
  declare state: RackGridState
275
278
 
279
+ // RecordStorage mixin hook overrides.
280
+ // record.cellId 가 slotId (3-segment '{col}-{row}-{shelf}' format).
281
+ _recordToSlotId(record: { cellId: string }): string {
282
+ return record.cellId ?? ''
283
+ }
284
+ // 3D rebuild — rebuildStockMesh 우선, 없으면 mixin default.
285
+ _rebuildVisual(): void {
286
+ const ro = (this as any)._realObject
287
+ if (ro?.rebuildStockMesh) ro.rebuildStockMesh()
288
+ else if (ro?.update) ro.update()
289
+ }
290
+
276
291
  // Phase Auto-Nav (AN-PR-2) — Obstacle 자격. AMR / mover 의 자동 path planning
277
292
  // 시 회피 대상. `state.isObstacle` 명시 false 시 override (예외 처리).
278
293
  get isObstacle(): boolean {
@@ -417,63 +432,8 @@ export default class RackGrid
417
432
  ;(this._realObject as any)?.rebuildStockMesh?.()
418
433
  }
419
434
 
420
- /** state.data records Plan A stock 보관소. */
421
- get records(): Array<{ cellId: string; [key: string]: any }> {
422
- return (this.state.data as any) ?? []
423
- }
435
+ // records / legendTarget / _onLegendChanged / resolveLegendColor RecordStorage mixin 제공.
424
436
 
425
- // ── Legend integration ──────────────────────────────────
426
-
427
- private _legendTarget?: Component
428
-
429
- /**
430
- * Legend 컴포넌트 lookup. 우선순위:
431
- * 1) state.legendTarget id 명시
432
- * 2) scene 전체 의 type='legend' 첫 번째 컴포넌트 (자동 발견)
433
- */
434
- get legendTarget(): Component | undefined {
435
- if (this._legendTarget) return this._legendTarget
436
-
437
- const id = this.state.legendTarget
438
- if (id) {
439
- const found = (this.root as any)?.findById?.(id) as Component | undefined
440
- if (found) {
441
- this._legendTarget = found
442
- ;(found as any).on?.('change', this._onLegendChanged, this)
443
- return found
444
- }
445
- }
446
-
447
- const visit = (node: any): Component | undefined => {
448
- if (!node) return undefined
449
- if (node.state?.type === 'legend') return node as Component
450
- const children = node.components as Component[] | undefined
451
- if (children) {
452
- for (const c of children) {
453
- const r = visit(c)
454
- if (r) return r
455
- }
456
- }
457
- return undefined
458
- }
459
- const found = visit(this.root)
460
- if (found) {
461
- this._legendTarget = found
462
- ;(found as any).on?.('change', this._onLegendChanged, this)
463
- }
464
- return found
465
- }
466
-
467
- private _onLegendChanged = (): void => {
468
- ;(this._realObject as any)?.rebuildStockMesh?.()
469
- }
470
-
471
- /**
472
- * record 의 legend.field 값을 ranges 와 매칭해 색상 해석.
473
- * - `range.value === recordValue` (카테고리 일치)
474
- * - `range.min ≤ Number(v) < range.max` (수치 범위)
475
- * - 매칭 없으면 `defaultColor` 또는 undefined
476
- */
477
437
  // ── View-mode click → rack-grid-cell-click event + popup ──
478
438
 
479
439
  get eventMap() {
@@ -516,24 +476,13 @@ export default class RackGrid
516
476
 
517
477
  const payload = { cellId, record, hitPoint: hit.point, instanceId, isStock }
518
478
  this.trigger('rack-grid-cell-click', payload)
519
- this._invokePopup(cellId, record)
479
+ if (cellId) this._invokePopup(cellId, record ?? { cellId })
520
480
 
521
481
  // popup 외부 click 으로 인식되어 자동 close 되는 회귀 차단
522
482
  mouseEvent.stopPropagation?.()
523
483
  }
524
484
 
525
- /** state.popupRef Popup 컴포넌트 invoke. anchor = 클릭된 cell 의 SlotTarget. */
526
- private _invokePopup(cellId: string | undefined, record: any): void {
527
- const popupRefId = this.state.popupRef
528
- if (!popupRefId || !cellId) return
529
- const popupComp: any = (this.root as any)?.findById?.(popupRefId)
530
- if (!popupComp || typeof popupComp.openPopup !== 'function') {
531
- console.warn(`[rack-grid] popupRef="${popupRefId}" 가 가리키는 컴포넌트 없거나 openPopup 미지원`)
532
- return
533
- }
534
- const anchor = this.slotTargetAt(cellId)
535
- popupComp.openPopup(record ?? { cellId }, { anchor })
536
- }
485
+ // _invokePopup RecordStorage mixin 제공 (signature 동일: slotId=cellId, payload=record).
537
486
 
538
487
  /** raycast → 우리 RackGrid 의 어떤 mesh 가 closest hit 인지. */
539
488
  private _raycastHit(mouseEvent: MouseEvent): THREE.Intersection | undefined {
@@ -612,35 +561,7 @@ export default class RackGrid
612
561
  return `${col}-${row}-${shelf}`
613
562
  }
614
563
 
615
- resolveLegendColor(record: any): string | undefined {
616
- const legend = this.legendTarget
617
- if (!legend) return undefined
618
- const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
619
- if (!status) return undefined
620
-
621
- const field = status.field as string | undefined
622
- const ranges = status.ranges as any[] | undefined
623
- if (!field || !Array.isArray(ranges)) return undefined
624
-
625
- const value = record?.[field]
626
- if (value === undefined || value === null) return status.defaultColor
627
-
628
- for (const range of ranges) {
629
- if (!range) continue
630
- if (range.value !== undefined) {
631
- if (range.value === value) return range.color
632
- continue
633
- }
634
- const num = Number(value)
635
- if (!Number.isFinite(num)) continue
636
- const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
637
- const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
638
- const minOk = min === undefined || num >= min
639
- const maxOk = max === undefined || num < max
640
- if (minOk && maxOk) return range.color
641
- }
642
- return status.defaultColor as string | undefined
643
- }
564
+ // resolveLegendColor RecordStorage mixin 제공.
644
565
 
645
566
  /**
646
567
  * 새 (rows × columns) 에 맞춰 children 재구성. rack-table.buildCells 정확 클론.
@@ -1251,7 +1172,7 @@ export default class RackGrid
1251
1172
  const cellId = this._resolveToCellId(slotIdOrLocation)
1252
1173
  if (!cellId) return false
1253
1174
  if (this._carrierChildAt(cellId)) return true
1254
- return this.records.some(r => r.cellId === cellId)
1175
+ return this.records.some((r: any) => r.cellId === cellId)
1255
1176
  }
1256
1177
 
1257
1178
  /**
package/src/spot.ts CHANGED
@@ -34,7 +34,7 @@ import type { State, Material3D } from '@hatiolab/things-scene'
34
34
  import {
35
35
  CarrierHolder,
36
36
  Placeable,
37
- SlotTarget,
37
+ SingleSlotHolder,
38
38
  type AttachFrame,
39
39
  type Alignment,
40
40
  type Heights,
@@ -69,7 +69,11 @@ const NATURE: ComponentNature = {
69
69
  // which forces `isHTMLElement(): true` and trips the 3D pipeline's
70
70
  // addObject DOM-skip gate. Spot is purely 3D.
71
71
  @sceneComponent('spot')
72
- export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
72
+ export default class Spot extends SingleSlotHolder()(
73
+ CarrierHolder(Placeable(ContainerAbstract))
74
+ ) {
75
+ // SingleSlotHolder hook override — Spot 의 slot id 'pad'.
76
+ _singleSlotId() { return SPOT_SLOT_ID }
73
77
  declare state: SpotState
74
78
  declare _realObject?: Spot3D
75
79
 
@@ -169,63 +173,11 @@ export default class Spot extends CarrierHolder(Placeable(ContainerAbstract)) {
169
173
  }
170
174
  }
171
175
 
172
- // ── SlottedHolder 자격 (single virtual slot) ───────────────────────────────
173
- // Spot CarrierHolder(child attach + attachPointFor 상판 위)라서 물리/시각은
174
- // 완벽하다. 다만 transfer planner 의 getHoldersInScope(=isSlottedHolder duck-type)에
175
- // 잡히고, multi-hop handoff 에서 다음 moverobtainCarrier carrier 를 승계하려면
176
- // SlottedHolder duck-type 갖춰야 한다. carrier child그대로 유지(dispose
177
- // 안 함) — virtual location 의 handoff buffer 동작.
178
- slotIds(): string[] {
179
- return [SPOT_SLOT_ID]
180
- }
181
- hasCarrierAt(slotId: string): boolean {
182
- return slotId === SPOT_SLOT_ID &&
183
- ((this as any).components ?? []).some((c: any) => c?.isCarriable)
184
- }
185
- canReceiveAt(slotId: string, _carrier?: Component): boolean {
186
- return slotId === SPOT_SLOT_ID && !this.hasCarrierAt(slotId)
187
- }
188
- occupiedSlotIds(): ReadonlyArray<string> {
189
- return this.hasCarrierAt(SPOT_SLOT_ID) ? [SPOT_SLOT_ID] : []
190
- }
191
- emptySlotIds(): ReadonlyArray<string> {
192
- return this.hasCarrierAt(SPOT_SLOT_ID) ? [] : [SPOT_SLOT_ID]
193
- }
194
- obtainCarrier(slotId: string): Component | null {
195
- if (slotId !== SPOT_SLOT_ID) return null
196
- return ((this as any).components ?? []).find((c: any) => c?.isCarriable) ?? null
197
- }
198
- async receiveAt(_slotId: string, carrier: Component, options?: any): Promise<void> {
199
- // Mover.place 의 dispatch → Transfer.handoff → SlotTarget.receive → 여기로 위임.
200
- // carrier 를 논리 components + 3D 로 reparent 해야 다음 mover(crane 등)가
201
- // obtainCarrier(components 조회)로 집어간다. dispose 하지 않고 상판에 유지
202
- // (virtual location handoff buffer).
203
- ;(this as any).reparent?.(carrier, { ...(options ?? {}), animated: false })
204
- }
205
- getSlotAttachObject3d(_slotId: string): any {
206
- return (this as any)._realObject?.getAttachFrame?.()
207
- }
208
- slotTargetAt(slotId: string): SlotTarget {
209
- return new SlotTarget(this as any, slotId)
210
- }
211
-
212
- /**
213
- * Mover.place 의 Case A(dispatch → holder.receive)에서 호출. 기본 receive 는
214
- * carrier 를 3D(object3d)로만 attach 해서 논리 tree(components)엔 안 들어간다 →
215
- * 다음 mover 가 obtainCarrier(components 조회)로 못 찾아 'no-carrier'. reparent
216
- * (CarrierHolder.reparent: addComponent[논리] + attach[3D])로 위임해 carrier 가
217
- * components 에 들어가도록 한다 — handoff 의 carrier 승계가 동작.
218
- */
219
- // Transfer 의 handoff 는 `target.accept ?? target.receive` 순으로 호출한다 —
220
- // accept 가 우선. Spot 은 carrier 를 논리 components + 3D 로 reparent 해야
221
- // obtainCarrier 가 찾는다. accept/receive 둘 다 reparent 로 위임.
222
- // 직접 dispatch(SlotTarget 미경유) 경로 대비 — accept/receive 도 reparent 로 위임.
223
- async accept(carrier: Component, options?: any): Promise<void> {
224
- ;(this as any).reparent?.(carrier, { ...(options ?? {}), animated: false })
225
- }
226
- async receive(carrier: Component, options?: any): Promise<void> {
227
- ;(this as any).reparent?.(carrier, { ...(options ?? {}), animated: false })
228
- }
176
+ // SlottedHolder duck (slotIds / hasCarrierAt / canReceiveAt / occupiedSlotIds /
177
+ // emptySlotIds / obtainCarrier / receiveAt / accept / receive / slotTargetAt /
178
+ // getSlotAttachObject3d) SingleSlotHolder mixin 제공.
179
+ // virtual location handoff buffer 동작: receiveAtreparent (components +
180
+ // 3D) carrier 자식으로 유지 다음 mover 가 obtainCarrier 승계.
229
181
  }
230
182
 
231
183
  /** Spot 의 유일 virtual slot id. */
@@ -22,6 +22,7 @@ import type { State, Material3D } from '@hatiolab/things-scene'
22
22
  import {
23
23
  CarrierHolder,
24
24
  Placeable,
25
+ RecordStorage,
25
26
  SlotTarget,
26
27
  type AttachFrame,
27
28
  type Alignment,
@@ -96,7 +97,9 @@ const NATURE: ComponentNature = {
96
97
  }
97
98
 
98
99
  @sceneComponent('stockpile-grid')
99
- export default class StockpileGrid extends CarrierHolder(Placeable(ContainerAbstract)) {
100
+ export default class StockpileGrid extends RecordStorage<StockpileGridCell>()(
101
+ CarrierHolder(Placeable(ContainerAbstract))
102
+ ) {
100
103
  declare state: StockpileGridState
101
104
  declare _realObject?: StockpileGrid3D
102
105
 
@@ -107,6 +110,14 @@ export default class StockpileGrid extends CarrierHolder(Placeable(ContainerAbst
107
110
  get nature(): ComponentNature { return NATURE }
108
111
  get anchors() { return [] }
109
112
 
113
+ // RecordStorage mixin 의 records / inventoryCount / addRecord / removeRecord 는
114
+ // cell-aware 시맨틱과 호환 안 됨. cell-nested data 라 *_recordsOf(col,row)_* 와
115
+ // _setCellRecords 를 사용. legend / popup / onLegendChanged 는 mixin 사용.
116
+ // _rebuildVisual hook 만 override.
117
+ _rebuildVisual(): void {
118
+ this._realObject?.update?.()
119
+ }
120
+
110
121
  // ── grid 좌표 helpers ─────────────────────────────────────
111
122
  get cols(): number { return Math.max(1, Math.floor(this.state.cols ?? 3)) }
112
123
  get rows(): number { return Math.max(1, Math.floor(this.state.rows ?? 3)) }
@@ -269,63 +280,9 @@ export default class StockpileGrid extends CarrierHolder(Placeable(ContainerAbst
269
280
  return carrier
270
281
  }
271
282
 
272
- // ── Legend ─────────────────────────────────────────────────
273
- private _legendTarget?: Component
274
- get legendTarget(): Component | undefined {
275
- if (this._legendTarget) return this._legendTarget
276
- const id = this.state.legendTarget
277
- if (id) {
278
- const found = ((this as any).root)?.findById?.(id) as Component | undefined
279
- if (found) {
280
- this._legendTarget = found
281
- ;(found as any).on?.('change', this._onLegendChanged, this)
282
- return found
283
- }
284
- }
285
- const visit = (node: any): Component | undefined => {
286
- if (!node) return undefined
287
- if (node.state?.type === 'legend') return node as Component
288
- const children = node.components as Component[] | undefined
289
- if (children) for (const c of children) { const r = visit(c); if (r) return r }
290
- return undefined
291
- }
292
- const found = visit((this as any).root)
293
- if (found) {
294
- this._legendTarget = found
295
- ;(found as any).on?.('change', this._onLegendChanged, this)
296
- }
297
- return found
298
- }
299
- private _onLegendChanged = (): void => { ;(this._realObject as any)?.update?.() }
300
-
301
- resolveLegendColor(record: any): string | undefined {
302
- const legend = this.legendTarget
303
- if (!legend) return undefined
304
- const status: any = (legend as any).getState?.('status') ?? (legend.state as any)?.status
305
- if (!status) return undefined
306
- const field = status.field as string | undefined
307
- const ranges = status.ranges as any[] | undefined
308
- if (!field || !Array.isArray(ranges)) return undefined
309
- const value = record?.[field]
310
- if (value === undefined || value === null) return status.defaultColor
311
- for (const range of ranges) {
312
- if (!range) continue
313
- if (range.value !== undefined) {
314
- if (range.value === value) return range.color
315
- continue
316
- }
317
- const num = Number(value)
318
- if (!Number.isFinite(num)) continue
319
- const min = range.min !== undefined && range.min !== '' ? Number(range.min) : undefined
320
- const max = range.max !== undefined && range.max !== '' ? Number(range.max) : undefined
321
- const minOk = min === undefined || num >= min
322
- const maxOk = max === undefined || num < max
323
- if (minOk && maxOk) return range.color
324
- }
325
- return status.defaultColor as string | undefined
326
- }
283
+ // legendTarget / _onLegendChanged / resolveLegendColor — RecordStorage mixin 제공.
327
284
 
328
- // ── Popup + click ─────────────────────────────────────────────
285
+ // ── Popup + click — mixin 의 _invokePopup 활용, cell payload 만 wrapper ──
329
286
  get eventMap() {
330
287
  return { '(self)': { '(self)': { click: this._onGridClick } } }
331
288
  }
@@ -334,28 +291,23 @@ export default class StockpileGrid extends CarrierHolder(Placeable(ContainerAbst
334
291
  const hit = this._raycastHit(mouseEvent)
335
292
  if (!hit) return
336
293
  const slotId = (hit.object?.userData?.slotId as string | undefined)
337
- this._invokePopup(slotId)
294
+ this._dispatchCellPopup(slotId)
338
295
  }
339
- private _invokePopup(slotId?: string): void {
340
- const popupRefId = this.state.popupRef
341
- if (!popupRefId) return
342
- const popupComp: any = (this as any).root?.findById?.(popupRefId)
343
- if (!popupComp || typeof popupComp.openPopup !== 'function') return
296
+ private _dispatchCellPopup(slotId?: string): void {
297
+ if (!this.state.popupRef) return
344
298
  if (slotId) {
345
299
  const p = this.parseCellId(slotId)
346
- const anchor = this.slotTargetAt(slotId)
347
- popupComp.openPopup({
300
+ this._invokePopup(slotId, {
348
301
  cellId: slotId,
349
302
  col: p?.col, row: p?.row,
350
303
  records: p ? this.recordsOf(p.col, p.row) : [],
351
304
  capacity: p ? this.capacityOf(p.col, p.row) : undefined
352
- }, { anchor })
305
+ })
353
306
  } else {
354
- const anchor = this.slotTargetAt(this.cellIdOf(0, 0))
355
- popupComp.openPopup({
307
+ this._invokePopup(this.cellIdOf(0, 0), {
356
308
  componentId: (this.state as any).id,
357
309
  cols: this.cols, rows: this.rows
358
- }, { anchor })
310
+ })
359
311
  }
360
312
  }
361
313