@fmsim/machine 2.0.0-beta.95 → 2.0.0-beta.98

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.
@@ -0,0 +1,62 @@
1
+ /*
2
+ * 스토커 게이지 세그먼트 계산 (2D/3D 공통 단일 소스).
3
+ *
4
+ * 2D(StockerCapacityBar) 와 3D(machine-3d buildGauge) 가 각자 세그먼트 비율/색상을
5
+ * 중복 계산하면 필드/색상 drift 가 발생한다(실제로 carrier 필드명이 어긋난 전례).
6
+ * 이 모듈이 carrier/abnormal 세그먼트 계산의 단일 소스이며 순수 함수라 단위 테스트가
7
+ * 용이하다. 표현(라벨 포맷·watermark·테두리색)은 렌더러별로 다르므로 여기서 다루지 않는다.
8
+ *
9
+ * canonical 데이터 필드(2D 기준):
10
+ * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY
11
+ * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL
12
+ */
13
+ /** carrier 세그먼트 색상 (2D/3D 공통). */
14
+ export const CARRIER_COLORS = {
15
+ full: '#4AA7FE',
16
+ empty: '#8BDA5B',
17
+ emptyEmpty: '#019D59'
18
+ };
19
+ /** abnormal 세그먼트 색상 (2D/3D 공통). */
20
+ export const ABNORMAL_COLORS = {
21
+ normal: '#8BDA5B',
22
+ abnormal: '#F4B4B4'
23
+ };
24
+ function num(v) {
25
+ const n = Number(v);
26
+ return Number.isFinite(n) && n > 0 ? n : 0;
27
+ }
28
+ /** 캐리어 상태 세그먼트 (Full / Empty / EmptyEmpty). total 0 이면 모든 ratio 0. */
29
+ export function carrierSegments(full, empty, emptyEmpty) {
30
+ const f = num(full);
31
+ const e = num(empty);
32
+ const ee = num(emptyEmpty);
33
+ const total = f + e + ee;
34
+ const r = (c) => (total > 0 ? c / total : 0);
35
+ return [
36
+ { label: 'Full', count: f, ratio: r(f), color: CARRIER_COLORS.full },
37
+ { label: 'Empty', count: e, ratio: r(e), color: CARRIER_COLORS.empty },
38
+ { label: 'Empty\nEmpty', count: ee, ratio: r(ee), color: CARRIER_COLORS.emptyEmpty }
39
+ ];
40
+ }
41
+ /** 캐리어 정상/비정상 세그먼트 (Normal / Abnormal). total 0 이면 모든 ratio 0. */
42
+ export function abnormalSegments(normal, abnormal) {
43
+ const n = num(normal);
44
+ const a = num(abnormal);
45
+ const total = n + a;
46
+ const r = (c) => (total > 0 ? c / total : 0);
47
+ return [
48
+ { label: 'Normal', count: n, ratio: r(n), color: ABNORMAL_COLORS.normal },
49
+ { label: 'Abnormal', count: a, ratio: r(a), color: ABNORMAL_COLORS.abnormal }
50
+ ];
51
+ }
52
+ /** 캐리어 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. 3D 처럼 raw data 소스용. */
53
+ export function carrierSegmentsFromData(data) {
54
+ const d = data && typeof data === 'object' ? data : {};
55
+ return carrierSegments(d.CARRIER_FULL, d.CARRIER_EMPTY, d.CARRIER_EMPTY_EMPTY);
56
+ }
57
+ /** 비정상 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. */
58
+ export function abnormalSegmentsFromData(data) {
59
+ const d = data && typeof data === 'object' ? data : {};
60
+ return abnormalSegments(d.CARRIER_NORMAL, d.CARRIER_ABNORMAL);
61
+ }
62
+ //# sourceMappingURL=stocker-gauge.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"stocker-gauge.js","sourceRoot":"","sources":["../../src/utils/stocker-gauge.ts"],"names":[],"mappings":"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","sourcesContent":["/*\n * 스토커 게이지 세그먼트 계산 (2D/3D 공통 단일 소스).\n *\n * 2D(StockerCapacityBar) 와 3D(machine-3d buildGauge) 가 각자 세그먼트 비율/색상을\n * 중복 계산하면 필드/색상 drift 가 발생한다(실제로 carrier 필드명이 어긋난 전례).\n * 이 모듈이 carrier/abnormal 세그먼트 계산의 단일 소스이며 순수 함수라 단위 테스트가\n * 용이하다. 표현(라벨 포맷·watermark·테두리색)은 렌더러별로 다르므로 여기서 다루지 않는다.\n *\n * canonical 데이터 필드(2D 기준):\n * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY\n * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL\n */\n\nexport type StockerGaugeMode = 'capacity' | 'carrier' | 'abnormal'\n\nexport interface GaugeSegment {\n /** 세그먼트 라벨(2D per-segment 표시용, '\\n' 은 줄바꿈). */\n label: string\n /** 카운트 원본값. */\n count: number\n /** 전체 대비 비율 (0~1). total 이 0 이면 0. */\n ratio: number\n /** 채움 색상. */\n color: string\n}\n\n/** carrier 세그먼트 색상 (2D/3D 공통). */\nexport const CARRIER_COLORS = {\n full: '#4AA7FE',\n empty: '#8BDA5B',\n emptyEmpty: '#019D59'\n}\n\n/** abnormal 세그먼트 색상 (2D/3D 공통). */\nexport const ABNORMAL_COLORS = {\n normal: '#8BDA5B',\n abnormal: '#F4B4B4'\n}\n\nfunction num(v: unknown): number {\n const n = Number(v)\n return Number.isFinite(n) && n > 0 ? n : 0\n}\n\n/** 캐리어 상태 세그먼트 (Full / Empty / EmptyEmpty). total 0 이면 모든 ratio 0. */\nexport function carrierSegments(full: unknown, empty: unknown, emptyEmpty: unknown): GaugeSegment[] {\n const f = num(full)\n const e = num(empty)\n const ee = num(emptyEmpty)\n const total = f + e + ee\n const r = (c: number) => (total > 0 ? c / total : 0)\n return [\n { label: 'Full', count: f, ratio: r(f), color: CARRIER_COLORS.full },\n { label: 'Empty', count: e, ratio: r(e), color: CARRIER_COLORS.empty },\n { label: 'Empty\\nEmpty', count: ee, ratio: r(ee), color: CARRIER_COLORS.emptyEmpty }\n ]\n}\n\n/** 캐리어 정상/비정상 세그먼트 (Normal / Abnormal). total 0 이면 모든 ratio 0. */\nexport function abnormalSegments(normal: unknown, abnormal: unknown): GaugeSegment[] {\n const n = num(normal)\n const a = num(abnormal)\n const total = n + a\n const r = (c: number) => (total > 0 ? c / total : 0)\n return [\n { label: 'Normal', count: n, ratio: r(n), color: ABNORMAL_COLORS.normal },\n { label: 'Abnormal', count: a, ratio: r(a), color: ABNORMAL_COLORS.abnormal }\n ]\n}\n\n/** 캐리어 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. 3D 처럼 raw data 소스용. */\nexport function carrierSegmentsFromData(data: any): GaugeSegment[] {\n const d = data && typeof data === 'object' ? data : {}\n return carrierSegments(d.CARRIER_FULL, d.CARRIER_EMPTY, d.CARRIER_EMPTY_EMPTY)\n}\n\n/** 비정상 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. */\nexport function abnormalSegmentsFromData(data: any): GaugeSegment[] {\n const d = data && typeof data === 'object' ? data : {}\n return abnormalSegments(d.CARRIER_NORMAL, d.CARRIER_ABNORMAL)\n}\n"]}
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.95",
5
+ "version": "2.0.0-beta.98",
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": "9281405a837f538acf8fc2955f2c2f79964cd360"
58
+ "gitHead": "d1b766f96c1c9656d8cb950f4b70cb5b583969e5"
59
59
  }
@@ -15,6 +15,12 @@ import type { Component } from '@hatiolab/things-scene'
15
15
  import { LEGEND_CAPACITY } from '../features/mcs-status-default'
16
16
  import { getValueOnRanges } from '../utils/get-value-on-ranges'
17
17
  import { fitGaugeScale } from '../utils/fit-gauge-scale'
18
+ import {
19
+ carrierSegmentsFromData,
20
+ abnormalSegmentsFromData,
21
+ CARRIER_COLORS,
22
+ ABNORMAL_COLORS
23
+ } from '../utils/stocker-gauge'
18
24
  import { Z_FMSIM } from './z-priority'
19
25
 
20
26
  // ── 설정 타입 ────────────────────────────────────────────
@@ -654,7 +660,7 @@ class StockerRealObject3D extends MCSRealObject3D {
654
660
  private buildGauge() {
655
661
  const comp = this.component as any
656
662
  const data = comp.data || {}
657
- const gaugeMode = comp.root?.style?.stockerGaugeMode || 'capacity'
663
+ const gaugeMode = comp.root?.stockerGaugeMode || 'capacity'
658
664
 
659
665
  const gauge = this.resolveGauge(data, gaugeMode)
660
666
 
@@ -741,37 +747,28 @@ class StockerRealObject3D extends MCSRealObject3D {
741
747
  private resolveGauge(data: any, mode: string) {
742
748
  switch (mode) {
743
749
  case 'carrier': {
744
- const full = Number(data.FULL_CNT) || 0
745
- const empty = Number(data.EMPTY_CNT) || 0
746
- const emptyEmpty = Number(data.EMPTYEMPTY_CNT) || 0
747
- const total = full + empty + emptyEmpty
750
+ // 세그먼트 비율/색상은 2D 와 공유되는 단일 소스에서 계산 (필드/색상 drift 방지)
751
+ const [f, e, ee] = carrierSegmentsFromData(data)
752
+ const total = f.count + e.count + ee.count
748
753
  const segments = total > 0
749
- ? [
750
- { ratio: full / total, color: '#4AA7FE' },
751
- { ratio: empty / total, color: '#8BDA5B' },
752
- { ratio: emptyEmpty / total, color: '#019D59' },
753
- ]
754
+ ? [f, e, ee].map(s => ({ ratio: s.ratio, color: s.color }))
754
755
  : [{ ratio: 1, color: '#dddddd' }]
755
756
  return {
756
757
  segments,
757
- borderColor: '#4AA7FE',
758
- label: total > 0 ? `F${full} E${empty} EE${emptyEmpty}` : '',
758
+ borderColor: CARRIER_COLORS.full,
759
+ label: total > 0 ? `F${f.count} E${e.count} EE${ee.count}` : '',
759
760
  }
760
761
  }
761
762
  case 'abnormal': {
762
- const normal = Number(data.CARRIER_NORMAL) || 0
763
- const abnormal = Number(data.CARRIER_ABNORMAL) || 0
764
- const total = normal + abnormal
763
+ const [n, a] = abnormalSegmentsFromData(data)
764
+ const total = n.count + a.count
765
765
  const segments = total > 0
766
- ? [
767
- { ratio: normal / total, color: '#8BDA5B' },
768
- { ratio: abnormal / total, color: '#F4B4B4' },
769
- ]
766
+ ? [n, a].map(s => ({ ratio: s.ratio, color: s.color }))
770
767
  : [{ ratio: 1, color: '#dddddd' }]
771
768
  return {
772
769
  segments,
773
- borderColor: abnormal > 0 ? '#F4B4B4' : '#8BDA5B',
774
- label: total > 0 ? `N${normal} A${abnormal}` : '',
770
+ borderColor: a.count > 0 ? ABNORMAL_COLORS.abnormal : ABNORMAL_COLORS.normal,
771
+ label: total > 0 ? `N${n.count} A${a.count}` : '',
775
772
  }
776
773
  }
777
774
  default: { // capacity
@@ -25,6 +25,10 @@ declare module '@hatiolab/things-scene' {
25
25
  equipmentLegendTheme?: any
26
26
  style: any
27
27
  vehicleAnimation?: boolean
28
+ // 스토커 게이지 표시 모드 — 런타임 전용 transient (모델 비저장, 항상 'capacity' 시작)
29
+ stockerGaugeMode: string
30
+ _stockerGaugeMode?: string
31
+ _refreshStockerGauges(): void
28
32
  // 캐시 관련 속성 추가
29
33
  _animationConfigCache?: Map<string, any>
30
34
  _carrierManager?: CarrierManager
@@ -32,6 +36,17 @@ declare module '@hatiolab/things-scene' {
32
36
  }
33
37
  }
34
38
 
39
+ /**
40
+ * 스토커 적재율 게이지 표시 모드 — 런타임 선택지.
41
+ * value 는 엔진 canonical(2D StockerCapacityBar / 3D buildGauge 공통), label 은 표시명.
42
+ * 런타임 UI(뷰어 컨트롤)가 이 목록으로 selector 를 구성한다.
43
+ */
44
+ export const STOCKER_GAUGE_MODES: { value: string; label: string }[] = [
45
+ { value: 'capacity', label: 'Capacity' },
46
+ { value: 'carrier', label: 'Carrier Status' },
47
+ { value: 'abnormal', label: 'Abnormal Carrier' }
48
+ ]
49
+
35
50
  const ModelLayer = (Component as any).register('model-layer') as any
36
51
 
37
52
  Object.defineProperty(ModelLayer.prototype, 'nature', {
@@ -318,3 +333,47 @@ Object.defineProperty(RootContainer.prototype, 'carrierManager', {
318
333
  return this._carrierManager
319
334
  }
320
335
  })
336
+
337
+ // === 스토커 게이지 표시 모드 (런타임 전용 transient) ===
338
+ //
339
+ // 모델(state)에 저장하지 않는다 — nature 속성이 아니므로 보드 직렬화에 포함되지 않고,
340
+ // 보드를 다시 열면 항상 기본값 'capacity' 로 시작한다. 실행 중 뷰어 컨트롤 등이
341
+ // `scene.rootContainer.stockerGaugeMode = 'carrier'` 로 동적 변경한다.
342
+ // (주의: scene.root 은 model_layer 라 override 가 없다. 반드시 rootContainer 에 설정.
343
+ // 컴포넌트의 this.root 는 RootContainer(isRoot)로 해석되어 이 값을 읽는다.)
344
+ //
345
+ // 2D(StockerCapacityBar.gaugeMode) 와 3D(machine-3d buildGauge) 가 모두 이 값을 읽어
346
+ // 게이지 표현이 항상 일치한다. 값이 바뀌면 setter 가 2D 리드로우 + 3D 게이지 재빌드를
347
+ // 즉시 트리거한다.
348
+ Object.defineProperty(RootContainer.prototype, 'stockerGaugeMode', {
349
+ get: function () {
350
+ return this._stockerGaugeMode || 'capacity'
351
+ },
352
+ set: function (mode: string) {
353
+ const next = mode || 'capacity'
354
+ if (this._stockerGaugeMode === next) return
355
+ this._stockerGaugeMode = next
356
+ this._refreshStockerGauges()
357
+ }
358
+ })
359
+
360
+ /** scene 하위 STOCKER(3D 게이지) + StockerCapacityBar(2D 게이지) 를 새 모드로 갱신. */
361
+ RootContainer.prototype._refreshStockerGauges = function () {
362
+ const walk = (container: any) => {
363
+ const kids = container?.components
364
+ if (!Array.isArray(kids)) return
365
+ for (const c of kids) {
366
+ const type = c?.state?.type
367
+ if (type === 'STOCKER') {
368
+ // 3D 게이지는 스토커 자신의 real-object 가 그린다 → 재빌드 (2D 모드면 _realObject 없음)
369
+ c._realObject?.update?.()
370
+ } else if (type === 'StockerCapacityBar' || type === 'MCSGaugeCapacityBar') {
371
+ // 2D 게이지는 자식 컴포넌트가 그린다 → 리드로우
372
+ c.invalidate?.()
373
+ }
374
+ walk(c)
375
+ }
376
+ }
377
+ walk(this)
378
+ this.invalidate?.()
379
+ }
@@ -13,6 +13,26 @@ import { LEGEND_CAPACITY, Legend } from './features/mcs-status-default'
13
13
  import { safeRound } from './utils/safe-round'
14
14
  import { getValueOnRanges } from './utils/get-value-on-ranges'
15
15
  import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin'
16
+ import { carrierSegments, abnormalSegments, 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',
@@ -97,6 +142,11 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
97
142
  return ['STOCKER']
98
143
  }
99
144
 
145
+ /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */
146
+ get gaugeMode(): string {
147
+ return (this.root as any)?.stockerGaugeMode || 'capacity'
148
+ }
149
+
100
150
  get controls() {
101
151
  var { left, top, width, round, height } = this.state
102
152
  round = round == undefined ? 0 : safeRound(round, width, height)
@@ -136,12 +186,24 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
136
186
  }
137
187
 
138
188
  get text() {
189
+ // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로
190
+ // base 의 value 텍스트(value-property=usage)를 억제한다.
191
+ if (this.gaugeMode !== 'capacity') {
192
+ return ''
193
+ }
139
194
  return this.usage + '%'
140
195
  }
141
196
 
142
197
  render(context: CanvasRenderingContext2D) {
143
- const { left, top, width, height } = this.bounds
198
+ const mode = this.gaugeMode
199
+
200
+ if (mode === 'carrier' || mode === 'abnormal') {
201
+ this.renderSegmentGauge(context, this.segments(mode))
202
+ return
203
+ }
144
204
 
205
+ // capacity (기본)
206
+ const { width, height } = this.bounds
145
207
  if (width >= height) {
146
208
  this.renderLandscape(context)
147
209
  } else {
@@ -149,6 +211,123 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
149
211
  }
150
212
  }
151
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
+ return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty)
235
+ }
236
+
237
+ /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */
238
+ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) {
239
+ const { left, top, width, height } = this.bounds
240
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
241
+ const landscape = width >= height
242
+
243
+ context.save()
244
+ context.translate(left, top)
245
+
246
+ // background
247
+ context.beginPath()
248
+ context.fillStyle = fillStyle
249
+ context.roundRect(0, 0, width, height, round)
250
+ context.fill()
251
+ context.clip()
252
+
253
+ if (landscape) {
254
+ // 왼→오
255
+ let x = 0
256
+ for (const seg of segments) {
257
+ const segWidth = width * seg.ratio
258
+ if (segWidth <= 0) continue
259
+
260
+ context.beginPath()
261
+ context.fillStyle = seg.color
262
+ context.rect(x, 0, segWidth, height)
263
+ context.fill()
264
+
265
+ this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
266
+ x += segWidth
267
+ }
268
+ } else {
269
+ // 아래→위 (배열 첫 세그먼트가 바닥)
270
+ let y = height
271
+ for (const seg of segments) {
272
+ const segHeight = height * seg.ratio
273
+ if (segHeight <= 0) continue
274
+
275
+ y -= segHeight
276
+
277
+ context.beginPath()
278
+ context.fillStyle = seg.color
279
+ context.rect(0, y, width, segHeight)
280
+ context.fill()
281
+
282
+ this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
283
+ }
284
+ }
285
+
286
+ // outline
287
+ context.beginPath()
288
+ context.setLineDash([])
289
+ context.strokeStyle = strokeStyle
290
+ context.lineWidth = lineWidth
291
+ context.roundRect(0, 0, width, height, round)
292
+ context.stroke()
293
+
294
+ context.translate(-left, -top)
295
+ context.restore()
296
+ }
297
+
298
+ private _drawSegmentLabel(
299
+ context: CanvasRenderingContext2D,
300
+ seg: GaugeSegment,
301
+ x: number,
302
+ y: number,
303
+ w: number,
304
+ h: number
305
+ ) {
306
+ // 이름 라벨(Full/Empty/Abnormal…)은 그리지 않는다. 각 세그먼트 영역 중심에 숫자(%)만 표시.
307
+ // value 텍스트와 동일하게 state.fontSize 를 따른다 (기본 15). 속성 하나로 함께 조절.
308
+ const fontSize = this.state.fontSize || 15
309
+ context.font = `bold ${fontSize}px sans-serif`
310
+
311
+ // 공간에 따라 단계적 축소: 넉넉하면 '값%', 좁으면 '%' 떼고 값만, 더 좁으면 생략.
312
+ const value = `${Math.round(seg.ratio * 100)}`
313
+ const fits = (t: string) => fontSize <= h && context.measureText(t).width <= w
314
+
315
+ let text: string | null = null
316
+ if (fits(`${value}%`)) {
317
+ text = `${value}%`
318
+ } else if (fits(value)) {
319
+ text = value
320
+ }
321
+ if (text === null) {
322
+ return
323
+ }
324
+
325
+ context.fillStyle = '#333'
326
+ context.textAlign = 'center'
327
+ context.textBaseline = 'middle'
328
+ context.fillText(text, x + w / 2, y + h / 2)
329
+ }
330
+
152
331
  renderLandscape(context: CanvasRenderingContext2D) {
153
332
  const { left, top, width, height } = this.bounds
154
333
  const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
@@ -303,13 +482,19 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
303
482
  }
304
483
 
305
484
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
485
+ const data = this.data && typeof this.data == 'object' ? this.data : {}
306
486
  const {
307
487
  CURRENTCAPACITY = 0,
308
488
  MAXCAPACITY = 100,
309
489
  HIGHWATERMARK = 100,
310
490
  LOWWATERMARK = 0,
311
- FULLUP = 'F'
312
- } = this.data && typeof this.data == 'object' ? this.data : {}
491
+ FULLUP = 'F',
492
+ CARRIER_FULL = 0,
493
+ CARRIER_EMPTY = 0,
494
+ CARRIER_EMPTY_EMPTY = 0,
495
+ CARRIER_NORMAL = 0,
496
+ CARRIER_ABNORMAL = 0
497
+ } = data
313
498
 
314
499
  const currentUsage = Number(CURRENTCAPACITY) || 0
315
500
  const maxCapacity = Number(MAXCAPACITY) || 100
@@ -322,7 +507,12 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
322
507
  highWatermark,
323
508
  lowWatermark,
324
509
  fullup: FULLUP == 'T',
325
- usage: Math.round((currentUsage / maxCapacity) * 100)
510
+ usage: Math.round((currentUsage / maxCapacity) * 100),
511
+ carrierFull: Number(CARRIER_FULL) || 0,
512
+ carrierEmpty: Number(CARRIER_EMPTY) || 0,
513
+ carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0,
514
+ carrierNormal: Number(CARRIER_NORMAL) || 0,
515
+ carrierAbnormal: Number(CARRIER_ABNORMAL) || 0
326
516
  })
327
517
  }
328
518
  }
@@ -0,0 +1,81 @@
1
+ /*
2
+ * 스토커 게이지 세그먼트 계산 (2D/3D 공통 단일 소스).
3
+ *
4
+ * 2D(StockerCapacityBar) 와 3D(machine-3d buildGauge) 가 각자 세그먼트 비율/색상을
5
+ * 중복 계산하면 필드/색상 drift 가 발생한다(실제로 carrier 필드명이 어긋난 전례).
6
+ * 이 모듈이 carrier/abnormal 세그먼트 계산의 단일 소스이며 순수 함수라 단위 테스트가
7
+ * 용이하다. 표현(라벨 포맷·watermark·테두리색)은 렌더러별로 다르므로 여기서 다루지 않는다.
8
+ *
9
+ * canonical 데이터 필드(2D 기준):
10
+ * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY
11
+ * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL
12
+ */
13
+
14
+ export type StockerGaugeMode = 'capacity' | 'carrier' | 'abnormal'
15
+
16
+ export interface GaugeSegment {
17
+ /** 세그먼트 라벨(2D per-segment 표시용, '\n' 은 줄바꿈). */
18
+ label: string
19
+ /** 카운트 원본값. */
20
+ count: number
21
+ /** 전체 대비 비율 (0~1). total 이 0 이면 0. */
22
+ ratio: number
23
+ /** 채움 색상. */
24
+ color: string
25
+ }
26
+
27
+ /** carrier 세그먼트 색상 (2D/3D 공통). */
28
+ export const CARRIER_COLORS = {
29
+ full: '#4AA7FE',
30
+ empty: '#8BDA5B',
31
+ emptyEmpty: '#019D59'
32
+ }
33
+
34
+ /** abnormal 세그먼트 색상 (2D/3D 공통). */
35
+ export const ABNORMAL_COLORS = {
36
+ normal: '#8BDA5B',
37
+ abnormal: '#F4B4B4'
38
+ }
39
+
40
+ function num(v: unknown): number {
41
+ const n = Number(v)
42
+ return Number.isFinite(n) && n > 0 ? n : 0
43
+ }
44
+
45
+ /** 캐리어 상태 세그먼트 (Full / Empty / EmptyEmpty). total 0 이면 모든 ratio 0. */
46
+ export function carrierSegments(full: unknown, empty: unknown, emptyEmpty: unknown): GaugeSegment[] {
47
+ const f = num(full)
48
+ const e = num(empty)
49
+ const ee = num(emptyEmpty)
50
+ const total = f + e + ee
51
+ const r = (c: number) => (total > 0 ? c / total : 0)
52
+ return [
53
+ { label: 'Full', count: f, ratio: r(f), color: CARRIER_COLORS.full },
54
+ { label: 'Empty', count: e, ratio: r(e), color: CARRIER_COLORS.empty },
55
+ { label: 'Empty\nEmpty', count: ee, ratio: r(ee), color: CARRIER_COLORS.emptyEmpty }
56
+ ]
57
+ }
58
+
59
+ /** 캐리어 정상/비정상 세그먼트 (Normal / Abnormal). total 0 이면 모든 ratio 0. */
60
+ export function abnormalSegments(normal: unknown, abnormal: unknown): GaugeSegment[] {
61
+ const n = num(normal)
62
+ const a = num(abnormal)
63
+ const total = n + a
64
+ const r = (c: number) => (total > 0 ? c / total : 0)
65
+ return [
66
+ { label: 'Normal', count: n, ratio: r(n), color: ABNORMAL_COLORS.normal },
67
+ { label: 'Abnormal', count: a, ratio: r(a), color: ABNORMAL_COLORS.abnormal }
68
+ ]
69
+ }
70
+
71
+ /** 캐리어 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. 3D 처럼 raw data 소스용. */
72
+ export function carrierSegmentsFromData(data: any): GaugeSegment[] {
73
+ const d = data && typeof data === 'object' ? data : {}
74
+ return carrierSegments(d.CARRIER_FULL, d.CARRIER_EMPTY, d.CARRIER_EMPTY_EMPTY)
75
+ }
76
+
77
+ /** 비정상 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. */
78
+ export function abnormalSegmentsFromData(data: any): GaugeSegment[] {
79
+ const d = data && typeof data === 'object' ? data : {}
80
+ return abnormalSegments(d.CARRIER_NORMAL, d.CARRIER_ABNORMAL)
81
+ }
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "label.current-usage": "current usage",
3
3
  "label.fullup": "fullup",
4
+ "label.carrier-full": "carrier full",
5
+ "label.carrier-empty": "carrier empty",
6
+ "label.carrier-empty-empty": "carrier empty empty",
7
+ "label.carrier-normal": "carrier normal",
8
+ "label.carrier-abnormal": "carrier abnormal",
4
9
  "label.high-watermark": "high watermark",
5
10
  "label.legend-name": "legend",
6
11
  "label.max-capacity": "max capacity",
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "label.current-usage": "current usage",
3
3
  "label.fullup": "fullup",
4
+ "label.carrier-full": "carrier full",
5
+ "label.carrier-empty": "carrier empty",
6
+ "label.carrier-empty-empty": "carrier empty empty",
7
+ "label.carrier-normal": "carrier normal",
8
+ "label.carrier-abnormal": "carrier abnormal",
4
9
  "label.high-watermark": "high watermark",
5
10
  "label.low-watermark": "low watermark",
6
11
  "label.legend-name": "legend",
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "label.current-usage": "현재 사용량",
3
3
  "label.fullup": "만재",
4
+ "label.carrier-full": "적재 캐리어",
5
+ "label.carrier-empty": "공 캐리어",
6
+ "label.carrier-empty-empty": "이중 공 캐리어",
7
+ "label.carrier-normal": "정상 캐리어",
8
+ "label.carrier-abnormal": "비정상 캐리어",
4
9
  "label.high-watermark": "고수위워터마크",
5
10
  "label.low-watermark": "저수위워터마크",
6
11
  "label.legend-name": "레전드",
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "label.current-usage": "current usage",
3
3
  "label.fullup": "fullup",
4
+ "label.carrier-full": "carrier full",
5
+ "label.carrier-empty": "carrier empty",
6
+ "label.carrier-empty-empty": "carrier empty empty",
7
+ "label.carrier-normal": "carrier normal",
8
+ "label.carrier-abnormal": "carrier abnormal",
4
9
  "label.high-watermark": "high watermark",
5
10
  "label.low-watermark": "low watermark",
6
11
  "label.legend-name": "legend",
@@ -1,6 +1,11 @@
1
1
  {
2
2
  "label.current-usage": "current usage",
3
3
  "label.fullup": "fullup",
4
+ "label.carrier-full": "carrier full",
5
+ "label.carrier-empty": "carrier empty",
6
+ "label.carrier-empty-empty": "carrier empty empty",
7
+ "label.carrier-normal": "carrier normal",
8
+ "label.carrier-abnormal": "carrier abnormal",
4
9
  "label.high-watermark": "high watermark",
5
10
  "label.low-watermark": "low watermark",
6
11
  "label.legend-name": "legend",