import { Component, ComponentNature, sceneComponent, frameClock } from '@hatiolab/things-scene' import MCSVehicle from './mcs-vehicle' import { LEGEND_CRANE } from './features/mcs-status-default' import { axisRatio, mapCraneLocalPosition, normalizeCranePosition, type CranePositionData } from './utils/crane-position' const NATURE: ComponentNature = { mutable: false, resizable: true, rotatable: true, properties: [...MCSVehicle.properties] } const GAP = 2 /** 좌표 보간 기본 duration (ms). data.DURATION_MS 가 없으면 이 값 사용. */ const DEFAULT_DURATION_MS = 1000 function lerp(a: number, b: number, t: number): number { return a + (b - a) * t } function easeInOut(t: number): number { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2 } interface CranePositionAnimator { fromLeft: number fromTop: number fromForkZ: number toLeft: number toTop: number toForkZ: number startTime: number duration: number unsubscribe: () => void } @sceneComponent('Crane') export default class Crane extends MCSVehicle { static get nature() { return NATURE } get auxColor() { return 'black' } /** Crane 의 parentObject 는 STOCKER. */ get parentObjectTypes(): string[] { return ['STOCKER'] } getLegendFallback() { return (this.root as any).craneLegendTheme || super.getLegendFallback() } private _posAnimator?: CranePositionAnimator /** * data.cranePosition = { current: {x,y,z}, limit: {x|y|z:{min,max}} } 분기. * current 를 limit 범위 안의 0~1 비율로 환산한 뒤, 부모 (보통 Stocker) 기준으로 * 실 좌표 (state.left/top) 와 forkZ 로 변환해 lerp 시작. x→host 긴쪽, y→짧은쪽, * z→포크 높이. 보간 중 새 값이 들어오면 *현재 보간 위치* 를 from 으로 잡고 새 * target 으로 새 lerp 시작. * NODEID 경로는 super (MCSVehicle.onchangeData) 가 처리 — cranePosition 만 있는 * 데이터에선 NODEID 가 없어 자연스럽게 noop. */ onchangeData(after: Record, before: Record): void { const afterData = after?.data // 데이터 키는 모두 대문자 전제. CRANEPOSITION 의 중첩 키(CURRENT/LIMIT/X/Y/Z/MIN/MAX)는 // normalizeCranePosition 이 내부 표준형(소문자)으로 변환. DURATION_MS 는 사용하지 않음. const afterCP = normalizeCranePosition(afterData?.CRANEPOSITION) if (afterCP) { const beforeCP = normalizeCranePosition(before?.data?.CRANEPOSITION) if (this._cranePositionChanged(afterCP, beforeCP)) { this._startPositionAnimation(afterCP) } } super.onchangeData(after, before) } /** current 또는 limit 중 하나라도 바뀌면 재계산 필요 (limit 변화도 실 위치를 바꿈). */ private _cranePositionChanged(a: CranePositionData, b?: CranePositionData): boolean { if (!b) return true return ( JSON.stringify(a?.current ?? null) !== JSON.stringify(b?.current ?? null) || JSON.stringify(a?.limit ?? null) !== JSON.stringify(b?.limit ?? null) ) } dispose() { if (this._posAnimator) { this._posAnimator.unsubscribe() this._posAnimator = undefined } super.dispose() } private _startPositionAnimation(cranePosition: CranePositionData, durationMs?: number): void { const target = this._ratioToReal(cranePosition) if (!target) return // 부모(host) 없거나 크기 0 이면 변환 skip const now = frameClock.simTime const duration = Math.max(1, Number(durationMs) || DEFAULT_DURATION_MS) // 현재 위치 — 보간 중이면 그 시점 보간값을 from 으로 let fromLeft = Number(this.state.left) || 0 let fromTop = Number(this.state.top) || 0 let fromForkZ = Number((this.state as any).forkZ) || 0 if (this._posAnimator) { const a = this._posAnimator const t = Math.min(1, Math.max(0, (now - a.startTime) / a.duration)) const e = easeInOut(t) fromLeft = lerp(a.fromLeft, a.toLeft, e) fromTop = lerp(a.fromTop, a.toTop, e) fromForkZ = lerp(a.fromForkZ, a.toForkZ, e) a.unsubscribe() this._posAnimator = undefined } const unsubscribe = frameClock.subscribe( (_dt: number, simTime: number) => this._tickPositionAnimation(simTime), { description: 'crane-position', component: this } ) this._posAnimator = { fromLeft, fromTop, fromForkZ, toLeft: target.left, toTop: target.top, toForkZ: target.forkZ, startTime: now, duration, unsubscribe } // 즉시 첫 frame 반영 (t=0) — set 한 번 호출. paused 환경에서도 from→target // 진입 시점이 명확. this._tickPositionAnimation(now) } private _tickPositionAnimation(simTime: number): void { const a = this._posAnimator if (!a) return const t = Math.min(1, Math.max(0, (simTime - a.startTime) / a.duration)) const e = easeInOut(t) this.set({ left: lerp(a.fromLeft, a.toLeft, e), top: lerp(a.fromTop, a.toTop, e), forkZ: lerp(a.fromForkZ, a.toForkZ, e) }) if (t >= 1) { a.unsubscribe() this._posAnimator = undefined } } /** * cranePosition (current + limit) → state.left/top + forkZ. * * 부모 오브젝트 (보통은 Stocker 이지만 다른 컨테이너일 수도) 의 width/height * 만 사용해 좌표 매핑 — 타입 무관 일반화. 가상 rail 은 host 의 긴쪽: * - current.x → host 의 긴쪽 (자동 판별: max(width, height)) * - current.y → 짧은쪽 * - current.z → forkZ (state 에 0~100 으로 저장, 3D 측에서 mast 비율로 매핑) * * 각 축 비율 = (current - limit.min) / (limit.max - limit.min) ∈ [0,1]. * * **스토커 안쪽 오프셋**: 크레인의 폭/두께를 감안해, 크레인 *전체* 가 host 안에 * 머무르도록 top-left 좌표를 [0, hostLen - craneLen] 범위로 매핑한다 (= 양끝에서 * craneLen/2 만큼 inset 된 중심선 위를 움직임). 크레인이 host 보다 크면 span 0 * 으로 떨어져 host 모서리에 정렬. * * 참조는 ParentObjectMixin 이 제공하는 `parentObject` 사용 — * state.parentObjectId 명시 시 그것, 미명시 시 this.parent 자동 fallback. */ private _ratioToReal( cp: CranePositionData ): { left: number; top: number; forkZ: number } | undefined { const host: any = (this as any).parentObject if (!host) return undefined const hw = Number(host.state?.width) || 0 const hh = Number(host.state?.height) || 0 const cw = Number(this.state.width) || 0 const ch = Number(this.state.height) || 0 if (!hw || !hh) return undefined const rx = axisRatio(cp.current?.x, cp.limit?.x) const ry = axisRatio(cp.current?.y, cp.limit?.y) const rz = axisRatio(cp.current?.z, cp.limit?.z) const { left, top } = mapCraneLocalPosition(hw, hh, cw, ch, rx, ry) return { left, top, forkZ: rz * 100 // forkZ 0~100, 3D 측 (crane-3d) 에서 mast 비율로 매핑 } } render(ctx: CanvasRenderingContext2D) { var { left, top, width, height } = this.bounds const { x: cx, y: cy } = this.center const rx = width / 2 const ry = height / 2 const round = Math.min(rx, ry) * 0.5 ctx.translate(left, top) ctx.beginPath() ctx.strokeStyle = this.auxColor ctx.fillStyle = this.statusColor! ctx.lineWidth = 1 ctx.lineCap = 'round' ctx.setLineDash([]) const radius = Math.round(Math.min(width, height) * 0.1) ctx.roundRect(1, 0, width - 2, height, radius) ctx.roundRect(GAP + 1, GAP, width - (GAP + 1) * 2, height - GAP * 2, radius - 1) ctx.fill() ctx.stroke() ctx.beginPath() ctx.arc(cx - left, cy - top, round, 0, Math.PI * 2) ctx.stroke() ctx.beginPath() ctx.roundRect(width / 2 - (round * 2) / 4 - round / 8, height / 2 - (round * 6) / 5, round / 4, (round * 6) / 5, 1) ctx.roundRect(width / 2 + (round * 2) / 4 - round / 8, height / 2 - (round * 6) / 5, round / 4, (round * 6) / 5, 1) ctx.fill() ctx.stroke() ctx.beginPath() // draw crane bar metapore ctx.strokeStyle = 'black' ctx.lineWidth = 3 ctx.lineCap = 'butt' ctx.moveTo(GAP * 3, height / 2) ctx.lineTo(width - GAP * 3, height / 2) ctx.stroke() ctx.translate(-left, -top) } }