import { Component, ComponentNature, POSITION, Properties, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene' import { themesColorRange } from '@fmsim/api' import { MCSStatusMixin } from './features/mcs-status-mixin' import { LEGEND_CAPACITY, Legend, resolveCarrierLegend } from './features/mcs-status-default' import { safeRound } from './utils/safe-round' import { getValueOnRanges } from './utils/get-value-on-ranges' import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin' import { carrierSegments, abnormalSegments, carrierColorsFromLegend, GaugeSegment } from './utils/stocker-gauge' /** * 스토커 게이지 (통합). * * 하나의 컴포넌트가 mode 에 따라 3가지 게이지를 표현한다: * - 'capacity' : 용량 사용률(usage%) 채움 게이지 + high/low watermark (기본) * - 'carrier' : 캐리어 상태비율 세그먼트 (Full / Empty / EmptyEmpty) * - 'abnormal' : 캐리어 정상/비정상 비율 세그먼트 (Normal / Abnormal) * * mode 소스: root.stockerGaugeMode (기본 'capacity'). * RootContainer 의 **런타임 전용 transient 속성** — 모델에 저장하지 않고 항상 'capacity' * 로 시작하며 실행 중 동적으로만 변경된다(root-container-override.ts 정의). 3D 표현 * (machine-3d.ts buildGauge)과 동일 소스라 2D/3D 게이지가 항상 일치한다. * * 데이터 필드(2D 기준, canonical): * - capacity : CURRENTCAPACITY / MAXCAPACITY / HIGHWATERMARK / LOWWATERMARK / FULLUP * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL */ const NATURE: ComponentNature = { mutable: false, resizable: true, rotatable: true, properties: [ ...ParentObjectMixinProperties, { type: 'select', label: 'legend-name', name: 'legendName', property: { options: themesColorRange } }, { type: 'number', name: 'currentUsage', label: 'current-usage' }, { type: 'number', name: 'highWatermark', label: 'high-watermark' }, { type: 'number', name: 'lowWatermark', label: 'low-watermark' }, { type: 'number', name: 'maxCapacity', label: 'max-capacity' }, { type: 'boolean', name: 'fullup', label: 'fullup' }, { type: 'number', name: 'carrierFull', label: 'carrier-full' }, { type: 'number', name: 'carrierEmpty', label: 'carrier-empty' }, { type: 'number', name: 'carrierEmptyEmpty', label: 'carrier-empty-empty' }, { type: 'number', name: 'carrierNormal', label: 'carrier-normal' }, { type: 'number', name: 'carrierAbnormal', label: 'carrier-abnormal' }, { type: 'number', label: 'round', name: 'round', property: { min: 0 } }, { type: 'hidden', name: 'usage', label: 'usage' } ], 'value-property': 'usage' } var controlHandler = { ondragmove: function (point: POSITION, index: number, component: Component) { var { left, top, width, height } = component.model var transcoorded = component.transcoordP2S(point.x, point.y) var round = ((transcoorded.x - left) / (width / 2)) * 100 round = safeRound(round, width, height) component.set({ round }) } } @sceneComponent('StockerCapacityBar') @sceneComponent('MCSGaugeCapacityBar') export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin(RectPath(Shape))) { static get nature() { return NATURE } /** StockerCapacityBar / MCSGaugeCapacityBar 의 parentObject 는 STOCKER. */ get parentObjectTypes(): string[] { return ['STOCKER'] } /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */ get gaugeMode(): string { return (this.root as any)?.stockerGaugeMode || 'capacity' } get controls() { var { left, top, width, round, height } = this.state round = round == undefined ? 0 : safeRound(round, width, height) return [ { x: left + (width / 2) * (round / 100), y: top, handler: controlHandler } ] } isIdentifiable() { return false } getTheme() { const { legendName } = this.state if (legendName) { return (this.root as any)?.style?.[legendName] } } get legend(): Legend { return this.getTheme() || this.getLegendFallback() } getLegendFallback() { return (this.root as any).stockerCapacityLegendTheme || LEGEND_CAPACITY } get statusColor() { const { fullup } = this.state return fullup ? 'red' : getValueOnRanges(this.usage, this.legend) } get text() { // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로 // base 의 value 텍스트(value-property=usage)를 억제한다. if (this.gaugeMode !== 'capacity') { return '' } return this.usage + '%' } render(context: CanvasRenderingContext2D) { const mode = this.gaugeMode if (mode === 'carrier' || mode === 'abnormal') { this.renderSegmentGauge(context, this.segments(mode)) return } // capacity (기본) const { width, height } = this.bounds if (width >= height) { this.renderLandscape(context) } else { this.renderPortrait(context) } } /** * Shape.postrender 의 drawFill/drawStroke 를 건너뛴다. * 각 모드 render 가 자체 배경(fillStyle)·외곽선(strokeStyle)을 이미 그리므로, base 가 * fillStyle 로 게이지 전체를 다시 덮어(각 모드 표현이 사라지는) 것을 방지한다. * 값 텍스트(drawText)는 Component.postrender 와 동일하게 유지한다. */ postrender(context: CanvasRenderingContext2D) { this.drawText(context) } /** * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산). * 배열 순서 = portrait 아래→위 / landscape 왼→오. */ segments(mode: string): GaugeSegment[] { if (mode === 'abnormal') { const { carrierNormal = 0, carrierAbnormal = 0 } = this.state return abnormalSegments(carrierNormal, carrierAbnormal) } const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = this.state // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득 (carrierColorsFromLegend). const colors = carrierColorsFromLegend(resolveCarrierLegend(this)) return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty, colors) } /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) { const { left, top, width, height } = this.bounds const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state const landscape = width >= height context.save() context.translate(left, top) // background context.beginPath() context.fillStyle = fillStyle context.roundRect(0, 0, width, height, round) context.fill() context.clip() if (landscape) { // 왼→오 let x = 0 for (const seg of segments) { const segWidth = width * seg.ratio if (segWidth <= 0) continue context.beginPath() context.fillStyle = seg.color context.rect(x, 0, segWidth, height) context.fill() this._drawSegmentLabel(context, seg, x, 0, segWidth, height) x += segWidth } } else { // 아래→위 (배열 첫 세그먼트가 바닥) let y = height for (const seg of segments) { const segHeight = height * seg.ratio if (segHeight <= 0) continue y -= segHeight context.beginPath() context.fillStyle = seg.color context.rect(0, y, width, segHeight) context.fill() this._drawSegmentLabel(context, seg, 0, y, width, segHeight) } } // outline context.beginPath() context.setLineDash([]) context.strokeStyle = strokeStyle context.lineWidth = lineWidth context.roundRect(0, 0, width, height, round) context.stroke() context.translate(-left, -top) context.restore() } private _drawSegmentLabel( context: CanvasRenderingContext2D, seg: GaugeSegment, x: number, y: number, w: number, h: number ) { // 이름 라벨(Full/Empty/Abnormal…)은 그리지 않는다. 각 세그먼트 영역 중심에 숫자(%)만 표시. // 컴포넌트의 폰트 설정(fontFamily/fontSize/bold/italic)을 그대로 적용 — this.font. const fontSize = this.state.fontSize || 15 context.font = this.font // 공간에 따라 단계적 축소: 넉넉하면 '값%', 좁으면 '%' 떼고 값만, 더 좁으면 생략. const value = `${Math.round(seg.ratio * 100)}` const fits = (t: string) => fontSize <= h && context.measureText(t).width <= w let text: string | null = null if (fits(`${value}%`)) { text = `${value}%` } else if (fits(value)) { text = value } if (text === null) { return } context.fillStyle = '#333' context.textAlign = 'center' context.textBaseline = 'middle' context.fillText(text, x + w / 2, y + h / 2) } renderLandscape(context: CanvasRenderingContext2D) { const { left, top, width, height } = this.bounds const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state context.save() context.translate(left, top) // gauge background context.beginPath() context.fillStyle = fillStyle context.roundRect(0, 0, width, height, round) context.fill() context.clip() // gauge foreground context.beginPath() context.fillStyle = this.statusColor! context.rect(0, 0, (width / 100) * this.usage, height) context.fill() // high-watermark level if (highWatermark && highWatermark > 0 && highWatermark < 100) { context.beginPath() context.strokeStyle = 'black' context.lineWidth = 1 context.setLineDash([3]) const x = (width / 100) * highWatermark context.moveTo(x, 0) context.lineTo(x, height) context.stroke() } // low-watermark level if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) { context.beginPath() context.strokeStyle = 'black' context.lineWidth = 1 context.setLineDash([3]) const x = (width / 100) * lowWatermark context.moveTo(x, 0) context.lineTo(x, height) context.stroke() } // gauge outline context.beginPath() context.setLineDash([]) context.strokeStyle = strokeStyle context.lineWidth = lineWidth context.roundRect(0, 0, width, height, round) context.stroke() context.beginPath() context.translate(-left, -top) context.restore() } renderPortrait(context: CanvasRenderingContext2D) { const { left, top, width, height } = this.bounds const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state context.save() context.translate(left, top) // gauge background context.beginPath() context.fillStyle = fillStyle context.roundRect(0, 0, width, height, round) context.fill() context.clip() // gauge foreground context.beginPath() context.fillStyle = this.statusColor! context.rect(0, height - (height / 100) * this.usage, width, (height / 100) * this.usage) context.fill() // high-watermark level if (highWatermark && highWatermark > 0 && highWatermark < 100) { context.beginPath() context.strokeStyle = 'black' context.lineWidth = 1 context.setLineDash([3]) const y = height - (height / 100) * highWatermark context.moveTo(0, y) context.lineTo(width, y) context.stroke() } // low-watermark level if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) { context.beginPath() context.strokeStyle = 'black' context.lineWidth = 1 context.setLineDash([3]) const y = height - (height / 100) * lowWatermark context.moveTo(0, y) context.lineTo(width, y) context.stroke() } // gauge outline context.beginPath() context.setLineDash([]) context.strokeStyle = strokeStyle context.lineWidth = lineWidth context.roundRect(0, 0, width, height, round) context.stroke() context.beginPath() context.translate(-left, -top) context.restore() } get usage() { const { currentUsage = 0, maxCapacity = 100 } = this.state return Math.round((currentUsage / maxCapacity) * 100) } set usage(usage: number) { // intentionally have done nothing } onchangeData(after: Record, before: Record): void { const data = this.data && typeof this.data == 'object' ? this.data : {} const { CURRENTCAPACITY = 0, MAXCAPACITY = 100, HIGHWATERMARK = 100, LOWWATERMARK = 0, FULLUP = 'F', CARRIER_FULL = 0, CARRIER_EMPTY = 0, CARRIER_EMPTY_EMPTY = 0, CARRIER_NORMAL = 0, CARRIER_ABNORMAL = 0 } = data const currentUsage = Number(CURRENTCAPACITY) || 0 const maxCapacity = Number(MAXCAPACITY) || 100 const highWatermark = Number(HIGHWATERMARK) || 100 const lowWatermark = Number(LOWWATERMARK) || 0 this.setState({ currentUsage, maxCapacity, highWatermark, lowWatermark, fullup: FULLUP == 'T', usage: Math.round((currentUsage / maxCapacity) * 100), carrierFull: Number(CARRIER_FULL) || 0, carrierEmpty: Number(CARRIER_EMPTY) || 0, carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0, carrierNormal: Number(CARRIER_NORMAL) || 0, carrierAbnormal: Number(CARRIER_ABNORMAL) || 0 }) } // ── hover 툴팁 (carrier/abnormal 모드 세그먼트별 수치) ─────────────────────────── // 색상만으로 타입 구분이 어려울 때, 마우스 hover 로 정확한 수치 확인. 뷰어 모드 전용 // (model-layer 가 편집 모드에선 hover dispatch 안 함). async ready(): Promise { await super.ready() // hover 이벤트 수신 opt-in: model-layer _onmouseenter_all guard(hasLegacyHover) 통과. const ev = this.model.event if (!ev || !ev.hover) { this.model.event = { ...(ev || {}), hover: true } } } onMouseEnter(): void { // capacity 는 값이 한 개(usage%)라 툴팁 불필요. carrier/abnormal 만. if (this.gaugeMode === 'capacity') return this._showGaugeTooltip() } onMouseLeave(): void { this._hideGaugeTooltip() } dispose(): void { this._hideGaugeTooltip() super.dispose() } private _tooltipEl?: HTMLDivElement private _tooltipMove?: (e: MouseEvent) => void private _showGaugeTooltip(): void { if (typeof document === 'undefined') return const segments = this.segments(this.gaugeMode).filter(s => s.count > 0) if (!segments.length) return this._hideGaugeTooltip() const el = document.createElement('div') el.style.cssText = [ 'position:fixed', 'left:-9999px', 'top:0', 'z-index:2147483647', 'pointer-events:none', 'background:rgba(30,30,30,0.92)', 'color:#fff', 'padding:6px 9px', 'border-radius:6px', 'font:12px/1.5 sans-serif', 'box-shadow:0 2px 10px rgba(0,0,0,0.35)', 'white-space:nowrap' ].join(';') for (const seg of segments) { const row = document.createElement('div') row.style.cssText = 'display:flex;align-items:center;gap:6px' const swatch = document.createElement('span') swatch.style.cssText = `display:inline-block;width:10px;height:10px;border-radius:2px;background:${seg.color}` const label = document.createElement('span') label.textContent = seg.label.replace(/\n/g, ' ') label.style.cssText = 'flex:1' const value = document.createElement('span') value.textContent = String(seg.count) value.style.cssText = 'font-weight:700;margin-left:12px' row.append(swatch, label, value) el.appendChild(row) } document.body.appendChild(el) this._tooltipEl = el this._tooltipMove = (e: MouseEvent) => { el.style.left = `${e.clientX + 14}px` el.style.top = `${e.clientY + 14}px` } document.addEventListener('mousemove', this._tooltipMove, true) } private _hideGaugeTooltip(): void { if (this._tooltipMove) { document.removeEventListener('mousemove', this._tooltipMove, true) this._tooltipMove = undefined } if (this._tooltipEl) { this._tooltipEl.remove() this._tooltipEl = undefined } } }