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

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":"AAAA;;;;;;;;;;;GAWG;AAeH,kCAAkC;AAClC,MAAM,CAAC,MAAM,cAAc,GAAG;IAC5B,IAAI,EAAE,SAAS;IACf,KAAK,EAAE,SAAS;IAChB,UAAU,EAAE,SAAS;CACtB,CAAA;AAED,mCAAmC;AACnC,MAAM,CAAC,MAAM,eAAe,GAAG;IAC7B,MAAM,EAAE,SAAS;IACjB,QAAQ,EAAE,SAAS;CACpB,CAAA;AAED,SAAS,GAAG,CAAC,CAAU;IACrB,MAAM,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAA;IACnB,OAAO,MAAM,CAAC,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;AAC5C,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,eAAe,CAAC,IAAa,EAAE,KAAc,EAAE,UAAmB;IAChF,MAAM,CAAC,GAAG,GAAG,CAAC,IAAI,CAAC,CAAA;IACnB,MAAM,CAAC,GAAG,GAAG,CAAC,KAAK,CAAC,CAAA;IACpB,MAAM,EAAE,GAAG,GAAG,CAAC,UAAU,CAAC,CAAA;IAC1B,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,GAAG,EAAE,CAAA;IACxB,MAAM,CAAC,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACpD,OAAO;QACL,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,cAAc,CAAC,IAAI,EAAE;QACpE,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,cAAc,CAAC,KAAK,EAAE;QACtE,EAAE,KAAK,EAAE,cAAc,EAAE,KAAK,EAAE,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,EAAE,CAAC,EAAE,KAAK,EAAE,cAAc,CAAC,UAAU,EAAE;KACrF,CAAA;AACH,CAAC;AAED,kEAAkE;AAClE,MAAM,UAAU,gBAAgB,CAAC,MAAe,EAAE,QAAiB;IACjE,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,CAAC,CAAA;IACrB,MAAM,CAAC,GAAG,GAAG,CAAC,QAAQ,CAAC,CAAA;IACvB,MAAM,KAAK,GAAG,CAAC,GAAG,CAAC,CAAA;IACnB,MAAM,CAAC,GAAG,CAAC,CAAS,EAAE,EAAE,CAAC,CAAC,KAAK,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC,CAAA;IACpD,OAAO;QACL,EAAE,KAAK,EAAE,QAAQ,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,eAAe,CAAC,MAAM,EAAE;QACzE,EAAE,KAAK,EAAE,UAAU,EAAE,KAAK,EAAE,CAAC,EAAE,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,KAAK,EAAE,eAAe,CAAC,QAAQ,EAAE;KAC9E,CAAA;AACH,CAAC;AAED,0EAA0E;AAC1E,MAAM,UAAU,uBAAuB,CAAC,IAAS;IAC/C,MAAM,CAAC,GAAG,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;IACtD,OAAO,eAAe,CAAC,CAAC,CAAC,YAAY,EAAE,CAAC,CAAC,aAAa,EAAE,CAAC,CAAC,mBAAmB,CAAC,CAAA;AAChF,CAAC;AAED,sDAAsD;AACtD,MAAM,UAAU,wBAAwB,CAAC,IAAS;IAChD,MAAM,CAAC,GAAG,IAAI,IAAI,OAAO,IAAI,KAAK,QAAQ,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAA;IACtD,OAAO,gBAAgB,CAAC,CAAC,CAAC,cAAc,EAAE,CAAC,CAAC,gBAAgB,CAAC,CAAA;AAC/D,CAAC","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.97",
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": "f994681c569562e248ea20178a7749af6c4e050d"
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,45 @@ 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.root.stockerGaugeMode = 'carrier'` 로 동적 변경한다.
342
+ //
343
+ // 2D(StockerCapacityBar.gaugeMode) 와 3D(machine-3d buildGauge) 가 모두 이 값을 읽어
344
+ // 게이지 표현이 항상 일치한다. 값이 바뀌면 setter 가 2D 리드로우 + 3D 게이지 재빌드를
345
+ // 즉시 트리거한다.
346
+ Object.defineProperty(RootContainer.prototype, 'stockerGaugeMode', {
347
+ get: function () {
348
+ return this._stockerGaugeMode || 'capacity'
349
+ },
350
+ set: function (mode: string) {
351
+ const next = mode || 'capacity'
352
+ if (this._stockerGaugeMode === next) return
353
+ this._stockerGaugeMode = next
354
+ this._refreshStockerGauges()
355
+ }
356
+ })
357
+
358
+ /** scene 하위 STOCKER(3D 게이지) + StockerCapacityBar(2D 게이지) 를 새 모드로 갱신. */
359
+ RootContainer.prototype._refreshStockerGauges = function () {
360
+ const walk = (container: any) => {
361
+ const kids = container?.components
362
+ if (!Array.isArray(kids)) return
363
+ for (const c of kids) {
364
+ const type = c?.state?.type
365
+ if (type === 'STOCKER') {
366
+ // 3D 게이지는 스토커 자신의 real-object 가 그린다 → 재빌드 (2D 모드면 _realObject 없음)
367
+ c._realObject?.update?.()
368
+ } else if (type === 'StockerCapacityBar' || type === 'MCSGaugeCapacityBar') {
369
+ // 2D 게이지는 자식 컴포넌트가 그린다 → 리드로우
370
+ c.invalidate?.()
371
+ }
372
+ walk(c)
373
+ }
374
+ }
375
+ walk(this)
376
+ this.invalidate?.()
377
+ }
@@ -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)
@@ -140,8 +190,15 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
140
190
  }
141
191
 
142
192
  render(context: CanvasRenderingContext2D) {
143
- const { left, top, width, height } = this.bounds
193
+ const mode = this.gaugeMode
144
194
 
195
+ if (mode === 'carrier' || mode === 'abnormal') {
196
+ this.renderSegmentGauge(context, this.segments(mode))
197
+ return
198
+ }
199
+
200
+ // capacity (기본)
201
+ const { width, height } = this.bounds
145
202
  if (width >= height) {
146
203
  this.renderLandscape(context)
147
204
  } else {
@@ -149,6 +206,107 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
149
206
  }
150
207
  }
151
208
 
209
+ /**
210
+ * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산).
211
+ * 배열 순서 = portrait 아래→위 / landscape 왼→오.
212
+ */
213
+ segments(mode: string): GaugeSegment[] {
214
+ if (mode === 'abnormal') {
215
+ const { carrierNormal = 0, carrierAbnormal = 0 } = this.state
216
+ return abnormalSegments(carrierNormal, carrierAbnormal)
217
+ }
218
+ const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = this.state
219
+ return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty)
220
+ }
221
+
222
+ /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */
223
+ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) {
224
+ const { left, top, width, height } = this.bounds
225
+ const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
226
+ const landscape = width >= height
227
+
228
+ context.save()
229
+ context.translate(left, top)
230
+
231
+ // background
232
+ context.beginPath()
233
+ context.fillStyle = fillStyle
234
+ context.roundRect(0, 0, width, height, round)
235
+ context.fill()
236
+ context.clip()
237
+
238
+ if (landscape) {
239
+ // 왼→오
240
+ let x = 0
241
+ for (const seg of segments) {
242
+ const segWidth = width * seg.ratio
243
+ if (segWidth <= 0) continue
244
+
245
+ context.beginPath()
246
+ context.fillStyle = seg.color
247
+ context.rect(x, 0, segWidth, height)
248
+ context.fill()
249
+
250
+ this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
251
+ x += segWidth
252
+ }
253
+ } else {
254
+ // 아래→위 (배열 첫 세그먼트가 바닥)
255
+ let y = height
256
+ for (const seg of segments) {
257
+ const segHeight = height * seg.ratio
258
+ if (segHeight <= 0) continue
259
+
260
+ y -= segHeight
261
+
262
+ context.beginPath()
263
+ context.fillStyle = seg.color
264
+ context.rect(0, y, width, segHeight)
265
+ context.fill()
266
+
267
+ this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
268
+ }
269
+ }
270
+
271
+ // outline
272
+ context.beginPath()
273
+ context.setLineDash([])
274
+ context.strokeStyle = strokeStyle
275
+ context.lineWidth = lineWidth
276
+ context.roundRect(0, 0, width, height, round)
277
+ context.stroke()
278
+
279
+ context.translate(-left, -top)
280
+ context.restore()
281
+ }
282
+
283
+ private _drawSegmentLabel(
284
+ context: CanvasRenderingContext2D,
285
+ seg: GaugeSegment,
286
+ x: number,
287
+ y: number,
288
+ w: number,
289
+ h: number
290
+ ) {
291
+ const percent = Math.round(seg.ratio * 100)
292
+ const lines = seg.label.split('\n')
293
+ lines.push(`${percent}%`)
294
+
295
+ const fontSize = Math.max(9, Math.min(14, Math.min(w, h) * 0.22))
296
+ context.font = `bold ${fontSize}px sans-serif`
297
+ context.fillStyle = '#333'
298
+ context.textAlign = 'center'
299
+ context.textBaseline = 'middle'
300
+
301
+ const lineHeight = fontSize * 1.2
302
+ const totalHeight = lines.length * lineHeight
303
+ const startY = y + (h - totalHeight) / 2 + lineHeight / 2
304
+
305
+ for (let i = 0; i < lines.length; i++) {
306
+ context.fillText(lines[i], x + w / 2, startY + i * lineHeight)
307
+ }
308
+ }
309
+
152
310
  renderLandscape(context: CanvasRenderingContext2D) {
153
311
  const { left, top, width, height } = this.bounds
154
312
  const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
@@ -303,13 +461,19 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
303
461
  }
304
462
 
305
463
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
464
+ const data = this.data && typeof this.data == 'object' ? this.data : {}
306
465
  const {
307
466
  CURRENTCAPACITY = 0,
308
467
  MAXCAPACITY = 100,
309
468
  HIGHWATERMARK = 100,
310
469
  LOWWATERMARK = 0,
311
- FULLUP = 'F'
312
- } = this.data && typeof this.data == 'object' ? this.data : {}
470
+ FULLUP = 'F',
471
+ CARRIER_FULL = 0,
472
+ CARRIER_EMPTY = 0,
473
+ CARRIER_EMPTY_EMPTY = 0,
474
+ CARRIER_NORMAL = 0,
475
+ CARRIER_ABNORMAL = 0
476
+ } = data
313
477
 
314
478
  const currentUsage = Number(CURRENTCAPACITY) || 0
315
479
  const maxCapacity = Number(MAXCAPACITY) || 100
@@ -322,7 +486,12 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
322
486
  highWatermark,
323
487
  lowWatermark,
324
488
  fullup: FULLUP == 'T',
325
- usage: Math.round((currentUsage / maxCapacity) * 100)
489
+ usage: Math.round((currentUsage / maxCapacity) * 100),
490
+ carrierFull: Number(CARRIER_FULL) || 0,
491
+ carrierEmpty: Number(CARRIER_EMPTY) || 0,
492
+ carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0,
493
+ carrierNormal: Number(CARRIER_NORMAL) || 0,
494
+ carrierAbnormal: Number(CARRIER_ABNORMAL) || 0
326
495
  })
327
496
  }
328
497
  }
@@ -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
+ }