@fmsim/machine 2.0.0-beta.84 → 2.0.0-beta.86

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/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "@fmsim/machine",
3
3
  "description": "Layout View를 구성하는 설비(machine)들과 유닛을 구현한 모듈입니다.",
4
4
  "author": "heartyoh",
5
- "version": "2.0.0-beta.84",
5
+ "version": "2.0.0-beta.86",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
8
8
  "things-scene": true,
@@ -55,5 +55,5 @@
55
55
  "prettier --write"
56
56
  ]
57
57
  },
58
- "gitHead": "7885adeec7d182777e1df093603efdd4c1a996e8"
58
+ "gitHead": "2e7fe85f5e286c28c9fb3b5c095a23c8c150b41d"
59
59
  }
@@ -2,7 +2,30 @@ import { Model } from '@hatiolab/things-scene'
2
2
  import type Carrier from './carrier'
3
3
  import type MCSCarrierHolder from './mcs-carrier-holder'
4
4
 
5
- const DYING_TIMEOUT = 1000
5
+ // 사라진 carrier 의 dispose 지연. 같은 macrotask 안에서 holder A 의 사라짐
6
+ // 보고와 holder B 의 등장 보고가 순차로 들어오는 reparent 시나리오 (= 같은
7
+ // frame snapshot 의 여러 record 적용) 를 보호하기 위해 setTimeout(0) 의
8
+ // next-macrotask delay 만 둔다. 과거 1000ms grace 가 있었으나 seek 시 2단계
9
+ // 갱신 (1초 후 carrier 사라짐) 부작용이 명확하고 frame 누락 bouncing 깜빡임
10
+ // 보호 효용은 작다 판단되어 0 으로 단축 (2026-06-09).
11
+ const DYING_TIMEOUT = 0
12
+
13
+ /**
14
+ * Carrier 가 들고 있는 4 키 묶음. data.CARRIERNAME 의 형제 키 (CARRIERTYPE,
15
+ * EMPTYTYPE, CARRIERSTATUS) 를 한 item 으로 묶는다. holder.data 의 *대표*
16
+ * 와 ADDITIONS 의 *각 항목* 모두 동일 shape 으로 정규화돼 stack diff 에
17
+ * 들어간다.
18
+ *
19
+ * 주의 — 외부 시스템이 ADDITIONS 의 각 항목 키를 다른 이름으로 (예:
20
+ * NAME / TYPE / EMPTY / STATUS) 줄 가능성에 대비해 이 build 함수가 단일
21
+ * 정규화 지점이 된다. 키 매핑 변경은 여기 한 곳만 손대면 된다.
22
+ */
23
+ export interface CarrierStackItem {
24
+ CARRIERNAME: string
25
+ CARRIERTYPE?: string
26
+ EMPTYTYPE?: string
27
+ CARRIERSTATUS?: string
28
+ }
6
29
 
7
30
  export default class CarrierManager {
8
31
  private carriers: Map<string, Carrier> = new Map()
@@ -17,58 +40,137 @@ export default class CarrierManager {
17
40
  return this.carriers.get(carrierName)
18
41
  }
19
42
 
43
+ /**
44
+ * Holder 의 data 변경에 대응해 carrier pool 을 동기화.
45
+ *
46
+ * 정책:
47
+ * - holder.data 가 *single source of truth*. 대표(CARRIERNAME) +
48
+ * ADDITIONS[] 를 하나의 *stack list* 로 정규화한 뒤 before/after 의
49
+ * 이름 set diff 로 추가/제거/유지 분기.
50
+ * - "대표" 와 "ADDITIONS" 는 *holder.data 안의 키 위치 차이일 뿐* 이고,
51
+ * carrier 인스턴스끼리는 **종속/그룹 관계 없음 — 모두 독립 동등**.
52
+ * stack 안의 위치 (stackIndex) 만 carrier.state 에 주입한다. 2D render
53
+ * skip 등의 판정은 *위치 (= parent.components 의 마지막 여부)* 로 한다.
54
+ * - 동일 CARRIERNAME 이 *다른 holder* 에 새로 등장하면 reparent (애니메이션).
55
+ * *해당 carrier 만 이동* — 같은 stack 의 다른 carrier 는 따라가지 않는다.
56
+ * - 사라진 carrier 는 next-macrotask 에 dispose. 같은 macrotask 안에서 다른
57
+ * holder 가 같은 이름을 보고하면 cancel 되어 reparent 로 전환.
58
+ */
20
59
  update(holder: MCSCarrierHolder, after: Record<string, any>, before: Record<string, any>): void {
21
- const afterName = after?.CARRIERNAME
22
- const beforeName = before?.CARRIERNAME
60
+ const afterStack = this._buildStack(after)
61
+ const beforeStack = this._buildStack(before)
62
+
63
+ const afterNames = new Set(afterStack.map(i => i.CARRIERNAME))
64
+ const beforeNames = new Set(beforeStack.map(i => i.CARRIERNAME))
23
65
 
24
- // 이전에는 캐리어가 있었는데 새 데이터에는 없다 → dying 후보
25
- if (beforeName && (beforeName !== afterName || !afterName)) {
26
- const carrier = this.carriers.get(beforeName)
66
+ // 1) 사라진 것 → dying schedule (자기 holder 의 자식인 carrier 만)
67
+ for (const name of beforeNames) {
68
+ if (afterNames.has(name)) continue
69
+ const carrier = this.carriers.get(name)
27
70
  if (carrier && carrier.parent === holder) {
28
- this.scheduleDying(beforeName)
71
+ this.scheduleDying(name)
29
72
  }
30
- return
31
73
  }
32
74
 
33
- if (!afterName) {
34
- return
35
- }
75
+ // 2) afterStack 의 *순서대로* 처리 — bottom (ADDITIONS[0]) 부터 top (대표)
76
+ // 그래야 parent.components 가 자연스럽게 같은 순서로 정렬되어 stack index
77
+ // 가 일관된다.
78
+ afterStack.forEach((item, idx) => {
79
+ const name = item.CARRIERNAME
80
+ this.cancelDying(name)
81
+
82
+ let carrier = this.carriers.get(name)
83
+ const { width, height } = holder.state
84
+
85
+ if (!carrier) {
86
+ carrier = Model.compile(
87
+ {
88
+ id: name,
89
+ type: 'Carrier',
90
+ left: 0,
91
+ top: 0,
92
+ width,
93
+ height,
94
+ showText: holder.getState('showCarrierText') || false,
95
+ stackIndex: idx
96
+ },
97
+ this.root.app
98
+ ) as unknown as Carrier
99
+
100
+ this.carriers.set(name, carrier)
101
+ carrier.data = item
102
+ holder.addComponent(carrier, { silent: true })
103
+ } else if (carrier.parent !== holder && !beforeNames.has(name)) {
104
+ // 다른 holder 에 있던 carrier 가 이번 update 에서 *새로 등장* 한 경우만
105
+ // reparent. before 에도 같은 이름이 있었으면 ping-pong (옛 시점의
106
+ // 반복 보고) 이므로 trigger 하지 않는다. (F05 의 bouncing 방지)
107
+ ;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
108
+ }
109
+
110
+ if (carrier.parent === holder) {
111
+ carrier.setState('stackIndex', idx)
112
+ carrier.data = item
113
+ }
114
+ })
115
+
116
+ // *_모든 carrier 추가 완료 후* — 각자의 transform 강제 갱신. 한 줄로 처리
117
+ // 안 되는 이유: cz override 의 offset 계산이 *형제 carrier 의 _realObject.
118
+ // effectiveDepth 누적*에 의존하는데, 첫 iteration 시점엔 자기 뒤 carrier 의
119
+ // _realObject 가 아직 없어 offset 이 부족하게 평가될 수 있다. 모두 빌드된
120
+ // 뒤 한 번 더 갱신해야 적층 정확.
121
+ afterStack.forEach(item => {
122
+ const carrier = this.carriers.get(item.CARRIERNAME)
123
+ if (carrier && carrier.parent === holder) {
124
+ ;(carrier as any)._realObject?.updateTransform?.()
125
+ }
126
+ })
127
+ }
36
128
 
37
- // dying 상태면 구출
38
- const wasDying = this.dyingTimers.has(afterName)
39
- this.cancelDying(afterName)
40
-
41
- const { width, height } = holder.state
42
- let carrier = this.carriers.get(afterName)
43
-
44
- if (!carrier) {
45
- // 새로 생성
46
- carrier = Model.compile(
47
- {
48
- id: afterName,
49
- type: 'Carrier',
50
- left: 0,
51
- top: 0,
52
- width,
53
- height,
54
- showText: holder.getState('showCarrierText') || false
55
- },
56
- this.root.app
57
- ) as unknown as Carrier
58
-
59
- this.carriers.set(afterName, carrier)
60
- carrier.data = after
61
- holder.addComponent(carrier, { silent: true })
62
- } else if (carrier.parent !== holder && (!beforeName && afterName)) {
63
- // CARRIERNAME이 새로 나타남 (이전엔 없었음) → 다른 holder로 이동
64
- ;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
129
+ /**
130
+ * data.CARRIERNAME (대표) + data.ADDITIONS[] 를 하나의 stack list 로 정규화.
131
+ * 적층 순서: ADDITIONS[0..n-1] (bottom → up) + 대표 (top, 맨 마지막).
132
+ */
133
+ private _buildStack(data: any): CarrierStackItem[] {
134
+ if (!data) return []
135
+
136
+ const stack: CarrierStackItem[] = []
137
+
138
+ if (Array.isArray(data.ADDITIONS)) {
139
+ for (const a of data.ADDITIONS) {
140
+ if (a && typeof a === 'object' && a.CARRIERNAME) {
141
+ // 항목 *원본 그대로* — 외부 시스템이 임의 키를 추가로 보낼 수 있고,
142
+ // 누락된 4 키는 default 없이 그대로 비워둔다 (carrier.data 에 흘리는
143
+ // 가공 최소화 — F03 같은 기존 테스트의 raw shape 기대와도 정합).
144
+ stack.push({ ...a } as CarrierStackItem)
145
+ }
146
+ }
65
147
  }
66
148
 
67
- if (carrier.parent === holder) {
68
- carrier.data = after
149
+ if (data.CARRIERNAME) {
150
+ // 대표는 *holder.data 의 carrier 관련 키만 추출* — showText / carrierDepth
151
+ // 같은 holder 자체 속성은 carrier 로 흘리지 않는다. 명시되지 않은 키는
152
+ // 생략 (default 안 채움).
153
+ const repr: any = { CARRIERNAME: data.CARRIERNAME }
154
+ if ('CARRIERTYPE' in data) repr.CARRIERTYPE = data.CARRIERTYPE
155
+ if ('EMPTYTYPE' in data) repr.EMPTYTYPE = data.EMPTYTYPE
156
+ if ('CARRIERSTATUS' in data) repr.CARRIERSTATUS = data.CARRIERSTATUS
157
+ stack.push(repr)
69
158
  }
159
+
160
+ return stack
70
161
  }
71
162
 
163
+ /**
164
+ * carrier dispose 를 *next macrotask* 로 미룬다. 동기 즉시 dispose 가 아닌
165
+ * 이유: 같은 macrotask 안에서 holder A 의 사라짐 보고가 step 1 에 들어오고
166
+ * holder B 의 등장 보고가 (별도 carrier-manager.update 호출의) step 2 에
167
+ * 들어오는 reparent 시나리오를 보호하기 위함. 즉시 dispose 면 step 1 에서
168
+ * carrier 인스턴스가 파괴돼 step 2 의 reparent 분기가 *새 인스턴스 생성*
169
+ * 으로 떨어지고, 3D mesh 재구축 + 이동 애니메이션 손실이 발생.
170
+ *
171
+ * 같은 macrotask 안에서 cancelDying 호출이 도달하면 clearTimeout 으로
172
+ * dispose 회수 → reparent 정상.
173
+ */
72
174
  private scheduleDying(carrierName: string): void {
73
175
  this.cancelDying(carrierName)
74
176
 
package/src/carrier.ts CHANGED
@@ -13,6 +13,7 @@ const NATURE: ComponentNature = {
13
13
  rotatable: false
14
14
  }
15
15
 
16
+
16
17
  /**
17
18
  * Feature flag — keep status text (F / E / X) screen-axis-aligned even
18
19
  * when the carrier (or its parent holder, or an in-flight reparent
@@ -33,6 +34,10 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
33
34
  return NATURE
34
35
  }
35
36
 
37
+ get decorators() {
38
+ return ['decotag']
39
+ }
40
+
36
41
  get path() {
37
42
  if (!this.parent) {
38
43
  return []
@@ -55,7 +60,11 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
55
60
  added(parent: any) {
56
61
  super.added(parent)
57
62
 
58
- // 새 부모에 맞춰 크기 및 상태 갱신
63
+ // 새 부모에 맞춰 크기 및 상태 갱신 — width/height 만. depth 는 *절대로
64
+ // 건드리지 않는다*. machine-3d.ts 의 CARRIER_CONFIG + MCSRealObject3D.
65
+ // effectiveDepth 가 depthRatio(0.5) × min(w,h) 로 자동 결정한다.
66
+ // 여기서 state.depth 를 명시 set 하면 그 resolution chain 의 step 1 에
67
+ // 걸려 본래 두께가 덮어써진다 (= 사용자 호소 "두께가 줄어들었다" 의 원인).
59
68
  if (parent) {
60
69
  const { width, height } = parent.state || {}
61
70
  if (width && height) {
@@ -266,6 +275,18 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
266
275
  return
267
276
  }
268
277
 
278
+ // 2D 에서는 stack 의 *맨 위 (= parent.components 의 마지막)* 1개만 그린다.
279
+ // ADDITIONS 의 carrier 들도 인스턴스로는 살아있어 조회/reparent 가 가능하지만
280
+ // 2D 시각에는 노출하지 않는다 — 정책: "2D 는 현행처럼" (대표 1개만).
281
+ // 판단 기준은 *위치* — carrier 간 그룹/종속 정보는 두지 않는다.
282
+ const parent = this.parent
283
+ if (parent && parent.components && parent.components.length > 1) {
284
+ const siblings = parent.components
285
+ if (siblings[siblings.length - 1] !== this) {
286
+ return
287
+ }
288
+ }
289
+
269
290
  const { width, height } = this.bounds
270
291
  const path = this.path
271
292
  const round = Math.round(Math.min(width, height) * 0.1)
@@ -770,7 +770,7 @@ class StockerRealObject3D extends MCSRealObject3D {
770
770
  const max = Number(data.MAXCAPACITY) || 100
771
771
  const fullup = data.FULLUP === 'T'
772
772
  const usage = Math.round((cur / max) * 100)
773
- const legend = (this.component as any).root?.style?.LEGEND_CAPACITY || LEGEND_CAPACITY
773
+ const legend = (this.component as any).root?.stockerCapacityLegendTheme || LEGEND_CAPACITY
774
774
  const color = fullup ? 'red' : (getValueOnRanges(usage, legend) || '#44aa44')
775
775
  return {
776
776
  segments: [{ ratio: usage / 100, color }],
@@ -852,10 +852,48 @@ const CARRIER_CONFIG: MCS3DConfig = {
852
852
  // Carrier 는 이동 entity — default OBJECTS(0). zPriority 미설정.
853
853
  }
854
854
 
855
+ /**
856
+ * 적층 시 다음 carrier 의 z-position 이 *_이전 carrier 의 effectiveDepth × N*
857
+ * 만큼 위에 놓인다. N=1 이면 carrier 들이 맞붙어 적층 (기존), N<1 이면 부분
858
+ * overlap (3D box 들이 겹쳐서 적층). 사용자 의도 — carrier 두께는 두꺼워도
859
+ * 적층 시 *_compact 하게 보이도록* — N 을 작게.
860
+ */
861
+ const CARRIER_STACK_STEP_RATIO = 0.9
862
+
855
863
  class CarrierRealObject3D extends MCSRealObject3D {
856
864
  constructor(component: Component) {
857
865
  super(component, CARRIER_CONFIG)
858
866
  }
867
+
868
+ /**
869
+ * cz override — *carrier 자기 결정* 의 적층 위치.
870
+ * parent.components 안 자기 index 까지의 형제 carrier 들의 effectiveDepth
871
+ * 누적합을 base cz 위에 더한다. holder 측에 별도 layout 책임을 두지 않고,
872
+ * carrier 가 자기 stack 위치를 알아서 결정.
873
+ */
874
+ override get cz(): number {
875
+ const base = super.cz
876
+ const parent = this.component.parent as any
877
+ if (!parent?.components) return base
878
+ const siblings = parent.components as any[]
879
+ const idx = siblings.indexOf(this.component)
880
+ if (idx <= 0) return base
881
+ let offset = 0
882
+ for (let i = 0; i < idx; i++) {
883
+ const ro = siblings[i]?._realObject
884
+ const eff = Number(ro?.effectiveDepth)
885
+ const d = Number.isFinite(eff) && eff > 0 ? eff : 1
886
+ offset += d * CARRIER_STACK_STEP_RATIO
887
+ }
888
+ // 적층 방향 — placement (= 부모 holder 타입) 에 따라 결정.
889
+ // OHT 처럼 그리퍼 아래에 매달리는 holder 는 carrier 가 *_아래로_* 누적,
890
+ // 그 외 (Port / Shelf / Shuttle / Vehicle) 는 위로 누적.
891
+ // MCSRealObject3D.syncZPosOffset 의 OHT 분기 (-effectiveDepth + halfD) 와
892
+ // 같은 방향성 일관 적용.
893
+ const parentType = parent?.state?.type
894
+ const dir = parentType === 'OHT' ? -1 : 1
895
+ return base + dir * offset
896
+ }
859
897
  }
860
898
 
861
899
  registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
@@ -64,7 +64,8 @@ export const LEGEND_CARRIER: Legend = {
64
64
  export const LEGEND_CAPACITY: Legend = {
65
65
  '0~20': '#5630D0',
66
66
  '20~80': '#8BDA5B',
67
- '80~100': '#FD9B00',
67
+ '80~': '#FD9B00', // 80% 이상 (= 100% 초과도 포함). 'from~' 형식이라
68
+ // getValueOnRanges 가 value >= 80 매칭.
68
69
  default: '#F0F0F0'
69
70
  }
70
71
 
@@ -1,6 +1,18 @@
1
1
  import { themesColorMap, themesAnimationMap } from '@fmsim/api'
2
2
  import MCSUnit from './mcs-unit.js'
3
3
 
4
+ /**
5
+ * Holder.data 변경 감지 시 carrierManager 에 전달할 4 키 묶음 + ADDITIONS 배열.
6
+ * 비교 결과 단 하나라도 다르면 carrierManager.update 호출.
7
+ */
8
+ const CARRIER_DATA_KEYS = ['CARRIERNAME', 'CARRIERTYPE', 'EMPTYTYPE', 'CARRIERSTATUS'] as const
9
+
10
+ function additionsChanged(a: any, b: any): boolean {
11
+ // shallow JSON 비교로 충분 — 각 항목이 4 키 primitive 만 가짐. 깊은 ref 비교
12
+ // 보다 빠르고 정확. 빈/누락은 같은 '[]' 직렬화로 동치 취급.
13
+ return JSON.stringify(a?.ADDITIONS ?? []) !== JSON.stringify(b?.ADDITIONS ?? [])
14
+ }
15
+
4
16
  export default class MCSCarrierHolder extends MCSUnit {
5
17
  static get properties(): any {
6
18
  return [
@@ -26,6 +38,8 @@ export default class MCSCarrierHolder extends MCSUnit {
26
38
  label: 'show-carrier-text',
27
39
  name: 'showCarrierText'
28
40
  }
41
+ // 적층 두께는 carrier 측 (Carrier.added) 에서 자기 default depth 를 결정.
42
+ // holder 측에는 별도 carrierDepth 속성을 두지 않는다.
29
43
  ]
30
44
  }
31
45
 
@@ -48,39 +62,38 @@ export default class MCSCarrierHolder extends MCSUnit {
48
62
  async ready() {
49
63
  super.ready()
50
64
 
51
- const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
52
-
53
- if(CARRIERNAME) {
54
- ;(this.root as any).carrierManager.update(this, {
55
- CARRIERNAME,
56
- CARRIERTYPE,
57
- EMPTYTYPE,
58
- CARRIERSTATUS
59
- }, undefined)
65
+ if (this._hasCarrierData(this.data)) {
66
+ ;(this.root as any).carrierManager.update(this, this.data || {}, undefined)
60
67
  }
61
68
  }
62
69
 
63
70
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
64
- // CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS 이 변경되었을 때만 캐리어 관리자에게 알린다.
65
- const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = after.data || {}
66
- const { CARRIERNAME: beforeCARRIERNAME, CARRIERTYPE: beforeCARRIERTYPE = '', EMPTYTYPE: beforeEMPTYTYPE, CARRIERSTATUS: beforeCARRIERSTATUS } = before.data || {}
67
- if(CARRIERNAME !== beforeCARRIERNAME ||
68
- CARRIERTYPE !== beforeCARRIERTYPE ||
69
- EMPTYTYPE !== beforeEMPTYTYPE ||
70
- CARRIERSTATUS !== beforeCARRIERSTATUS) {
71
- ;(this.root as any).carrierManager.update(this, {
72
- CARRIERNAME,
73
- CARRIERTYPE,
74
- EMPTYTYPE,
75
- CARRIERSTATUS
76
- }, {
77
- CARRIERNAME: beforeCARRIERNAME,
78
- CARRIERTYPE: beforeCARRIERTYPE,
79
- EMPTYTYPE: beforeEMPTYTYPE,
80
- CARRIERSTATUS: beforeCARRIERSTATUS
81
- })
71
+ const afterData = after.data || {}
72
+ const beforeData = before.data || {}
73
+
74
+ let changed = false
75
+ for (const k of CARRIER_DATA_KEYS) {
76
+ if (afterData[k] !== beforeData[k]) {
77
+ changed = true
78
+ break
79
+ }
80
+ }
81
+ if (!changed) {
82
+ changed = additionsChanged(afterData, beforeData)
83
+ }
84
+
85
+ if (changed) {
86
+ ;(this.root as any).carrierManager.update(this, afterData, beforeData)
82
87
  }
83
88
 
84
89
  super.onchangeData(after, before)
85
90
  }
91
+
92
+ /** data 안에 carrier 정보 (대표 또는 ADDITIONS) 가 하나라도 있는지. */
93
+ private _hasCarrierData(data: any): boolean {
94
+ if (!data) return false
95
+ if (data.CARRIERNAME) return true
96
+ if (Array.isArray(data.ADDITIONS) && data.ADDITIONS.some((a: any) => a?.CARRIERNAME)) return true
97
+ return false
98
+ }
86
99
  }
@@ -249,124 +249,59 @@ ModelLayer.prototype.onchange = function (after: any) {
249
249
  return _origModelLayerOnchange.call(this, after)
250
250
  }
251
251
 
252
- Object.defineProperty(RootContainer.prototype, 'equipmentLegendTheme', {
253
- get: function () {
254
- const { equipmentLegendName } = this.rootModel.state
255
- return equipmentLegendName && this.state.style[equipmentLegendName]
256
- }
257
- })
258
-
259
- Object.defineProperty(RootContainer.prototype, 'bufferLegendTheme', {
260
- get: function () {
261
- const { bufferLegendName } = this.rootModel.state
262
- return bufferLegendName && this.state.style[bufferLegendName]
263
- }
264
- })
265
-
266
- Object.defineProperty(RootContainer.prototype, 'agvlineLegendTheme', {
267
- get: function () {
268
- const { agvlineLegendName } = this.rootModel.state
269
- return agvlineLegendName && this.state.style[agvlineLegendName]
270
- }
271
- })
272
-
273
- Object.defineProperty(RootContainer.prototype, 'ohtlineLegendTheme', {
274
- get: function () {
275
- const { ohtlineLegendName } = this.rootModel.state
276
- return ohtlineLegendName && this.state.style[ohtlineLegendName]
277
- }
278
- })
279
-
280
- Object.defineProperty(RootContainer.prototype, 'conveyorLegendTheme', {
281
- get: function () {
282
- const { conveyorLegendName } = this.rootModel.state
283
- return conveyorLegendName && this.state.style[conveyorLegendName]
284
- }
285
- })
286
-
287
- Object.defineProperty(RootContainer.prototype, 'stokerLegendTheme', {
288
- get: function () {
289
- const { stokerLegendName } = this.rootModel.state
290
- return stokerLegendName && this.state.style[stokerLegendName]
291
- }
292
- })
293
-
294
- Object.defineProperty(RootContainer.prototype, 'agvLegendTheme', {
295
- get: function () {
296
- const { agvLegendName } = this.rootModel.state
297
- return agvLegendName && this.state.style[agvLegendName]
298
- }
299
- })
300
-
301
- Object.defineProperty(RootContainer.prototype, 'ohtLegendTheme', {
302
- get: function () {
303
- const { ohtLegendName } = this.rootModel.state
304
- return ohtLegendName && this.state.style[ohtLegendName]
305
- }
306
- })
307
-
308
- Object.defineProperty(RootContainer.prototype, 'shuttleLegendTheme', {
309
- get: function () {
310
- const { shuttleLegendName } = this.rootModel.state
311
- return shuttleLegendName && this.state.style[shuttleLegendName]
312
- }
313
- })
314
-
315
- Object.defineProperty(RootContainer.prototype, 'craneLegendTheme', {
316
- get: function () {
317
- const { craneLegendName } = this.rootModel.state
318
- return craneLegendName && this.state.style[craneLegendName]
319
- }
320
- })
321
-
322
- Object.defineProperty(RootContainer.prototype, 'shelfLegendTheme', {
323
- get: function () {
324
- const { shelfLegendName } = this.rootModel.state
325
- return shelfLegendName && this.state.style[shelfLegendName]
326
- }
327
- })
328
-
329
- Object.defineProperty(RootContainer.prototype, 'portLegendTheme', {
330
- get: function () {
331
- const { portLegendName } = this.rootModel.state
332
- return portLegendName && this.state.style[portLegendName]
333
- }
334
- })
335
-
336
- Object.defineProperty(RootContainer.prototype, 'carrierLegendTheme', {
337
- get: function () {
338
- const { carrierLegendName } = this.rootModel.state
339
- return carrierLegendName && this.state.style[carrierLegendName]
340
- }
341
- })
342
-
343
- Object.defineProperty(RootContainer.prototype, 'carrierAnimationTheme', {
344
- get: function () {
345
- const { carrierAnimationName } = this.rootModel.state
346
- return carrierAnimationName && this.state.style[carrierAnimationName]
347
- }
348
- })
252
+ // === LegendTheme / AnimationTheme value resolve ===
253
+ //
254
+ // rootModel.state.style[name] 에 저장된 legend/animation 값은 보드 직렬화
255
+ // 형태에 따라 (a) 정상 object 또는 (b) JSON 문자열 둘 다로 나타난다 (2026-06-09
256
+ // 진단 — consumer 인 carrier.ts:animationPreset 이 같은 이유로 typeof string
257
+ // 분기 + JSON5.parse 처리하는 선례 존재). LegendTheme getter 들이 이 분기를
258
+ // 두지 않아 string 통과 → getValueOnRanges 매칭 실패 → 3D gauge 의 색상이
259
+ // fallback 으로만 결정되던 증상.
260
+ //
261
+ // fix 는 getter 한 곳에서 lazy parse. WeakMap<state, Map<rawString, parsed>>
262
+ // 으로 *raw string 자체를 key* — 보드 편집으로 raw 가 바뀌면 자연 무효화,
263
+ // 같은 raw 의 반복 lookup 은 O(1). root 가 GC 되면 cache 도 같이 GC.
264
+ const _legendParseCache = new WeakMap<object, Map<string, any>>()
349
265
 
350
- Object.defineProperty(RootContainer.prototype, 'stockerCapacityLegendTheme', {
351
- get: function () {
352
- const { stockerCapacityLegendName } = this.rootModel.state
353
- return stockerCapacityLegendName && this.state.style[stockerCapacityLegendName]
266
+ function parseLegendIfString(state: any, raw: any): any {
267
+ if (typeof raw !== 'string') return raw
268
+ let cache = _legendParseCache.get(state)
269
+ if (!cache) {
270
+ cache = new Map()
271
+ _legendParseCache.set(state, cache)
354
272
  }
355
- })
273
+ if (cache.has(raw)) return cache.get(raw)
274
+ let parsed: any
275
+ try { parsed = JSON.parse(raw) } catch { parsed = raw } // parse 실패 시 raw 보존
276
+ cache.set(raw, parsed)
277
+ return parsed
278
+ }
356
279
 
357
- Object.defineProperty(RootContainer.prototype, 'zoneCapacityLegendTheme', {
358
- get: function () {
359
- const { zoneCapacityLegendName } = this.rootModel.state
360
- return zoneCapacityLegendName && this.state.style[zoneCapacityLegendName]
280
+ function legendThemeGetter(stateKey: string) {
281
+ return function (this: any) {
282
+ const name = this.rootModel.state[stateKey]
283
+ const raw = name && this.state.style[name]
284
+ return parseLegendIfString(this.state, raw)
361
285
  }
362
- })
286
+ }
363
287
 
364
- Object.defineProperty(RootContainer.prototype, 'batteryRateLegendTheme', {
365
- get: function () {
366
- const { batteryRateLegendName } = this.rootModel.state
367
- return batteryRateLegendName && this.state.style[batteryRateLegendName]
368
- }
369
- })
288
+ Object.defineProperty(RootContainer.prototype, 'equipmentLegendTheme', { get: legendThemeGetter('equipmentLegendName') })
289
+ Object.defineProperty(RootContainer.prototype, 'bufferLegendTheme', { get: legendThemeGetter('bufferLegendName') })
290
+ Object.defineProperty(RootContainer.prototype, 'agvlineLegendTheme', { get: legendThemeGetter('agvlineLegendName') })
291
+ Object.defineProperty(RootContainer.prototype, 'ohtlineLegendTheme', { get: legendThemeGetter('ohtlineLegendName') })
292
+ Object.defineProperty(RootContainer.prototype, 'conveyorLegendTheme', { get: legendThemeGetter('conveyorLegendName') })
293
+ Object.defineProperty(RootContainer.prototype, 'stokerLegendTheme', { get: legendThemeGetter('stokerLegendName') })
294
+ Object.defineProperty(RootContainer.prototype, 'agvLegendTheme', { get: legendThemeGetter('agvLegendName') })
295
+ Object.defineProperty(RootContainer.prototype, 'ohtLegendTheme', { get: legendThemeGetter('ohtLegendName') })
296
+ Object.defineProperty(RootContainer.prototype, 'shuttleLegendTheme', { get: legendThemeGetter('shuttleLegendName') })
297
+ Object.defineProperty(RootContainer.prototype, 'craneLegendTheme', { get: legendThemeGetter('craneLegendName') })
298
+ Object.defineProperty(RootContainer.prototype, 'shelfLegendTheme', { get: legendThemeGetter('shelfLegendName') })
299
+ Object.defineProperty(RootContainer.prototype, 'portLegendTheme', { get: legendThemeGetter('portLegendName') })
300
+ Object.defineProperty(RootContainer.prototype, 'carrierLegendTheme', { get: legendThemeGetter('carrierLegendName') })
301
+ Object.defineProperty(RootContainer.prototype, 'carrierAnimationTheme', { get: legendThemeGetter('carrierAnimationName') })
302
+ Object.defineProperty(RootContainer.prototype, 'stockerCapacityLegendTheme', { get: legendThemeGetter('stockerCapacityLegendName') })
303
+ Object.defineProperty(RootContainer.prototype, 'zoneCapacityLegendTheme', { get: legendThemeGetter('zoneCapacityLegendName') })
304
+ Object.defineProperty(RootContainer.prototype, 'batteryRateLegendTheme', { get: legendThemeGetter('batteryRateLegendName') })
370
305
 
371
306
  Object.defineProperty(RootContainer.prototype, 'vehicleAnimation', {
372
307
  get: function () {