@fmsim/machine 2.0.0-beta.99 → 2.0.1

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 (73) hide show
  1. package/dist/carrier-manager.js +9 -1
  2. package/dist/carrier-manager.js.map +1 -1
  3. package/dist/carrier.js +69 -52
  4. package/dist/carrier.js.map +1 -1
  5. package/dist/crane.js +15 -6
  6. package/dist/crane.js.map +1 -1
  7. package/dist/factories/agv-3d.js +9 -27
  8. package/dist/factories/agv-3d.js.map +1 -1
  9. package/dist/factories/conveyor-3d.js +11 -19
  10. package/dist/factories/conveyor-3d.js.map +1 -1
  11. package/dist/factories/crane-3d.js +8 -17
  12. package/dist/factories/crane-3d.js.map +1 -1
  13. package/dist/factories/crane-rail-3d.js +8 -12
  14. package/dist/factories/crane-rail-3d.js.map +1 -1
  15. package/dist/factories/machine-3d.js +100 -63
  16. package/dist/factories/machine-3d.js.map +1 -1
  17. package/dist/factories/oht-3d.js +9 -27
  18. package/dist/factories/oht-3d.js.map +1 -1
  19. package/dist/factories/shuttle-3d.js +11 -27
  20. package/dist/factories/shuttle-3d.js.map +1 -1
  21. package/dist/features/capacity-gauge-mixin.js +520 -0
  22. package/dist/features/capacity-gauge-mixin.js.map +1 -0
  23. package/dist/features/mcs-status-default.js +13 -0
  24. package/dist/features/mcs-status-default.js.map +1 -1
  25. package/dist/features/parent-object-mixin.js +5 -1
  26. package/dist/features/parent-object-mixin.js.map +1 -1
  27. package/dist/mcs-machine.js +4 -23
  28. package/dist/mcs-machine.js.map +1 -1
  29. package/dist/mcs-unit.js +2 -5
  30. package/dist/mcs-unit.js.map +1 -1
  31. package/dist/mcs-vehicle.js +3 -1
  32. package/dist/mcs-vehicle.js.map +1 -1
  33. package/dist/scene/root-container-override.js +5 -2
  34. package/dist/scene/root-container-override.js.map +1 -1
  35. package/dist/stocker-capacity-bar.js +11 -372
  36. package/dist/stocker-capacity-bar.js.map +1 -1
  37. package/dist/stocker.js +29 -1
  38. package/dist/stocker.js.map +1 -1
  39. package/dist/tsconfig.tsbuildinfo +1 -1
  40. package/dist/utils/crane-position.js +23 -14
  41. package/dist/utils/crane-position.js.map +1 -1
  42. package/dist/utils/stocker-gauge.js +48 -10
  43. package/dist/utils/stocker-gauge.js.map +1 -1
  44. package/dist/zone-capacity-bar.js +13 -213
  45. package/dist/zone-capacity-bar.js.map +1 -1
  46. package/package.json +4 -4
  47. package/src/carrier-manager.ts +9 -1
  48. package/src/carrier.ts +73 -52
  49. package/src/crane.ts +14 -5
  50. package/src/factories/agv-3d.ts +10 -26
  51. package/src/factories/conveyor-3d.ts +12 -19
  52. package/src/factories/crane-3d.ts +9 -17
  53. package/src/factories/crane-rail-3d.ts +9 -13
  54. package/src/factories/machine-3d.ts +125 -66
  55. package/src/factories/oht-3d.ts +10 -26
  56. package/src/factories/shuttle-3d.ts +12 -26
  57. package/src/features/capacity-gauge-mixin.ts +642 -0
  58. package/src/features/mcs-status-default.ts +13 -0
  59. package/src/features/parent-object-mixin.ts +5 -1
  60. package/src/mcs-machine.ts +4 -23
  61. package/src/mcs-unit.ts +2 -5
  62. package/src/mcs-vehicle.ts +3 -1
  63. package/src/scene/root-container-override.ts +5 -2
  64. package/src/stocker-capacity-bar.ts +15 -476
  65. package/src/stocker.ts +29 -1
  66. package/src/utils/crane-position.ts +46 -14
  67. package/src/utils/stocker-gauge.ts +63 -10
  68. package/src/zone-capacity-bar.ts +17 -282
  69. package/translations/en.json +4 -1
  70. package/translations/ja.json +4 -1
  71. package/translations/ko.json +4 -1
  72. package/translations/ms.json +4 -1
  73. package/translations/zh.json +4 -1
@@ -0,0 +1,642 @@
1
+ import { Component, POSITION } from '@hatiolab/things-scene'
2
+ import { themesColorRange } from '@fmsim/api'
3
+ import { LEGEND_CAPACITY, Legend, resolveCarrierLegend } from './mcs-status-default'
4
+ import { safeRound } from '../utils/safe-round'
5
+ import { getValueOnRanges } from '../utils/get-value-on-ranges'
6
+ import { carrierSegments, abnormalSegments, carrierColorsFromLegend, pickSegmentAt, GaugeSegment } from '../utils/stocker-gauge'
7
+
8
+ type Constructor<T = {}> = new (...args: any[]) => T
9
+
10
+ /**
11
+ * 용량 게이지 (통합 로직).
12
+ *
13
+ * 하나의 컴포넌트가 mode 에 따라 3가지 게이지를 표현한다:
14
+ * - 'capacity' : 용량 사용률(usage%) 채움 게이지 + high/low watermark (기본)
15
+ * - 'carrier' : 캐리어 상태비율 세그먼트 (Full / Empty / EmptyEmpty)
16
+ * - 'abnormal' : 캐리어 정상/비정상 비율 세그먼트 (Normal / Abnormal)
17
+ *
18
+ * StockerCapacityBar(스토커 지붕 게이지)와 ZoneCapacityBar(존 게이지)가 이 로직을 공유한다.
19
+ * 두 컴포넌트는 base(스토커=Shape, 존=Container)·정체성(identifiable)·legend 폴백만 다르고,
20
+ * 게이지를 그리는 규칙은 완전히 동일하다 — 규칙을 이 믹스인 한 곳에 두어 drift 를 막는다.
21
+ *
22
+ * mode 소스: root.stockerGaugeMode (기본 'capacity'). RootContainer 의 런타임 전용
23
+ * transient 속성으로, 3D(machine-3d buildGauge)와도 동일 소스라 2D/3D 게이지가 항상 일치한다.
24
+ * (root-container-override.ts 정의. 모드가 바뀌면 _refreshStockerGauges 가 두 게이지를 갱신.)
25
+ *
26
+ * 데이터 필드(2D 기준, canonical):
27
+ * - capacity : CURRENTCAPACITY / MAXCAPACITY / HIGHWATERMARK / LOWWATERMARK / FULLUP
28
+ * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY
29
+ * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL
30
+ */
31
+
32
+ /** 게이지 NATURE 속성 조각 (스토커/존 공통). 각 컴포넌트 NATURE 에 spread 로 합친다. */
33
+ export const CapacityGaugeMixinProperties = [
34
+ {
35
+ type: 'select',
36
+ label: 'legend-name',
37
+ name: 'legendName',
38
+ property: {
39
+ options: themesColorRange
40
+ }
41
+ },
42
+ {
43
+ type: 'number',
44
+ name: 'currentUsage',
45
+ label: 'current-usage'
46
+ },
47
+ {
48
+ type: 'number',
49
+ name: 'highWatermark',
50
+ label: 'high-watermark'
51
+ },
52
+ {
53
+ type: 'number',
54
+ name: 'lowWatermark',
55
+ label: 'low-watermark'
56
+ },
57
+ {
58
+ type: 'number',
59
+ name: 'maxCapacity',
60
+ label: 'max-capacity'
61
+ },
62
+ {
63
+ type: 'boolean',
64
+ name: 'fullup',
65
+ label: 'fullup'
66
+ },
67
+ {
68
+ type: 'number',
69
+ name: 'carrierFull',
70
+ label: 'carrier-full'
71
+ },
72
+ {
73
+ type: 'number',
74
+ name: 'carrierEmpty',
75
+ label: 'carrier-empty'
76
+ },
77
+ {
78
+ type: 'number',
79
+ name: 'carrierEmptyEmpty',
80
+ label: 'carrier-empty-empty'
81
+ },
82
+ {
83
+ type: 'number',
84
+ name: 'carrierNormal',
85
+ label: 'carrier-normal'
86
+ },
87
+ {
88
+ type: 'number',
89
+ name: 'carrierAbnormal',
90
+ label: 'carrier-abnormal'
91
+ },
92
+ {
93
+ type: 'number',
94
+ label: 'round',
95
+ name: 'round',
96
+ property: {
97
+ min: 0
98
+ }
99
+ },
100
+ {
101
+ type: 'hidden',
102
+ name: 'usage',
103
+ label: 'usage'
104
+ }
105
+ ]
106
+
107
+ const controlHandler = {
108
+ ondragmove: function (point: POSITION, index: number, component: Component) {
109
+ var { left, top, width, height } = (component as any).model
110
+
111
+ var transcoorded = (component as any).transcoordP2S(point.x, point.y)
112
+ var round = ((transcoorded.x - left) / (width / 2)) * 100
113
+
114
+ round = safeRound(round, width, height)
115
+
116
+ ;(component as any).set({
117
+ round
118
+ })
119
+ }
120
+ }
121
+
122
+ export function CapacityGaugeMixin<TBase extends Constructor<Component>>(Base: TBase): any {
123
+ return class extends Base {
124
+ /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */
125
+ get gaugeMode(): string {
126
+ return (this as any).root?.stockerGaugeMode || 'capacity'
127
+ }
128
+
129
+ get controls() {
130
+ var { left, top, width, round, height } = (this as any).state
131
+ round = round == undefined ? 0 : safeRound(round, width, height)
132
+
133
+ return [
134
+ {
135
+ x: left + (width / 2) * (round / 100),
136
+ y: top,
137
+ handler: controlHandler
138
+ }
139
+ ]
140
+ }
141
+
142
+ getTheme() {
143
+ const { legendName } = (this as any).state
144
+
145
+ if (legendName) {
146
+ return (this as any).root?.style?.[legendName]
147
+ }
148
+ }
149
+
150
+ get legend(): Legend {
151
+ return this.getTheme() || this.getLegendFallback()
152
+ }
153
+
154
+ /** legend 미지정 시 폴백 테마. 컴포넌트별로 override (스토커/존 각자 전역 테마). */
155
+ getLegendFallback(): Legend {
156
+ return LEGEND_CAPACITY
157
+ }
158
+
159
+ get statusColor(): string | undefined {
160
+ const { fullup } = (this as any).state
161
+ return fullup ? 'red' : getValueOnRanges(this.usage, this.legend)
162
+ }
163
+
164
+ get text() {
165
+ // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로
166
+ // base 의 value 텍스트를 억제한다.
167
+ if (this.gaugeMode !== 'capacity') {
168
+ return ''
169
+ }
170
+ return this.usage + '%'
171
+ }
172
+
173
+ render(context: CanvasRenderingContext2D) {
174
+ const mode = this.gaugeMode
175
+
176
+ if (mode === 'carrier' || mode === 'abnormal') {
177
+ // carrier/abnormal 모드는 세그먼트 값이 전부 0(데이터 없음)일 때 빈 막대 대신 '0%' 를 표시한다.
178
+ this.renderSegmentGauge(context, this.segments(mode), true)
179
+ return
180
+ }
181
+
182
+ // capacity (기본)
183
+ const { width, height } = (this as any).bounds
184
+ if (width >= height) {
185
+ this.renderLandscape(context)
186
+ } else {
187
+ this.renderPortrait(context)
188
+ }
189
+ }
190
+
191
+ /**
192
+ * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산).
193
+ * 배열 순서 = portrait 아래→위 / landscape 왼→오.
194
+ */
195
+ segments(mode: string): GaugeSegment[] {
196
+ if (mode === 'abnormal') {
197
+ const { carrierNormal = 0, carrierAbnormal = 0 } = (this as any).state
198
+ return abnormalSegments(carrierNormal, carrierAbnormal)
199
+ }
200
+ const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = (this as any).state
201
+ // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득 (carrierColorsFromLegend).
202
+ const colors = carrierColorsFromLegend(resolveCarrierLegend(this))
203
+ return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty, colors)
204
+ }
205
+
206
+ /**
207
+ * 클릭(또는 hover) 지점이 어떤 세그먼트에 속하는지 판정. carrier/abnormal 모드 전용 —
208
+ * capacity 모드·데이터 없음·좌표 변환 실패 시 null.
209
+ *
210
+ * renderSegmentGauge 와 동일한 배치 규칙으로 매핑한다: landscape(가로) 는 왼→오(누적 x),
211
+ * portrait(세로) 는 아래→위(바닥부터 누적). ratio 0 세그먼트는 화면에 없으므로 건너뛴다.
212
+ */
213
+ segmentAtEvent(event: MouseEvent): { mode: string; key: string; count: number } | null {
214
+ const mode = this.gaugeMode
215
+ if (mode !== 'carrier' && mode !== 'abnormal') return null
216
+
217
+ // 3D 게이지는 부모 STOCKER 의 real-object 가 그린다(이 컴포넌트가 아님) → 2D 세그먼트 판정 대상 아님.
218
+ if ((this as any).root?.is3dMode) return null
219
+
220
+ const ox = (event as any)?.offsetX
221
+ const oy = (event as any)?.offsetY
222
+ if (typeof ox !== 'number' || typeof oy !== 'number') return null
223
+
224
+ // 표면 픽셀(offsetX/offsetY) → 이 컴포넌트 로컬 프레임.
225
+ // 주의: transcoordP2S 는 '부모→로컬' 한 단계라 캔버스 픽셀로 단독 호출하면 안 된다.
226
+ // 엔진 capture() 와 동일하게 루트부터 전체 체인을 적용하는 helper 가 toLocal.
227
+ let local: { x: number; y: number } | undefined
228
+ try {
229
+ local = (this as any).toLocal(ox, oy)
230
+ } catch {
231
+ return null
232
+ }
233
+ if (!local) return null
234
+
235
+ const { left, top, width, height } = (this as any).bounds
236
+ if (!(width > 0) || !(height > 0)) return null
237
+
238
+ // 컴포넌트 로컬프레임 좌표 → bounds 기준 within-box. 경계 반올림 방어로 clamp.
239
+ const lx = Math.min(Math.max(local.x - left, 0), width)
240
+ const ly = Math.min(Math.max(local.y - top, 0), height)
241
+
242
+ const landscape = width >= height
243
+ const pos = landscape ? lx : height - ly // portrait 는 바닥 기준 거리
244
+ const span = landscape ? width : height
245
+
246
+ const picked = pickSegmentAt(this.segments(mode), pos, span)
247
+ if (!picked) return null
248
+
249
+ // label 은 key 로 도출 가능하고 로케일 의존이라 payload 에서 제외 — key(대문자 토큰)로 식별.
250
+ return { mode, key: picked.key, count: picked.count }
251
+ }
252
+
253
+ /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */
254
+ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[], zeroPercentWhenEmpty = false) {
255
+ const { left, top, width, height } = (this as any).bounds
256
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).state
257
+ const landscape = width >= height
258
+
259
+ context.save()
260
+ context.translate(left, top)
261
+
262
+ // background
263
+ context.beginPath()
264
+ context.fillStyle = fillStyle
265
+ context.roundRect(0, 0, width, height, round)
266
+ context.fill()
267
+ context.clip()
268
+
269
+ if (landscape) {
270
+ // 왼→오
271
+ let x = 0
272
+ for (const seg of segments) {
273
+ const segWidth = width * seg.ratio
274
+ if (segWidth <= 0) continue
275
+
276
+ context.beginPath()
277
+ context.fillStyle = seg.color
278
+ context.rect(x, 0, segWidth, height)
279
+ context.fill()
280
+
281
+ this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
282
+ x += segWidth
283
+ }
284
+ } else {
285
+ // 아래→위 (배열 첫 세그먼트가 바닥)
286
+ let y = height
287
+ for (const seg of segments) {
288
+ const segHeight = height * seg.ratio
289
+ if (segHeight <= 0) continue
290
+
291
+ y -= segHeight
292
+
293
+ context.beginPath()
294
+ context.fillStyle = seg.color
295
+ context.rect(0, y, width, segHeight)
296
+ context.fill()
297
+
298
+ this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
299
+ }
300
+ }
301
+
302
+ // 데이터가 전혀 없어(모든 ratio 0) 세그먼트가 하나도 안 그려진 경우 —
303
+ // carrier/abnormal 모드는 빈 막대 대신 '0%' 를 막대 중앙에 표시한다.
304
+ if (zeroPercentWhenEmpty && segments.every(s => s.ratio <= 0)) {
305
+ this._drawSegmentLabel(
306
+ context,
307
+ { key: 'EMPTY', label: '', count: 0, ratio: 0, color: '' },
308
+ 0,
309
+ 0,
310
+ width,
311
+ height
312
+ )
313
+ }
314
+
315
+ // outline
316
+ context.beginPath()
317
+ context.setLineDash([])
318
+ context.strokeStyle = strokeStyle
319
+ context.lineWidth = lineWidth
320
+ context.roundRect(0, 0, width, height, round)
321
+ context.stroke()
322
+
323
+ context.translate(-left, -top)
324
+ context.restore()
325
+ }
326
+
327
+ private _drawSegmentLabel(
328
+ context: CanvasRenderingContext2D,
329
+ seg: GaugeSegment,
330
+ x: number,
331
+ y: number,
332
+ w: number,
333
+ h: number
334
+ ) {
335
+ // 이름 라벨(Full/Empty/Abnormal…)은 그리지 않는다. 각 세그먼트 영역 중심에 숫자(%)만 표시.
336
+ // 컴포넌트의 폰트 설정(fontFamily/fontSize/bold/italic)을 그대로 적용 — this.font.
337
+ const fontSize = (this as any).state.fontSize || 15
338
+ context.font = (this as any).font
339
+
340
+ // 공간에 따라 단계적 축소: 넉넉하면 '값%', 좁으면 '%' 떼고 값만, 더 좁으면 생략.
341
+ const value = `${Math.round(seg.ratio * 100)}`
342
+ const fits = (t: string) => fontSize <= h && context.measureText(t).width <= w
343
+
344
+ let text: string | null = null
345
+ if (fits(`${value}%`)) {
346
+ text = `${value}%`
347
+ } else if (fits(value)) {
348
+ text = value
349
+ }
350
+ if (text === null) {
351
+ return
352
+ }
353
+
354
+ context.fillStyle = '#333'
355
+ context.textAlign = 'center'
356
+ context.textBaseline = 'middle'
357
+ context.fillText(text, x + w / 2, y + h / 2)
358
+ }
359
+
360
+ renderLandscape(context: CanvasRenderingContext2D) {
361
+ const { left, top, width, height } = (this as any).bounds
362
+ const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).state
363
+
364
+ context.save()
365
+ context.translate(left, top)
366
+
367
+ // gauge background
368
+ context.beginPath()
369
+
370
+ context.fillStyle = fillStyle
371
+ context.roundRect(0, 0, width, height, round)
372
+
373
+ context.fill()
374
+
375
+ context.clip()
376
+
377
+ // gauge foreground
378
+ context.beginPath()
379
+
380
+ context.fillStyle = this.statusColor!
381
+
382
+ context.rect(0, 0, (width / 100) * this.usage, height)
383
+ context.fill()
384
+
385
+ // high-watermark level
386
+
387
+ if (highWatermark && highWatermark > 0 && highWatermark < 100) {
388
+ context.beginPath()
389
+
390
+ context.strokeStyle = 'black'
391
+ context.lineWidth = 1
392
+ context.setLineDash([3])
393
+
394
+ const x = (width / 100) * highWatermark
395
+ context.moveTo(x, 0)
396
+ context.lineTo(x, height)
397
+
398
+ context.stroke()
399
+ }
400
+
401
+ // low-watermark level
402
+
403
+ if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) {
404
+ context.beginPath()
405
+
406
+ context.strokeStyle = 'black'
407
+ context.lineWidth = 1
408
+ context.setLineDash([3])
409
+
410
+ const x = (width / 100) * lowWatermark
411
+ context.moveTo(x, 0)
412
+ context.lineTo(x, height)
413
+
414
+ context.stroke()
415
+ }
416
+
417
+ // gauge outline
418
+ context.beginPath()
419
+
420
+ context.setLineDash([])
421
+ context.strokeStyle = strokeStyle
422
+ context.lineWidth = lineWidth
423
+ context.roundRect(0, 0, width, height, round)
424
+ context.stroke()
425
+
426
+ context.beginPath()
427
+
428
+ context.translate(-left, -top)
429
+ context.restore()
430
+ }
431
+
432
+ renderPortrait(context: CanvasRenderingContext2D) {
433
+ const { left, top, width, height } = (this as any).bounds
434
+ const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).state
435
+
436
+ context.save()
437
+ context.translate(left, top)
438
+
439
+ // gauge background
440
+ context.beginPath()
441
+
442
+ context.fillStyle = fillStyle
443
+ context.roundRect(0, 0, width, height, round)
444
+
445
+ context.fill()
446
+
447
+ context.clip()
448
+
449
+ // gauge foreground
450
+ context.beginPath()
451
+
452
+ context.fillStyle = this.statusColor!
453
+
454
+ context.rect(0, height - (height / 100) * this.usage, width, (height / 100) * this.usage)
455
+ context.fill()
456
+
457
+ // high-watermark level
458
+
459
+ if (highWatermark && highWatermark > 0 && highWatermark < 100) {
460
+ context.beginPath()
461
+
462
+ context.strokeStyle = 'black'
463
+ context.lineWidth = 1
464
+ context.setLineDash([3])
465
+
466
+ const y = height - (height / 100) * highWatermark
467
+ context.moveTo(0, y)
468
+ context.lineTo(width, y)
469
+
470
+ context.stroke()
471
+ }
472
+
473
+ // low-watermark level
474
+
475
+ if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) {
476
+ context.beginPath()
477
+
478
+ context.strokeStyle = 'black'
479
+ context.lineWidth = 1
480
+ context.setLineDash([3])
481
+
482
+ const y = height - (height / 100) * lowWatermark
483
+ context.moveTo(0, y)
484
+ context.lineTo(width, y)
485
+
486
+ context.stroke()
487
+ }
488
+
489
+ // gauge outline
490
+ context.beginPath()
491
+
492
+ context.setLineDash([])
493
+ context.strokeStyle = strokeStyle
494
+ context.lineWidth = lineWidth
495
+ context.roundRect(0, 0, width, height, round)
496
+ context.stroke()
497
+
498
+ context.beginPath()
499
+
500
+ context.translate(-left, -top)
501
+ context.restore()
502
+ }
503
+
504
+ get usage() {
505
+ const { currentUsage = 0, maxCapacity = 100 } = (this as any).state
506
+ return Math.round((currentUsage / maxCapacity) * 100)
507
+ }
508
+
509
+ set usage(usage: number) {
510
+ // intentionally have done nothing
511
+ }
512
+
513
+ onchangeData(after: Record<string, any>, before: Record<string, any>): void {
514
+ const data = (this as any).data && typeof (this as any).data == 'object' ? (this as any).data : {}
515
+ const {
516
+ CURRENTCAPACITY = 0,
517
+ MAXCAPACITY = 100,
518
+ HIGHWATERMARK = 100,
519
+ LOWWATERMARK = 0,
520
+ FULLUP = 'F',
521
+ CARRIER_FULL = 0,
522
+ CARRIER_EMPTY = 0,
523
+ CARRIER_EMPTY_EMPTY = 0,
524
+ CARRIER_NORMAL = 0,
525
+ CARRIER_ABNORMAL = 0
526
+ } = data
527
+
528
+ const currentUsage = Number(CURRENTCAPACITY) || 0
529
+ const maxCapacity = Number(MAXCAPACITY) || 100
530
+ const highWatermark = Number(HIGHWATERMARK) || 100
531
+ const lowWatermark = Number(LOWWATERMARK) || 0
532
+
533
+ ;(this as any).setState({
534
+ currentUsage,
535
+ maxCapacity,
536
+ highWatermark,
537
+ lowWatermark,
538
+ fullup: FULLUP == 'T',
539
+ usage: Math.round((currentUsage / maxCapacity) * 100),
540
+ carrierFull: Number(CARRIER_FULL) || 0,
541
+ carrierEmpty: Number(CARRIER_EMPTY) || 0,
542
+ carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0,
543
+ carrierNormal: Number(CARRIER_NORMAL) || 0,
544
+ carrierAbnormal: Number(CARRIER_ABNORMAL) || 0
545
+ })
546
+ }
547
+
548
+ // ── hover 툴팁 (carrier/abnormal 모드 세그먼트별 수치) ───────────────────────────
549
+ // 색상만으로 타입 구분이 어려울 때, 마우스 hover 로 정확한 수치 확인. 뷰어 모드 전용
550
+ // (model-layer 가 편집 모드에선 hover dispatch 안 함).
551
+
552
+ async ready(): Promise<void> {
553
+ await super.ready()
554
+ // hover 이벤트 수신 opt-in: model-layer _onmouseenter_all guard(hasLegacyHover) 통과.
555
+ const ev = (this as any).model.event
556
+ if (!ev || !ev.hover) {
557
+ ;(this as any).model.event = { ...(ev || {}), hover: true }
558
+ }
559
+ }
560
+
561
+ onMouseEnter(): void {
562
+ // capacity 는 값이 한 개(usage%)라 툴팁 불필요. carrier/abnormal 만.
563
+ if (this.gaugeMode === 'capacity') return
564
+ this._showGaugeTooltip()
565
+ }
566
+
567
+ onMouseLeave(): void {
568
+ this._hideGaugeTooltip()
569
+ }
570
+
571
+ dispose(): void {
572
+ this._hideGaugeTooltip()
573
+ super.dispose()
574
+ }
575
+
576
+ private _tooltipEl?: HTMLDivElement
577
+ private _tooltipMove?: (e: MouseEvent) => void
578
+
579
+ private _showGaugeTooltip(): void {
580
+ if (typeof document === 'undefined') return
581
+ const segments = this.segments(this.gaugeMode).filter(s => s.count > 0)
582
+ if (!segments.length) return
583
+
584
+ this._hideGaugeTooltip()
585
+
586
+ const el = document.createElement('div')
587
+ el.style.cssText = [
588
+ 'position:fixed',
589
+ 'left:-9999px',
590
+ 'top:0',
591
+ 'z-index:2147483647',
592
+ 'pointer-events:none',
593
+ 'background:rgba(30,30,30,0.92)',
594
+ 'color:#fff',
595
+ 'padding:6px 9px',
596
+ 'border-radius:6px',
597
+ 'font:12px/1.5 sans-serif',
598
+ 'box-shadow:0 2px 10px rgba(0,0,0,0.35)',
599
+ 'white-space:nowrap'
600
+ ].join(';')
601
+
602
+ for (const seg of segments) {
603
+ const row = document.createElement('div')
604
+ row.style.cssText = 'display:flex;align-items:center;gap:6px'
605
+
606
+ const swatch = document.createElement('span')
607
+ swatch.style.cssText = `display:inline-block;width:10px;height:10px;border-radius:2px;background:${seg.color}`
608
+
609
+ const label = document.createElement('span')
610
+ label.textContent = seg.label.replace(/\n/g, ' ')
611
+ label.style.cssText = 'flex:1'
612
+
613
+ const value = document.createElement('span')
614
+ value.textContent = String(seg.count)
615
+ value.style.cssText = 'font-weight:700;margin-left:12px'
616
+
617
+ row.append(swatch, label, value)
618
+ el.appendChild(row)
619
+ }
620
+
621
+ document.body.appendChild(el)
622
+ this._tooltipEl = el
623
+
624
+ this._tooltipMove = (e: MouseEvent) => {
625
+ el.style.left = `${e.clientX + 14}px`
626
+ el.style.top = `${e.clientY + 14}px`
627
+ }
628
+ document.addEventListener('mousemove', this._tooltipMove, true)
629
+ }
630
+
631
+ private _hideGaugeTooltip(): void {
632
+ if (this._tooltipMove) {
633
+ document.removeEventListener('mousemove', this._tooltipMove, true)
634
+ this._tooltipMove = undefined
635
+ }
636
+ if (this._tooltipEl) {
637
+ this._tooltipEl.remove()
638
+ this._tooltipEl = undefined
639
+ }
640
+ }
641
+ }
642
+ }
@@ -61,6 +61,19 @@ export const LEGEND_CARRIER: Legend = {
61
61
  default: '#F0F0F0'
62
62
  }
63
63
 
64
+ /**
65
+ * carrier legend(테마) 해석 — carrier 컴포넌트(carrier.ts get legend)와 동일 로직 단일 소스.
66
+ * parent 에 carrierLegendName 이 지정되면 그 style 을, 아니면 board 전역 carrierLegendTheme,
67
+ * 둘 다 없으면 LEGEND_CARRIER 기본값. carrier 컴포넌트·스토커 게이지(2D/3D)가 공유한다.
68
+ */
69
+ export function resolveCarrierLegend(component: any): Legend {
70
+ const carrierLegendName = component?.parent?.state?.carrierLegendName
71
+ if (carrierLegendName) {
72
+ return (component.root as any)?.style?.[carrierLegendName] || LEGEND_CARRIER
73
+ }
74
+ return (component.root as any)?.carrierLegendTheme || LEGEND_CARRIER
75
+ }
76
+
64
77
  export const LEGEND_CAPACITY: Legend = {
65
78
  '0~20': '#5630D0',
66
79
  '20~80': '#8BDA5B',
@@ -20,7 +20,11 @@ export function ParentObjectMixin<TBase extends Constructor<Component>>(Base: TB
20
20
  //@ts-ignore
21
21
  const targets = this.root?.findAllById(parentObjectId || id) || []
22
22
  for (const target of targets) {
23
- if (target instanceof MCSMachine || target instanceof MCSUnit) {
23
+ // 자기 자신은 제외 — parentObjectId 미지정 시 id 로 룩업하면 자신이 잡히는데,
24
+ // 자신이 MCSMachine/MCSUnit(예: Crane 은 MCSUnit) 이면 자신을 parentObject 로
25
+ // 반환해버려 host=self→span 0→(0,0) 으로 이동하는 버그. 자신을 건너뛰면
26
+ // 아래 암묵적 this.parent(컨테이너) fallback 으로 정상 해석된다.
27
+ if (target !== this && (target instanceof MCSMachine || target instanceof MCSUnit)) {
24
28
  return target
25
29
  }
26
30
  }