@fmsim/machine 1.0.88 → 2.0.0-beta.100

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.
Files changed (165) hide show
  1. package/dist/agv-line.js.map +1 -1
  2. package/dist/agv.js +6 -2
  3. package/dist/agv.js.map +1 -1
  4. package/dist/buffer.js.map +1 -1
  5. package/dist/carrier-manager.js +183 -0
  6. package/dist/carrier-manager.js.map +1 -0
  7. package/dist/carrier.js +105 -14
  8. package/dist/carrier.js.map +1 -1
  9. package/dist/conveyor-join.js +8 -2
  10. package/dist/conveyor-join.js.map +1 -1
  11. package/dist/conveyor.js +9 -1
  12. package/dist/conveyor.js.map +1 -1
  13. package/dist/crane-rail.js +53 -0
  14. package/dist/crane-rail.js.map +1 -0
  15. package/dist/crane.js +144 -1
  16. package/dist/crane.js.map +1 -1
  17. package/dist/equipment.js.map +1 -1
  18. package/dist/factories/agv-3d.js +147 -0
  19. package/dist/factories/agv-3d.js.map +1 -0
  20. package/dist/factories/conveyor-3d.js +345 -0
  21. package/dist/factories/conveyor-3d.js.map +1 -0
  22. package/dist/factories/crane-3d.js +186 -0
  23. package/dist/factories/crane-3d.js.map +1 -0
  24. package/dist/factories/crane-rail-3d.js +95 -0
  25. package/dist/factories/crane-rail-3d.js.map +1 -0
  26. package/dist/factories/machine-3d.js +973 -0
  27. package/dist/factories/machine-3d.js.map +1 -0
  28. package/dist/factories/oht-3d.js +177 -0
  29. package/dist/factories/oht-3d.js.map +1 -0
  30. package/dist/factories/shuttle-3d.js +159 -0
  31. package/dist/factories/shuttle-3d.js.map +1 -0
  32. package/dist/factories/z-priority-defaults.js +33 -0
  33. package/dist/factories/z-priority-defaults.js.map +1 -0
  34. package/dist/factories/z-priority.js +54 -0
  35. package/dist/factories/z-priority.js.map +1 -0
  36. package/dist/features/conveyor-mixin.js.map +1 -1
  37. package/dist/features/mcs-status-default.js +15 -1
  38. package/dist/features/mcs-status-default.js.map +1 -1
  39. package/dist/features/mcs-status-mixin.js +22 -0
  40. package/dist/features/mcs-status-mixin.js.map +1 -1
  41. package/dist/features/mcs-zone-mixin.js.map +1 -1
  42. package/dist/features/parent-object-mixin.js +15 -7
  43. package/dist/features/parent-object-mixin.js.map +1 -1
  44. package/dist/floating-container.js +5 -0
  45. package/dist/floating-container.js.map +1 -1
  46. package/dist/index.js +58 -2
  47. package/dist/index.js.map +1 -1
  48. package/dist/mcs-carrier-holder.js +56 -17
  49. package/dist/mcs-carrier-holder.js.map +1 -1
  50. package/dist/mcs-machine.js +39 -0
  51. package/dist/mcs-machine.js.map +1 -1
  52. package/dist/mcs-unit.js +20 -0
  53. package/dist/mcs-unit.js.map +1 -1
  54. package/dist/mcs-vehicle.js +10 -4
  55. package/dist/mcs-vehicle.js.map +1 -1
  56. package/dist/node.js +10 -3
  57. package/dist/node.js.map +1 -1
  58. package/dist/oht-line.js.map +1 -1
  59. package/dist/oht.js +4 -0
  60. package/dist/oht.js.map +1 -1
  61. package/dist/port-flow.js +4 -0
  62. package/dist/port-flow.js.map +1 -1
  63. package/dist/port.js +4 -0
  64. package/dist/port.js.map +1 -1
  65. package/dist/scanner.js +30 -0
  66. package/dist/scanner.js.map +1 -0
  67. package/dist/scene/root-container-override.js +160 -96
  68. package/dist/scene/root-container-override.js.map +1 -1
  69. package/dist/shelf.js +4 -0
  70. package/dist/shelf.js.map +1 -1
  71. package/dist/shuttle.js.map +1 -1
  72. package/dist/static-container.js +5 -0
  73. package/dist/static-container.js.map +1 -1
  74. package/dist/stocker-abnormal-bar.js +184 -0
  75. package/dist/stocker-abnormal-bar.js.map +1 -0
  76. package/dist/stocker-capacity-bar.js +253 -6
  77. package/dist/stocker-capacity-bar.js.map +1 -1
  78. package/dist/stocker-carrier-bar.js +198 -0
  79. package/dist/stocker-carrier-bar.js.map +1 -0
  80. package/dist/stocker.js +7 -1
  81. package/dist/stocker.js.map +1 -1
  82. package/dist/templates/backdrop.js +30 -0
  83. package/dist/templates/backdrop.js.map +1 -0
  84. package/dist/templates/decal.js +21 -0
  85. package/dist/templates/decal.js.map +1 -0
  86. package/dist/templates/index.js +7 -1
  87. package/dist/templates/index.js.map +1 -1
  88. package/dist/templates/scanner.js +20 -0
  89. package/dist/templates/scanner.js.map +1 -0
  90. package/dist/tsconfig.tsbuildinfo +1 -1
  91. package/dist/utils/crane-position.js +83 -0
  92. package/dist/utils/crane-position.js.map +1 -0
  93. package/dist/utils/data-value.js +21 -0
  94. package/dist/utils/data-value.js.map +1 -0
  95. package/dist/utils/fit-gauge-scale.js +28 -0
  96. package/dist/utils/fit-gauge-scale.js.map +1 -0
  97. package/dist/utils/get-value-on-ranges.js +2 -4
  98. package/dist/utils/get-value-on-ranges.js.map +1 -1
  99. package/dist/utils/stocker-gauge.js +78 -0
  100. package/dist/utils/stocker-gauge.js.map +1 -0
  101. package/dist/zone-capacity-bar.js +3 -3
  102. package/dist/zone-capacity-bar.js.map +1 -1
  103. package/icons/backdrop.png +0 -0
  104. package/icons/decal.png +0 -0
  105. package/jest.config.js +10 -0
  106. package/package.json +7 -6
  107. package/src/agv-line.ts +1 -1
  108. package/src/agv.ts +10 -5
  109. package/src/buffer.ts +1 -1
  110. package/src/carrier-manager.ts +223 -0
  111. package/src/carrier.ts +111 -18
  112. package/src/conveyor-join.ts +6 -3
  113. package/src/conveyor.ts +10 -2
  114. package/src/crane-rail.ts +58 -0
  115. package/src/crane.ts +182 -2
  116. package/src/equipment.ts +1 -1
  117. package/src/factories/agv-3d.ts +160 -0
  118. package/src/factories/conveyor-3d.ts +398 -0
  119. package/src/factories/crane-3d.ts +217 -0
  120. package/src/factories/crane-rail-3d.ts +115 -0
  121. package/src/factories/machine-3d.ts +1192 -0
  122. package/src/factories/oht-3d.ts +196 -0
  123. package/src/factories/shuttle-3d.ts +179 -0
  124. package/src/factories/z-priority-defaults.ts +38 -0
  125. package/src/factories/z-priority.ts +60 -0
  126. package/src/features/conveyor-mixin.ts +1 -1
  127. package/src/features/mcs-status-default.ts +15 -1
  128. package/src/features/mcs-status-mixin.ts +26 -4
  129. package/src/features/mcs-zone-mixin.ts +1 -1
  130. package/src/features/parent-object-mixin.ts +17 -8
  131. package/src/floating-container.ts +5 -0
  132. package/src/index.ts +66 -2
  133. package/src/mcs-carrier-holder.ts +58 -29
  134. package/src/mcs-machine.ts +43 -1
  135. package/src/mcs-unit.ts +25 -2
  136. package/src/mcs-vehicle.ts +12 -9
  137. package/src/node.ts +9 -3
  138. package/src/oht-line.ts +1 -1
  139. package/src/oht.ts +7 -2
  140. package/src/port-flow.ts +6 -1
  141. package/src/port.ts +6 -1
  142. package/src/scanner.ts +29 -0
  143. package/src/scene/root-container-override.ts +156 -105
  144. package/src/shelf.ts +6 -1
  145. package/src/shuttle.ts +1 -1
  146. package/src/static-container.ts +5 -0
  147. package/src/stocker-abnormal-bar.ts +247 -0
  148. package/src/stocker-capacity-bar.ts +301 -10
  149. package/src/stocker-carrier-bar.ts +262 -0
  150. package/src/stocker.ts +10 -3
  151. package/src/templates/backdrop.ts +30 -0
  152. package/src/templates/decal.ts +21 -0
  153. package/src/templates/index.ts +7 -1
  154. package/src/templates/scanner.ts +20 -0
  155. package/src/utils/crane-position.ts +105 -0
  156. package/src/utils/data-value.ts +19 -0
  157. package/src/utils/fit-gauge-scale.ts +35 -0
  158. package/src/utils/get-value-on-ranges.ts +2 -4
  159. package/src/utils/stocker-gauge.ts +111 -0
  160. package/src/zone-capacity-bar.ts +5 -5
  161. package/translations/en.json +26 -1
  162. package/translations/ja.json +26 -1
  163. package/translations/ko.json +26 -1
  164. package/translations/ms.json +26 -1
  165. package/translations/zh.json +26 -1
@@ -9,10 +9,30 @@ import {
9
9
  } from '@hatiolab/things-scene'
10
10
  import { themesColorRange } from '@fmsim/api'
11
11
  import { MCSStatusMixin } from './features/mcs-status-mixin'
12
- import { LEGEND_CAPACITY, Legend } from './features/mcs-status-default'
12
+ import { LEGEND_CAPACITY, Legend, resolveCarrierLegend } from './features/mcs-status-default'
13
13
  import { safeRound } from './utils/safe-round'
14
- import { getVaueOnRanges } from './utils/get-value-on-ranges'
14
+ import { getValueOnRanges } from './utils/get-value-on-ranges'
15
15
  import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin'
16
+ import { carrierSegments, abnormalSegments, carrierColorsFromLegend, GaugeSegment } from './utils/stocker-gauge'
17
+
18
+ /**
19
+ * 스토커 게이지 (통합).
20
+ *
21
+ * 하나의 컴포넌트가 mode 에 따라 3가지 게이지를 표현한다:
22
+ * - 'capacity' : 용량 사용률(usage%) 채움 게이지 + high/low watermark (기본)
23
+ * - 'carrier' : 캐리어 상태비율 세그먼트 (Full / Empty / EmptyEmpty)
24
+ * - 'abnormal' : 캐리어 정상/비정상 비율 세그먼트 (Normal / Abnormal)
25
+ *
26
+ * mode 소스: root.stockerGaugeMode (기본 'capacity').
27
+ * RootContainer 의 **런타임 전용 transient 속성** — 모델에 저장하지 않고 항상 'capacity'
28
+ * 로 시작하며 실행 중 동적으로만 변경된다(root-container-override.ts 정의). 3D 표현
29
+ * (machine-3d.ts buildGauge)과 동일 소스라 2D/3D 게이지가 항상 일치한다.
30
+ *
31
+ * 데이터 필드(2D 기준, canonical):
32
+ * - capacity : CURRENTCAPACITY / MAXCAPACITY / HIGHWATERMARK / LOWWATERMARK / FULLUP
33
+ * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY
34
+ * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL
35
+ */
16
36
 
17
37
  const NATURE: ComponentNature = {
18
38
  mutable: false,
@@ -53,6 +73,31 @@ const NATURE: ComponentNature = {
53
73
  name: 'fullup',
54
74
  label: 'fullup'
55
75
  },
76
+ {
77
+ type: 'number',
78
+ name: 'carrierFull',
79
+ label: 'carrier-full'
80
+ },
81
+ {
82
+ type: 'number',
83
+ name: 'carrierEmpty',
84
+ label: 'carrier-empty'
85
+ },
86
+ {
87
+ type: 'number',
88
+ name: 'carrierEmptyEmpty',
89
+ label: 'carrier-empty-empty'
90
+ },
91
+ {
92
+ type: 'number',
93
+ name: 'carrierNormal',
94
+ label: 'carrier-normal'
95
+ },
96
+ {
97
+ type: 'number',
98
+ name: 'carrierAbnormal',
99
+ label: 'carrier-abnormal'
100
+ },
56
101
  {
57
102
  type: 'number',
58
103
  label: 'round',
@@ -92,6 +137,16 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
92
137
  return NATURE
93
138
  }
94
139
 
140
+ /** StockerCapacityBar / MCSGaugeCapacityBar 의 parentObject 는 STOCKER. */
141
+ get parentObjectTypes(): string[] {
142
+ return ['STOCKER']
143
+ }
144
+
145
+ /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */
146
+ get gaugeMode(): string {
147
+ return (this.root as any)?.stockerGaugeMode || 'capacity'
148
+ }
149
+
95
150
  get controls() {
96
151
  var { left, top, width, round, height } = this.state
97
152
  round = round == undefined ? 0 : safeRound(round, width, height)
@@ -113,7 +168,7 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
113
168
  const { legendName } = this.state
114
169
 
115
170
  if (legendName) {
116
- return this.root?.style?.[legendName]
171
+ return (this.root as any)?.style?.[legendName]
117
172
  }
118
173
  }
119
174
 
@@ -122,21 +177,33 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
122
177
  }
123
178
 
124
179
  getLegendFallback() {
125
- return this.root.stockerCapacityLegendTheme || LEGEND_CAPACITY
180
+ return (this.root as any).stockerCapacityLegendTheme || LEGEND_CAPACITY
126
181
  }
127
182
 
128
183
  get statusColor() {
129
184
  const { fullup } = this.state
130
- return fullup ? 'red' : getVaueOnRanges(this.usage, this.legend)
185
+ return fullup ? 'red' : getValueOnRanges(this.usage, this.legend)
131
186
  }
132
187
 
133
188
  get text() {
189
+ // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로
190
+ // base 의 value 텍스트(value-property=usage)를 억제한다.
191
+ if (this.gaugeMode !== 'capacity') {
192
+ return ''
193
+ }
134
194
  return this.usage + '%'
135
195
  }
136
196
 
137
197
  render(context: CanvasRenderingContext2D) {
138
- const { left, top, width, height } = this.bounds
198
+ const mode = this.gaugeMode
139
199
 
200
+ if (mode === 'carrier' || mode === 'abnormal') {
201
+ this.renderSegmentGauge(context, this.segments(mode))
202
+ return
203
+ }
204
+
205
+ // capacity (기본)
206
+ const { width, height } = this.bounds
140
207
  if (width >= height) {
141
208
  this.renderLandscape(context)
142
209
  } else {
@@ -144,6 +211,125 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
144
211
  }
145
212
  }
146
213
 
214
+ /**
215
+ * Shape.postrender 의 drawFill/drawStroke 를 건너뛴다.
216
+ * 각 모드 render 가 자체 배경(fillStyle)·외곽선(strokeStyle)을 이미 그리므로, base 가
217
+ * fillStyle 로 게이지 전체를 다시 덮어(각 모드 표현이 사라지는) 것을 방지한다.
218
+ * 값 텍스트(drawText)는 Component.postrender 와 동일하게 유지한다.
219
+ */
220
+ postrender(context: CanvasRenderingContext2D) {
221
+ this.drawText(context)
222
+ }
223
+
224
+ /**
225
+ * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산).
226
+ * 배열 순서 = portrait 아래→위 / landscape 왼→오.
227
+ */
228
+ segments(mode: string): GaugeSegment[] {
229
+ if (mode === 'abnormal') {
230
+ const { carrierNormal = 0, carrierAbnormal = 0 } = this.state
231
+ return abnormalSegments(carrierNormal, carrierAbnormal)
232
+ }
233
+ const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = this.state
234
+ // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득 (carrierColorsFromLegend).
235
+ const colors = carrierColorsFromLegend(resolveCarrierLegend(this))
236
+ return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty, colors)
237
+ }
238
+
239
+ /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */
240
+ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) {
241
+ const { left, top, width, height } = this.bounds
242
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
243
+ const landscape = width >= height
244
+
245
+ context.save()
246
+ context.translate(left, top)
247
+
248
+ // background
249
+ context.beginPath()
250
+ context.fillStyle = fillStyle
251
+ context.roundRect(0, 0, width, height, round)
252
+ context.fill()
253
+ context.clip()
254
+
255
+ if (landscape) {
256
+ // 왼→오
257
+ let x = 0
258
+ for (const seg of segments) {
259
+ const segWidth = width * seg.ratio
260
+ if (segWidth <= 0) continue
261
+
262
+ context.beginPath()
263
+ context.fillStyle = seg.color
264
+ context.rect(x, 0, segWidth, height)
265
+ context.fill()
266
+
267
+ this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
268
+ x += segWidth
269
+ }
270
+ } else {
271
+ // 아래→위 (배열 첫 세그먼트가 바닥)
272
+ let y = height
273
+ for (const seg of segments) {
274
+ const segHeight = height * seg.ratio
275
+ if (segHeight <= 0) continue
276
+
277
+ y -= segHeight
278
+
279
+ context.beginPath()
280
+ context.fillStyle = seg.color
281
+ context.rect(0, y, width, segHeight)
282
+ context.fill()
283
+
284
+ this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
285
+ }
286
+ }
287
+
288
+ // outline
289
+ context.beginPath()
290
+ context.setLineDash([])
291
+ context.strokeStyle = strokeStyle
292
+ context.lineWidth = lineWidth
293
+ context.roundRect(0, 0, width, height, round)
294
+ context.stroke()
295
+
296
+ context.translate(-left, -top)
297
+ context.restore()
298
+ }
299
+
300
+ private _drawSegmentLabel(
301
+ context: CanvasRenderingContext2D,
302
+ seg: GaugeSegment,
303
+ x: number,
304
+ y: number,
305
+ w: number,
306
+ h: number
307
+ ) {
308
+ // 이름 라벨(Full/Empty/Abnormal…)은 그리지 않는다. 각 세그먼트 영역 중심에 숫자(%)만 표시.
309
+ // 컴포넌트의 폰트 설정(fontFamily/fontSize/bold/italic)을 그대로 적용 — this.font.
310
+ const fontSize = this.state.fontSize || 15
311
+ context.font = this.font
312
+
313
+ // 공간에 따라 단계적 축소: 넉넉하면 '값%', 좁으면 '%' 떼고 값만, 더 좁으면 생략.
314
+ const value = `${Math.round(seg.ratio * 100)}`
315
+ const fits = (t: string) => fontSize <= h && context.measureText(t).width <= w
316
+
317
+ let text: string | null = null
318
+ if (fits(`${value}%`)) {
319
+ text = `${value}%`
320
+ } else if (fits(value)) {
321
+ text = value
322
+ }
323
+ if (text === null) {
324
+ return
325
+ }
326
+
327
+ context.fillStyle = '#333'
328
+ context.textAlign = 'center'
329
+ context.textBaseline = 'middle'
330
+ context.fillText(text, x + w / 2, y + h / 2)
331
+ }
332
+
147
333
  renderLandscape(context: CanvasRenderingContext2D) {
148
334
  const { left, top, width, height } = this.bounds
149
335
  const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
@@ -297,14 +483,20 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
297
483
  // intentionally have done nothing
298
484
  }
299
485
 
300
- onchangeData(after: Properties, before: Properties): void {
486
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
487
+ const data = this.data && typeof this.data == 'object' ? this.data : {}
301
488
  const {
302
489
  CURRENTCAPACITY = 0,
303
490
  MAXCAPACITY = 100,
304
491
  HIGHWATERMARK = 100,
305
492
  LOWWATERMARK = 0,
306
- FULLUP = 'F'
307
- } = typeof this.data == 'object' ? this.data : {}
493
+ FULLUP = 'F',
494
+ CARRIER_FULL = 0,
495
+ CARRIER_EMPTY = 0,
496
+ CARRIER_EMPTY_EMPTY = 0,
497
+ CARRIER_NORMAL = 0,
498
+ CARRIER_ABNORMAL = 0
499
+ } = data
308
500
 
309
501
  const currentUsage = Number(CURRENTCAPACITY) || 0
310
502
  const maxCapacity = Number(MAXCAPACITY) || 100
@@ -317,7 +509,106 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
317
509
  highWatermark,
318
510
  lowWatermark,
319
511
  fullup: FULLUP == 'T',
320
- usage: Math.round((currentUsage / maxCapacity) * 100)
512
+ usage: Math.round((currentUsage / maxCapacity) * 100),
513
+ carrierFull: Number(CARRIER_FULL) || 0,
514
+ carrierEmpty: Number(CARRIER_EMPTY) || 0,
515
+ carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0,
516
+ carrierNormal: Number(CARRIER_NORMAL) || 0,
517
+ carrierAbnormal: Number(CARRIER_ABNORMAL) || 0
321
518
  })
322
519
  }
520
+
521
+ // ── hover 툴팁 (carrier/abnormal 모드 세그먼트별 수치) ───────────────────────────
522
+ // 색상만으로 타입 구분이 어려울 때, 마우스 hover 로 정확한 수치 확인. 뷰어 모드 전용
523
+ // (model-layer 가 편집 모드에선 hover dispatch 안 함).
524
+
525
+ async ready(): Promise<void> {
526
+ await super.ready()
527
+ // hover 이벤트 수신 opt-in: model-layer _onmouseenter_all guard(hasLegacyHover) 통과.
528
+ const ev = this.model.event
529
+ if (!ev || !ev.hover) {
530
+ this.model.event = { ...(ev || {}), hover: true }
531
+ }
532
+ }
533
+
534
+ onMouseEnter(): void {
535
+ // capacity 는 값이 한 개(usage%)라 툴팁 불필요. carrier/abnormal 만.
536
+ if (this.gaugeMode === 'capacity') return
537
+ this._showGaugeTooltip()
538
+ }
539
+
540
+ onMouseLeave(): void {
541
+ this._hideGaugeTooltip()
542
+ }
543
+
544
+ dispose(): void {
545
+ this._hideGaugeTooltip()
546
+ super.dispose()
547
+ }
548
+
549
+ private _tooltipEl?: HTMLDivElement
550
+ private _tooltipMove?: (e: MouseEvent) => void
551
+
552
+ private _showGaugeTooltip(): void {
553
+ if (typeof document === 'undefined') return
554
+ const segments = this.segments(this.gaugeMode).filter(s => s.count > 0)
555
+ if (!segments.length) return
556
+
557
+ this._hideGaugeTooltip()
558
+
559
+ const el = document.createElement('div')
560
+ el.style.cssText = [
561
+ 'position:fixed',
562
+ 'left:-9999px',
563
+ 'top:0',
564
+ 'z-index:2147483647',
565
+ 'pointer-events:none',
566
+ 'background:rgba(30,30,30,0.92)',
567
+ 'color:#fff',
568
+ 'padding:6px 9px',
569
+ 'border-radius:6px',
570
+ 'font:12px/1.5 sans-serif',
571
+ 'box-shadow:0 2px 10px rgba(0,0,0,0.35)',
572
+ 'white-space:nowrap'
573
+ ].join(';')
574
+
575
+ for (const seg of segments) {
576
+ const row = document.createElement('div')
577
+ row.style.cssText = 'display:flex;align-items:center;gap:6px'
578
+
579
+ const swatch = document.createElement('span')
580
+ swatch.style.cssText = `display:inline-block;width:10px;height:10px;border-radius:2px;background:${seg.color}`
581
+
582
+ const label = document.createElement('span')
583
+ label.textContent = seg.label.replace(/\n/g, ' ')
584
+ label.style.cssText = 'flex:1'
585
+
586
+ const value = document.createElement('span')
587
+ value.textContent = String(seg.count)
588
+ value.style.cssText = 'font-weight:700;margin-left:12px'
589
+
590
+ row.append(swatch, label, value)
591
+ el.appendChild(row)
592
+ }
593
+
594
+ document.body.appendChild(el)
595
+ this._tooltipEl = el
596
+
597
+ this._tooltipMove = (e: MouseEvent) => {
598
+ el.style.left = `${e.clientX + 14}px`
599
+ el.style.top = `${e.clientY + 14}px`
600
+ }
601
+ document.addEventListener('mousemove', this._tooltipMove, true)
602
+ }
603
+
604
+ private _hideGaugeTooltip(): void {
605
+ if (this._tooltipMove) {
606
+ document.removeEventListener('mousemove', this._tooltipMove, true)
607
+ this._tooltipMove = undefined
608
+ }
609
+ if (this._tooltipEl) {
610
+ this._tooltipEl.remove()
611
+ this._tooltipEl = undefined
612
+ }
613
+ }
323
614
  }
@@ -0,0 +1,262 @@
1
+ import {
2
+ Component,
3
+ ComponentNature,
4
+ POSITION,
5
+ RectPath,
6
+ Shape,
7
+ sceneComponent
8
+ } from '@hatiolab/things-scene'
9
+ import { LEGEND_CARRIER } from './features/mcs-status-default'
10
+ import { safeRound } from './utils/safe-round'
11
+ import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin'
12
+
13
+ /**
14
+ * 캐리어 상태별 비율 게이지 (Full / Empty / EmptyEmpty)
15
+ *
16
+ * 스토커에 보관된 캐리어들의 상태 비율을 세그먼트 게이지로 표시한다.
17
+ * Portrait(세로) 모드에서는 아래→위, Landscape(가로) 모드에서는 왼→오 방향으로 채운다.
18
+ */
19
+
20
+ interface Segment {
21
+ label: string
22
+ count: number
23
+ ratio: number
24
+ color: string
25
+ }
26
+
27
+ const NATURE: ComponentNature = {
28
+ mutable: false,
29
+ resizable: true,
30
+ rotatable: true,
31
+ properties: [
32
+ ...ParentObjectMixinProperties,
33
+ {
34
+ type: 'number',
35
+ name: 'carrierFull',
36
+ label: 'carrier-full'
37
+ },
38
+ {
39
+ type: 'number',
40
+ name: 'carrierEmpty',
41
+ label: 'carrier-empty'
42
+ },
43
+ {
44
+ type: 'number',
45
+ name: 'carrierEmptyEmpty',
46
+ label: 'carrier-empty-empty'
47
+ },
48
+ {
49
+ type: 'number',
50
+ label: 'round',
51
+ name: 'round',
52
+ property: {
53
+ min: 0
54
+ }
55
+ }
56
+ ]
57
+ }
58
+
59
+ const DEFAULT_COLORS = {
60
+ full: LEGEND_CARRIER['FULL'] || '#4AA7FE',
61
+ empty: LEGEND_CARRIER['EMPTY'] || '#8BDA5B',
62
+ emptyEmpty: LEGEND_CARRIER['EMPTYEMPTY'] || '#019D59'
63
+ }
64
+
65
+ var controlHandler = {
66
+ ondragmove: function (point: POSITION, index: number, component: Component) {
67
+ var { left, top, width, height } = component.model
68
+
69
+ var transcoorded = component.transcoordP2S(point.x, point.y)
70
+ var round = ((transcoorded.x - left) / (width / 2)) * 100
71
+
72
+ round = safeRound(round, width, height)
73
+
74
+ component.set({ round })
75
+ }
76
+ }
77
+
78
+ @sceneComponent('StockerCarrierBar')
79
+ export default class StockerCarrierBar extends ParentObjectMixin(RectPath(Shape)) {
80
+ static get nature() {
81
+ return NATURE
82
+ }
83
+
84
+ /** StockerCarrierBar 의 parentObject 는 STOCKER. */
85
+ get parentObjectTypes(): string[] {
86
+ return ['STOCKER']
87
+ }
88
+
89
+ get controls() {
90
+ var { left, top, width, round, height } = this.state
91
+ round = round == undefined ? 0 : safeRound(round, width, height)
92
+
93
+ return [
94
+ {
95
+ x: left + (width / 2) * (round / 100),
96
+ y: top,
97
+ handler: controlHandler
98
+ }
99
+ ]
100
+ }
101
+
102
+ isIdentifiable() {
103
+ return false
104
+ }
105
+
106
+ get segments(): Segment[] {
107
+ const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = this.state
108
+ const total = carrierFull + carrierEmpty + carrierEmptyEmpty
109
+
110
+ if (total === 0) {
111
+ return [
112
+ { label: 'Full', count: 0, ratio: 0, color: DEFAULT_COLORS.full },
113
+ { label: 'Empty', count: 0, ratio: 0, color: DEFAULT_COLORS.empty },
114
+ { label: 'Empty\nEmpty', count: 0, ratio: 0, color: DEFAULT_COLORS.emptyEmpty }
115
+ ]
116
+ }
117
+
118
+ return [
119
+ { label: 'Full', count: carrierFull, ratio: carrierFull / total, color: DEFAULT_COLORS.full },
120
+ { label: 'Empty', count: carrierEmpty, ratio: carrierEmpty / total, color: DEFAULT_COLORS.empty },
121
+ { label: 'Empty\nEmpty', count: carrierEmptyEmpty, ratio: carrierEmptyEmpty / total, color: DEFAULT_COLORS.emptyEmpty }
122
+ ]
123
+ }
124
+
125
+ render(context: CanvasRenderingContext2D) {
126
+ const { width, height } = this.bounds
127
+
128
+ if (width >= height) {
129
+ this.renderLandscape(context)
130
+ } else {
131
+ this.renderPortrait(context)
132
+ }
133
+ }
134
+
135
+ renderPortrait(context: CanvasRenderingContext2D) {
136
+ const { left, top, width, height } = this.bounds
137
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
138
+ const segments = this.segments
139
+
140
+ context.save()
141
+ context.translate(left, top)
142
+
143
+ // background
144
+ context.beginPath()
145
+ context.fillStyle = fillStyle
146
+ context.roundRect(0, 0, width, height, round)
147
+ context.fill()
148
+ context.clip()
149
+
150
+ // segments (아래→위)
151
+ let y = height
152
+ for (const seg of segments) {
153
+ const segHeight = height * seg.ratio
154
+ if (segHeight <= 0) continue
155
+
156
+ y -= segHeight
157
+
158
+ context.beginPath()
159
+ context.fillStyle = seg.color
160
+ context.rect(0, y, width, segHeight)
161
+ context.fill()
162
+
163
+ // label
164
+ this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
165
+ }
166
+
167
+ // outline
168
+ context.beginPath()
169
+ context.setLineDash([])
170
+ context.strokeStyle = strokeStyle
171
+ context.lineWidth = lineWidth
172
+ context.roundRect(0, 0, width, height, round)
173
+ context.stroke()
174
+
175
+ context.translate(-left, -top)
176
+ context.restore()
177
+ }
178
+
179
+ renderLandscape(context: CanvasRenderingContext2D) {
180
+ const { left, top, width, height } = this.bounds
181
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
182
+ const segments = this.segments
183
+
184
+ context.save()
185
+ context.translate(left, top)
186
+
187
+ // background
188
+ context.beginPath()
189
+ context.fillStyle = fillStyle
190
+ context.roundRect(0, 0, width, height, round)
191
+ context.fill()
192
+ context.clip()
193
+
194
+ // segments (왼→오)
195
+ let x = 0
196
+ for (const seg of segments) {
197
+ const segWidth = width * seg.ratio
198
+ if (segWidth <= 0) continue
199
+
200
+ context.beginPath()
201
+ context.fillStyle = seg.color
202
+ context.rect(x, 0, segWidth, height)
203
+ context.fill()
204
+
205
+ this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
206
+
207
+ x += segWidth
208
+ }
209
+
210
+ // outline
211
+ context.beginPath()
212
+ context.setLineDash([])
213
+ context.strokeStyle = strokeStyle
214
+ context.lineWidth = lineWidth
215
+ context.roundRect(0, 0, width, height, round)
216
+ context.stroke()
217
+
218
+ context.translate(-left, -top)
219
+ context.restore()
220
+ }
221
+
222
+ private _drawSegmentLabel(
223
+ context: CanvasRenderingContext2D,
224
+ seg: Segment,
225
+ x: number,
226
+ y: number,
227
+ w: number,
228
+ h: number
229
+ ) {
230
+ const percent = Math.round(seg.ratio * 100)
231
+ const lines = seg.label.split('\n')
232
+ lines.push(`${percent}%`)
233
+
234
+ const fontSize = Math.max(9, Math.min(14, Math.min(w, h) * 0.22))
235
+ context.font = `bold ${fontSize}px sans-serif`
236
+ context.fillStyle = '#333'
237
+ context.textAlign = 'center'
238
+ context.textBaseline = 'middle'
239
+
240
+ const lineHeight = fontSize * 1.2
241
+ const totalHeight = lines.length * lineHeight
242
+ const startY = y + (h - totalHeight) / 2 + lineHeight / 2
243
+
244
+ for (let i = 0; i < lines.length; i++) {
245
+ context.fillText(lines[i], x + w / 2, startY + i * lineHeight)
246
+ }
247
+ }
248
+
249
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
250
+ const {
251
+ CARRIER_FULL = 0,
252
+ CARRIER_EMPTY = 0,
253
+ CARRIER_EMPTY_EMPTY = 0
254
+ } = this.data && typeof this.data == 'object' ? this.data : {}
255
+
256
+ this.setState({
257
+ carrierFull: Number(CARRIER_FULL) || 0,
258
+ carrierEmpty: Number(CARRIER_EMPTY) || 0,
259
+ carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0
260
+ })
261
+ }
262
+ }
package/src/stocker.ts CHANGED
@@ -34,19 +34,26 @@ export default class Stocker extends MCSTransport {
34
34
  }
35
35
 
36
36
  getLegendFallback() {
37
- return this.root.stokerLegendTheme || super.getLegendFallback()
37
+ return (this.root as any).stokerLegendTheme || super.getLegendFallback()
38
38
  }
39
39
 
40
40
  containable(component: Component) {
41
41
  return (
42
42
  super.containable(component) ||
43
- ['Crane', 'Shelf', 'Port', 'MCSGaugeCapacityBar', 'StockerCapacityBar', 'ZoneCapacityBar'].includes(
43
+ ['Crane', 'CraneRail', 'Shelf', 'Port', 'MCSGaugeCapacityBar', 'StockerCapacityBar', 'StockerCarrierBar', 'StockerAbnormalBar', 'ZoneCapacityBar'].includes(
44
44
  component.state.type
45
45
  )
46
46
  )
47
47
  }
48
48
 
49
- onchangeData(after: Properties, before: Properties): void {
49
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
50
+ /* MCSStatusMixin.onchangeData 의 statusColor → state.fillStyle 동기화 로직을
51
+ * 호출. 3D 경로에서는 mesh material 의 color 가 build 시점 한 번 결정되므로
52
+ * 데이터 변경 시 fillStyle 이 갱신되어야 MCSRealObject3D.onchange 의
53
+ * 'fillStyle in after' 분기를 타고 mesh rebuild 가 발생한다.
54
+ * (2D 는 render() 가 매 프레임 statusColor getter 를 직접 사용하므로 영향 없음.) */
55
+ super.onchangeData(after, before)
56
+
50
57
  /* non-identifiable component 즉, StockerCapacityBar 에게 데이터를 전달함 */
51
58
  this.components.filter(component => !component.isIdentifiable()).forEach(component => (component.data = this.data))
52
59
  }
@@ -0,0 +1,30 @@
1
+ const icon = new URL('../../icons/backdrop.png', import.meta.url).href
2
+
3
+ export default {
4
+ type: 'backdrop',
5
+ description: '3D scene backdrop — 6-sided envelope (4 walls + floor + ceiling)',
6
+ group: ['etc'],
7
+ icon,
8
+ model: {
9
+ type: 'backdrop',
10
+ top: 0,
11
+ left: 0,
12
+ width: 3000,
13
+ height: 2400,
14
+ // depth seeded on creation as min(width, height) by Backdrop's
15
+ // `_syncFromRoot(seedDepth=true)`. Editable via property panel
16
+ // afterward — board-resize syncs won't override it.
17
+ fitToBoard: true,
18
+ alpha: 1,
19
+ // 6면 합성 상태. 비어있으면 backdrop 컴포넌트의 default 가 적용됨
20
+ // (벽 4면 = #dde2e8/0.35, 바닥 = #9c9c9c/1.0, 천장 = #e8e8ec/1.0).
21
+ faces: {
22
+ east: { fillStyle: '#dde2e8', visible: true, alpha: 0.35 },
23
+ west: { fillStyle: '#dde2e8', visible: true, alpha: 0.35 },
24
+ north: { fillStyle: '#dde2e8', visible: true, alpha: 0.35 },
25
+ south: { fillStyle: '#dde2e8', visible: true, alpha: 0.35 },
26
+ floor: { fillStyle: '#9c9c9c', visible: true, alpha: 1.0 },
27
+ ceiling: { fillStyle: '#e8e8ec', visible: true, alpha: 1.0 }
28
+ }
29
+ }
30
+ }