import JSON5 from 'json5' import { getPopupData } from '@fmsim/api' import { BOUNDS, Component, ComponentNature, Properties, Shape, sceneComponent } from '@hatiolab/things-scene' import { ANIMATION_DEFAULT, AnimationPreset, AnimationConfig } from './features/animation-default.js' import { ParentObjectMixin } from './features/parent-object-mixin.js' import { LEGEND_CARRIER, Legend } from './features/mcs-status-default.js' import { MCSStatusMixin } from './features/mcs-status-mixin.js' const NATURE: ComponentNature = { mutable: false, resizable: false, rotatable: false } /** * Feature flag — keep status text (F / E / X) screen-axis-aligned even * when the carrier (or its parent holder, or an in-flight reparent * tween) is rotated. Counter-rotates ONLY the text; the polygon path * still rotates with the carrier as before. * * ROLLBACK procedure (if customer decides to revert): * 1. Flip this flag to `false` — text rotates with carrier again. * 2. Or delete this constant and the — fully strip the feature: keep * `if (STATUS_TEXT_COUNTER_ROTATE)` only the `else` branch in * branch in `render()`. `render()` (the original code). */ const STATUS_TEXT_COUNTER_ROTATE = true @sceneComponent('Carrier') export default class Carrier extends MCSStatusMixin(ParentObjectMixin(Shape)) { static get nature() { return NATURE } get path() { if (!this.parent) { return [] } const { left, top, width, height } = this.calculateShrunkRectangle(this.parent.bounds) return [ { x: left, y: top }, { x: left + width / 2, y: top }, { x: left + width, y: top + height / 2 }, { x: left + width, y: top + height }, { x: left, y: top + height } ] } set path(path) {} added(parent: any) { super.added(parent) // 새 부모에 맞춰 크기 및 상태 갱신 if (parent) { const { width, height } = parent.state || {} if (width && height) { this.set({ width, height }) } } this.syncStateFromData() // 3D 오브젝트 최초 생성 (동기적 — reparent()에서 _realObject 필요) if ((this.root as any)?.threed && !this._realObject) { ;(this.root as any).model_layer?._threeCapability?.addObject(this) } // reparent 애니메이션은 things-scene의 reparent()가 처리 } /** * data로부터 carrier 상태를 동기화 (생성 시, 부모 변경 시 공통 사용) */ private syncStateFromData(): void { const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {} this.setState('id', CARRIERNAME) this.setState('EMPTYTYPE', EMPTYTYPE) this.setState('CARRIERTYPE', CARRIERTYPE) this.setState('CARRIERSTATUS', CARRIERSTATUS) } onchangeData(after: Record, before: Record): void { const { CARRIERNAME, CARRIERTYPE = '', EMPTYTYPE, CARRIERSTATUS } = this.data || {} this.setState('id', CARRIERNAME) this.setState('EMPTYTYPE', EMPTYTYPE) this.setState('CARRIERTYPE', CARRIERTYPE) this.setState('CARRIERSTATUS', CARRIERSTATUS) // TODO carrierstatus에 따라서 매핑되는 애니메이션 테마 수행 if (after.data?.CARRIERSTATUS !== before.data?.CARRIERSTATUS) { const { CARRIERSTATUS: lastCarrierStatus } = before.data || {} const lastAnimationConfig = lastCarrierStatus && this.getAnimationConfig(lastCarrierStatus) if (lastAnimationConfig) { let { animation, decorator, border, arrow } = lastAnimationConfig if (animation) { this.resetAnimation() this.setState('animation', { oncreate: null }) } if (decorator || border || arrow) { this.trigger('deco-off') } } const animationConfig = this.getAnimationConfig(this.getState('CARRIERSTATUS')) if (animationConfig) { let { animation, decorator, border, arrow } = animationConfig if (animation) { this.resetAnimation() this.setState('animation', { oncreate: animation }) this.started = true } if (decorator) { this.trigger('deco-icon-off') this.trigger('deco-icon', decorator) } if (border) { this.trigger('deco-border-off') this.trigger('deco-border', border) } if (arrow) { this.trigger('deco-arrow-off') this.trigger('deco-arrow', arrow) } } } } get status() { return this.getState('EMPTYTYPE') } get legend(): Legend { const { carrierLegendName } = this.parent?.state || {} if (carrierLegendName) { return (this.root as any)?.style[carrierLegendName] } return (this.root as any)?.carrierLegendTheme || LEGEND_CARRIER } getAnimationConfig(carrierStatus): AnimationConfig | null { const config = this.animationPreset[carrierStatus || 'default'] return (config as AnimationConfig) || null } get animationPreset(): AnimationPreset { const carrierAnimationName = this.parent?.state.carrierAnimationName || this.root?.rootModel.state.carrierAnimationName || '' if (carrierAnimationName) { // 캐시에서 먼저 확인 if (!(this.root as any)._animationConfigCache) { (this.root as any)._animationConfigCache = new Map() } const cached = (this.root as any)._animationConfigCache.get(carrierAnimationName) if (cached) { return cached } // 캐시에 없으면 파싱하고 저장 let theme = (this.root as any)?.style?.[carrierAnimationName] if (theme) { // theme이 string이면 먼저 파싱 if (typeof theme === 'string') { try { theme = JSON5.parse(theme) } catch { return ANIMATION_DEFAULT } } if (typeof theme !== 'object' || theme === null) return ANIMATION_DEFAULT // 문자열 형태의 설정들을 미리 파싱 const parsedTheme = {} for (const [status, config] of Object.entries(theme)) { if (typeof config === 'string' && config.trim()) { try { parsedTheme[status] = JSON5.parse(config) } catch (e) { console.error('JSON5 parse error:', e, 'input:', JSON.stringify(config), 'status:', status, 'theme:', carrierAnimationName) parsedTheme[status] = config } } else { parsedTheme[status] = config } } ;(this.root as any)._animationConfigCache.set(carrierAnimationName, parsedTheme) return parsedTheme } return ANIMATION_DEFAULT } else { return ANIMATION_DEFAULT } } get hasTextProperty() { return this.getState('showText') || false } calculateShrunkRectangle(originalRect: BOUNDS): BOUNDS { const shrunkRect: BOUNDS = { ...originalRect } shrunkRect.left = originalRect.width * 0.1 shrunkRect.width *= 0.8 shrunkRect.top = originalRect.height * 0.1 shrunkRect.height *= 0.8 return shrunkRect } contains(x, y) { const { id: CARRIERNAME } = this.state || {} if (!CARRIERNAME) { return false } const rect = this.bounds x -= rect.left y -= rect.top if (x < 0 || y < 0 || x > rect.width || y > rect.height) { return false } // 윗변 중앙 좌표 const topCenterX = rect.width / 2 const topCenterY = 0 // 오른쪽 변 중앙 좌표 const rightCenterX = rect.width const rightCenterY = rect.height / 2 // 기울기 m = (y2 - y1) / (x2 - x1) const m = (rightCenterY - topCenterY) / (rightCenterX - topCenterX) // y 절편 b = y - mx const b = topCenterY - m * topCenterX // 점(px, py)이 선 아래(좌하부)에 있는지 확인 return y > m * x + b } render(ctx: CanvasRenderingContext2D) { const { EMPTYTYPE, id: CARRIERNAME, showText } = this.state if (!CARRIERNAME) { return } const { width, height } = this.bounds const path = this.path const round = Math.round(Math.min(width, height) * 0.1) const lineWidth = round > 5 ? 1 : 0.5 ctx.beginPath() ctx.moveTo(path[0].x, path[0].y) path.slice(1).forEach(({ x, y }) => { ctx.lineTo(x, y) }) ctx.closePath() ctx.lineWidth = lineWidth ctx.fillStyle = this.statusColor || 'transparent' ctx.strokeStyle = this.auxColor || 'transparent' ctx.fill() ctx.stroke() if (showText) { const text = EMPTYTYPE == 'FULL' ? 'F' : EMPTYTYPE == 'EMPTY' ? 'E' : EMPTYTYPE == 'EMPTYEMPTY' ? 'X' : '' if (text) { const { x: cx, y: cy } = this.center ctx.fillStyle = 'black' ctx.font = `normal ${Math.round(round * 8)}px Arial` ctx.textAlign = 'center' ctx.textBaseline = 'middle' if (STATUS_TEXT_COUNTER_ROTATE) { // Counter-rotate so the status label stays screen-axis-aligned // even when the carrier (or its parent holder, or an in-flight // reparent transition) is rotated. Reading the current cumulative // rotation from the canvas transform handles all sources of // rotation uniformly (state.rotation, parent transforms, // animation tweens) — no need to enumerate them. const m = ctx.getTransform() const cumulRotation = Math.atan2(m.b, m.a) ctx.save() ctx.translate(cx, cy) ctx.rotate(-cumulRotation) ctx.fillText(text, -width / 8, height / 8) ctx.restore() } else { // Original — text rotates with the carrier. ctx.fillText(text, cx - width / 8, cy + height / 8) } } } } dispose(): void { ;(this.root as any)?.carrierManager?.onCarrierDisposed(this.state.id) super.dispose() } async detailInfo() { const { type, id } = this.state if (!id) { return } return await getPopupData(type, id) } }