@fmsim/machine 2.0.0-beta.10 → 2.0.0-beta.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/jest.config.js ADDED
@@ -0,0 +1,10 @@
1
+ module.exports = {
2
+ rootDir: '../../',
3
+ roots: ['<rootDir>/packages/machine'],
4
+ moduleFileExtensions: ['ts', 'tsx', 'js', 'json'],
5
+ transform: {
6
+ '^.+\\.tsx?$': 'babel-jest'
7
+ },
8
+ testRegex: 'packages/machine/.*\\.test\\.tsx?$',
9
+ testEnvironment: 'node'
10
+ }
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.10",
5
+ "version": "2.0.0-beta.14",
6
6
  "main": "dist/index.js",
7
7
  "module": "dist/index.js",
8
8
  "things-scene": true,
@@ -22,10 +22,11 @@
22
22
  "prepublish": "tsc; exit 0",
23
23
  "lint": "eslint --ext .ts,.html . --ignore-path .gitignore && prettier \"**/*.ts\" --check --ignore-path .gitignore",
24
24
  "format": "eslint --ext .ts,.html . --fix --ignore-path .gitignore && prettier \"**/*.ts\" --write --ignore-path .gitignore",
25
+ "test": "jest --config jest.config.js",
25
26
  "migration": "things-factory-migration"
26
27
  },
27
28
  "dependencies": {
28
- "@fmsim/api": "^2.0.0-beta.7",
29
+ "@fmsim/api": "^2.0.0-beta.12",
29
30
  "@hatiolab/things-scene": "^10.0.0-beta.1"
30
31
  },
31
32
  "devDependencies": {
@@ -54,5 +55,5 @@
54
55
  "prettier --write"
55
56
  ]
56
57
  },
57
- "gitHead": "b7e9ac371e788cfb8787fa1180c6e0670e8ba531"
58
+ "gitHead": "33caf0dec68805c9bf049707a51684bb3969b222"
58
59
  }
@@ -0,0 +1,116 @@
1
+ import { Model } from '@hatiolab/things-scene'
2
+ import type Carrier from './carrier'
3
+ import type MCSCarrierHolder from './mcs-carrier-holder'
4
+
5
+ const DYING_TIMEOUT = 1000
6
+
7
+ export default class CarrierManager {
8
+ private carriers: Map<string, Carrier> = new Map()
9
+ private dyingTimers: Map<string, ReturnType<typeof setTimeout>> = new Map()
10
+ private root: any
11
+
12
+ constructor(root: any) {
13
+ this.root = root
14
+ }
15
+
16
+ get(carrierName: string): Carrier | undefined {
17
+ return this.carriers.get(carrierName)
18
+ }
19
+
20
+ update(holder: MCSCarrierHolder, after: Record<string, any>, before: Record<string, any>): void {
21
+ const afterName = after?.CARRIERNAME
22
+ const beforeName = before?.CARRIERNAME
23
+
24
+ // 이전에는 캐리어가 있었는데 새 데이터에는 없다 → dying 후보
25
+ if (beforeName && (beforeName !== afterName || !afterName)) {
26
+ const carrier = this.carriers.get(beforeName)
27
+ if (carrier && carrier.parent === holder) {
28
+ this.scheduleDying(beforeName)
29
+ }
30
+ return
31
+ }
32
+
33
+ if (!afterName) {
34
+ return
35
+ }
36
+
37
+ // dying 상태면 구출
38
+ const wasDying = this.dyingTimers.has(afterName)
39
+ this.cancelDying(afterName)
40
+
41
+ const { width, height } = holder.state
42
+ let carrier = this.carriers.get(afterName)
43
+
44
+ if (!carrier) {
45
+ // 새로 생성
46
+ carrier = Model.compile(
47
+ {
48
+ id: afterName,
49
+ type: 'Carrier',
50
+ left: 0,
51
+ top: 0,
52
+ width,
53
+ height,
54
+ showText: holder.getState('showCarrierText') || false
55
+ },
56
+ this.root.app
57
+ ) as unknown as Carrier
58
+
59
+ this.carriers.set(afterName, carrier)
60
+ holder.addComponent(carrier, true)
61
+ } else if (carrier.parent !== holder && (!beforeName && afterName)) {
62
+ // CARRIERNAME이 새로 나타남 (이전엔 없었음) → 다른 holder로 이동
63
+ ;(holder as any).reparent(carrier, { animated: true, duration: 2000, ease: 'inout' })
64
+ }
65
+
66
+ if (carrier.parent === holder) {
67
+ carrier.data = after
68
+ }
69
+ }
70
+
71
+ private scheduleDying(carrierName: string): void {
72
+ this.cancelDying(carrierName)
73
+
74
+ const timer = setTimeout(() => {
75
+ this.dyingTimers.delete(carrierName)
76
+
77
+ const carrier = this.carriers.get(carrierName)
78
+ if (carrier) {
79
+ this.carriers.delete(carrierName)
80
+ if (!carrier.disposed) {
81
+ carrier.removeSelf(true)
82
+ }
83
+ }
84
+ }, DYING_TIMEOUT)
85
+
86
+ this.dyingTimers.set(carrierName, timer)
87
+ }
88
+
89
+ private cancelDying(carrierName: string): void {
90
+ const timer = this.dyingTimers.get(carrierName)
91
+ if (timer) {
92
+ clearTimeout(timer)
93
+ this.dyingTimers.delete(carrierName)
94
+ }
95
+ }
96
+
97
+ onCarrierDisposed(carrierName: string): void {
98
+ this.cancelDying(carrierName)
99
+ this.carriers.delete(carrierName)
100
+ }
101
+
102
+ dispose(): void {
103
+ for (const timer of this.dyingTimers.values()) {
104
+ clearTimeout(timer)
105
+ }
106
+ this.dyingTimers.clear()
107
+
108
+ for (const carrier of this.carriers.values()) {
109
+ if (!carrier.disposed) {
110
+ carrier.removeSelf(true)
111
+ }
112
+ }
113
+
114
+ this.carriers.clear()
115
+ }
116
+ }
package/src/carrier.ts CHANGED
@@ -37,6 +37,40 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
37
37
 
38
38
  set path(path) {}
39
39
 
40
+
41
+ added(parent: any) {
42
+ super.added(parent)
43
+
44
+ // 새 부모에 맞춰 크기 및 상태 갱신
45
+ if (parent) {
46
+ const { width, height } = parent.state || {}
47
+ if (width && height) {
48
+ this.set({ width, height })
49
+ }
50
+ }
51
+
52
+ this.syncStateFromData()
53
+
54
+ // 3D 오브젝트 최초 생성 (동기적 — reparent()에서 _realObject 필요)
55
+ if ((this.root as any)?.threed && !this._realObject) {
56
+ ;(this.root as any).model_layer?._threeCapability?.addObject(this)
57
+ }
58
+
59
+ // reparent 애니메이션은 things-scene의 reparent()가 처리
60
+ }
61
+
62
+ /**
63
+ * data로부터 carrier 상태를 동기화 (생성 시, 부모 변경 시 공통 사용)
64
+ */
65
+ private syncStateFromData(): void {
66
+ const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
67
+
68
+ this.setState('id', CARRIERNAME)
69
+ this.setState('EMPTYTYPE', EMPTYTYPE)
70
+ this.setState('CARRIERTYPE', CARRIERTYPE)
71
+ this.setState('CARRIERSTATUS', CARRIERSTATUS)
72
+ }
73
+
40
74
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
41
75
  const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
42
76
 
@@ -249,6 +283,12 @@ export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) {
249
283
  }
250
284
  }
251
285
 
286
+ dispose(): void {
287
+ ;(this.root as any)?._carrierManager?.onCarrierDisposed(this.state.id)
288
+
289
+ super.dispose()
290
+ }
291
+
252
292
  async detailInfo() {
253
293
  const { type, id } = this.state
254
294
 
@@ -643,111 +643,9 @@ const CARRIER_CONFIG: MCS3DConfig = {
643
643
  }
644
644
 
645
645
  class CarrierRealObject3D extends MCSRealObject3D {
646
- private _animRaf: number | null = null
647
- private _animStartTime = 0
648
-
649
646
  constructor(component: Component) {
650
647
  super(component, CARRIER_CONFIG)
651
648
  }
652
-
653
- build() {
654
- super.build()
655
- this.startCarrierAnimation()
656
- }
657
-
658
- /**
659
- * CARRIERSTATUS의 애니메이션 설정을 읽어 3D에서 직접 실행.
660
- * things-scene의 delta 시스템과 독립적으로 동작.
661
- */
662
- private startCarrierAnimation() {
663
- this.stopCarrierAnimation()
664
-
665
- const comp = this.component as any
666
- if (!comp.app?.isViewMode) return
667
-
668
- const carrierStatus = comp.state?.CARRIERSTATUS
669
- if (!carrierStatus) return
670
-
671
- // 애니메이션 설정 가져오기
672
- const config = comp.getAnimationConfig?.(carrierStatus)
673
- if (!config?.animation) return
674
-
675
- const { type, duration = 2000, scale: animScale = 1.3 } = config.animation
676
- if (!type) return
677
-
678
- this._animStartTime = performance.now()
679
-
680
- // 애니메이션 대상: group(object3d) — mesh 개별 변환 대신 group 전체를 변환하여
681
- // 회전/스케일 중심이 component의 3D 위치(= 2D 중심)와 정확히 일치하도록 함
682
- const obj = this.object3d
683
-
684
- const animate = () => {
685
- if (!this._ownMesh) return
686
-
687
- const elapsed = performance.now() - this._animStartTime
688
- const progress = (elapsed % duration) / duration // 0~1 반복
689
-
690
- switch (type) {
691
- case 'heartbeat': {
692
- const t = progress < 0.5 ? progress * 2 : (1 - progress) * 2
693
- const s = 1 + (animScale - 1) * t
694
- obj.scale.set(s, 1, s)
695
- break
696
- }
697
- case 'rotation': {
698
- obj.rotation.y = progress * Math.PI * 2
699
- break
700
- }
701
- case 'vibration': {
702
- const theta = config.animation.theta ?? 0.26
703
- const current = Math.sin(progress * Math.PI * 2) * theta
704
- obj.rotation.y = current
705
- break
706
- }
707
- case 'fade': {
708
- const alpha = 0.3 + 0.7 * (progress < 0.5 ? progress * 2 : (1 - progress) * 2)
709
- obj.traverse((o: any) => {
710
- if (o.material && !o.material.map) {
711
- o.material.opacity = alpha
712
- o.material.transparent = true
713
- }
714
- })
715
- break
716
- }
717
- }
718
-
719
- this._animRaf = requestAnimationFrame(animate)
720
- }
721
-
722
- this._animRaf = requestAnimationFrame(animate)
723
- }
724
-
725
- // 자체 애니메이션이 실행 중이면 things-scene의 syncAnimationDelta 비활성화
726
- syncAnimationDelta() {
727
- if (!this._animRaf) {
728
- super.syncAnimationDelta()
729
- }
730
- }
731
-
732
- private stopCarrierAnimation() {
733
- if (this._animRaf) {
734
- cancelAnimationFrame(this._animRaf)
735
- this._animRaf = null
736
- }
737
- // group 스케일/회전 초기화
738
- this.object3d.scale.set(1, 1, 1)
739
- this.object3d.rotation.y = 0
740
- }
741
-
742
- update() {
743
- this.stopCarrierAnimation()
744
- super.update()
745
- }
746
-
747
- clear() {
748
- this.stopCarrierAnimation()
749
- return super.clear()
750
- }
751
649
  }
752
650
 
753
651
  registerRealObjectFactory('Carrier', (component: Component) => new CarrierRealObject3D(component))
@@ -1,7 +1,5 @@
1
- import { Model, Properties } from '@hatiolab/things-scene'
2
1
  import { themesColorMap, themesAnimationMap } from '@fmsim/api'
3
2
  import MCSUnit from './mcs-unit.js'
4
- import Carrier from './carrier.js'
5
3
 
6
4
  export default class MCSCarrierHolder extends MCSUnit {
7
5
  static get properties(): any {
@@ -31,50 +29,56 @@ export default class MCSCarrierHolder extends MCSUnit {
31
29
  ]
32
30
  }
33
31
 
34
- carrier?: Carrier | null
35
-
36
- async ready() {
37
- super.ready()
32
+ get showOverflow() {
33
+ return true
34
+ }
38
35
 
39
- for (let component of this.components) {
40
- this.removeComponent(component)
36
+ get hierarchy() {
37
+ return {
38
+ ...super.hierarchy,
39
+ components: []
41
40
  }
42
-
43
- const { width, height } = this.state
44
-
45
- this.carrier = Model.compile(
46
- {
47
- type: 'Carrier',
48
- left: 0,
49
- top: 0,
50
- width,
51
- height,
52
- showText: this.getState('showCarrierText') || false
53
- },
54
- this.root.app
55
- ) as any
56
-
57
- this.addComponent(this.carrier!)
58
-
59
- this.carrier!.data = this.data
60
41
  }
61
42
 
62
43
  dispose(): void {
63
- this.carrier = null
64
-
44
+ ;(this.root as any)?.carrierManager?.update(this, {}, this.data)
65
45
  super.dispose()
66
46
  }
67
47
 
68
- get hierarchy() {
69
- return {
70
- ...super.hierarchy,
71
- components: []
48
+ async ready() {
49
+ super.ready()
50
+
51
+ const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {}
52
+
53
+ if(CARRIERNAME) {
54
+ ;(this.root as any).carrierManager.update(this, {
55
+ CARRIERNAME,
56
+ CARRIERTYPE,
57
+ EMPTYTYPE,
58
+ CARRIERSTATUS
59
+ }, undefined)
72
60
  }
73
61
  }
74
62
 
75
63
  onchangeData(after: Record<string, any>, before: Record<string, any>): void {
76
- if (this.carrier) {
77
- this.carrier.data = this.data
64
+ // CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS 이 변경되었을 때만 캐리어 관리자에게 알린다.
65
+ const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = after.data || {}
66
+ const { CARRIERNAME: beforeCARRIERNAME, CARRIERTYPE: beforeCARRIERTYPE = '', EMPTYTYPE: beforeEMPTYTYPE, CARRIERSTATUS: beforeCARRIERSTATUS } = before.data || {}
67
+ if(CARRIERNAME !== beforeCARRIERNAME ||
68
+ CARRIERTYPE !== beforeCARRIERTYPE ||
69
+ EMPTYTYPE !== beforeEMPTYTYPE ||
70
+ CARRIERSTATUS !== beforeCARRIERSTATUS) {
71
+ ;(this.root as any).carrierManager.update(this, {
72
+ CARRIERNAME,
73
+ CARRIERTYPE,
74
+ EMPTYTYPE,
75
+ CARRIERSTATUS
76
+ }, {
77
+ CARRIERNAME: beforeCARRIERNAME,
78
+ CARRIERTYPE: beforeCARRIERTYPE,
79
+ EMPTYTYPE: beforeEMPTYTYPE,
80
+ CARRIERSTATUS: beforeCARRIERSTATUS
81
+ })
78
82
  }
79
83
 
80
84
  super.onchangeData(after, before)
@@ -1,6 +1,7 @@
1
1
  import { Component, RootContainer } from '@hatiolab/things-scene'
2
2
  import { themesColorMap, themesColorRange, themesAnimationMap } from '@fmsim/api'
3
3
  import { Legend } from '../features/mcs-status-default'
4
+ import CarrierManager from '../carrier-manager'
4
5
 
5
6
  // RootContainer 클래스 타입 확장
6
7
  declare module '@hatiolab/things-scene' {
@@ -26,6 +27,8 @@ declare module '@hatiolab/things-scene' {
26
27
  vehicleAnimation?: boolean
27
28
  // 캐시 관련 속성 추가
28
29
  _animationConfigCache?: Map<string, any>
30
+ _carrierManager?: CarrierManager
31
+ carrierManager: CarrierManager
29
32
  }
30
33
  }
31
34
 
@@ -326,3 +329,12 @@ Object.defineProperty(RootContainer.prototype, 'vehicleAnimation', {
326
329
  return !!vehicleAnimation
327
330
  }
328
331
  })
332
+
333
+ Object.defineProperty(RootContainer.prototype, 'carrierManager', {
334
+ get: function () {
335
+ if (!this._carrierManager) {
336
+ this._carrierManager = new CarrierManager(this)
337
+ }
338
+ return this._carrierManager
339
+ }
340
+ })