import { Component, POSITION } from '@hatiolab/things-scene' import { themesColorRange } from '@fmsim/api' import { LEGEND_CAPACITY, Legend, resolveCarrierLegend } from './mcs-status-default' import { safeRound } from '../utils/safe-round' import { getValueOnRanges } from '../utils/get-value-on-ranges' import { carrierSegments, abnormalSegments, carrierColorsFromLegend, pickSegmentAt, GaugeSegment } from '../utils/stocker-gauge' type Constructor = new (...args: any[]) => T /** * 용량 게이지 (통합 로직). * * 하나의 컴포넌트가 mode 에 따라 3가지 게이지를 표현한다: * - 'capacity' : 용량 사용률(usage%) 채움 게이지 + high/low watermark (기본) * - 'carrier' : 캐리어 상태비율 세그먼트 (Full / Empty / EmptyEmpty) * - 'abnormal' : 캐리어 정상/비정상 비율 세그먼트 (Normal / Abnormal) * * StockerCapacityBar(스토커 지붕 게이지)와 ZoneCapacityBar(존 게이지)가 이 로직을 공유한다. * 두 컴포넌트는 base(스토커=Shape, 존=Container)·정체성(identifiable)·legend 폴백만 다르고, * 게이지를 그리는 규칙은 완전히 동일하다 — 규칙을 이 믹스인 한 곳에 두어 drift 를 막는다. * * mode 소스: root.stockerGaugeMode (기본 'capacity'). RootContainer 의 런타임 전용 * transient 속성으로, 3D(machine-3d buildGauge)와도 동일 소스라 2D/3D 게이지가 항상 일치한다. * (root-container-override.ts 정의. 모드가 바뀌면 _refreshStockerGauges 가 두 게이지를 갱신.) * * 데이터 필드(2D 기준, canonical): * - capacity : CURRENTCAPACITY / MAXCAPACITY / HIGHWATERMARK / LOWWATERMARK / FULLUP * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL */ /** 게이지 NATURE 속성 조각 (스토커/존 공통). 각 컴포넌트 NATURE 에 spread 로 합친다. */ export const CapacityGaugeMixinProperties = [ { 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' } ] const controlHandler = { ondragmove: function (point: POSITION, index: number, component: Component) { var { left, top, width, height } = (component as any).model var transcoorded = (component as any).transcoordP2S(point.x, point.y) var round = ((transcoorded.x - left) / (width / 2)) * 100 round = safeRound(round, width, height) ;(component as any).set({ round }) } } export function CapacityGaugeMixin>(Base: TBase): any { return class extends Base { /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */ get gaugeMode(): string { return (this as any).root?.stockerGaugeMode || 'capacity' } get controls() { var { left, top, width, round, height } = (this as any).state round = round == undefined ? 0 : safeRound(round, width, height) return [ { x: left + (width / 2) * (round / 100), y: top, handler: controlHandler } ] } getTheme() { const { legendName } = (this as any).state if (legendName) { return (this as any).root?.style?.[legendName] } } get legend(): Legend { return this.getTheme() || this.getLegendFallback() } /** legend 미지정 시 폴백 테마. 컴포넌트별로 override (스토커/존 각자 전역 테마). */ getLegendFallback(): Legend { return LEGEND_CAPACITY } get statusColor(): string | undefined { const { fullup } = (this as any).state return fullup ? 'red' : getValueOnRanges(this.usage, this.legend) } get text() { // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로 // base 의 value 텍스트를 억제한다. 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 as any).bounds if (width >= height) { this.renderLandscape(context) } else { this.renderPortrait(context) } } /** * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산). * 배열 순서 = portrait 아래→위 / landscape 왼→오. */ segments(mode: string): GaugeSegment[] { if (mode === 'abnormal') { const { carrierNormal = 0, carrierAbnormal = 0 } = (this as any).state return abnormalSegments(carrierNormal, carrierAbnormal) } const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = (this as any).state // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득 (carrierColorsFromLegend). const colors = carrierColorsFromLegend(resolveCarrierLegend(this)) return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty, colors) } /** * 클릭(또는 hover) 지점이 어떤 세그먼트에 속하는지 판정. carrier/abnormal 모드 전용 — * capacity 모드·데이터 없음·좌표 변환 실패 시 null. * * renderSegmentGauge 와 동일한 배치 규칙으로 매핑한다: landscape(가로) 는 왼→오(누적 x), * portrait(세로) 는 아래→위(바닥부터 누적). ratio 0 세그먼트는 화면에 없으므로 건너뛴다. */ segmentAtEvent(event: MouseEvent): { mode: string; key: string; count: number } | null { const mode = this.gaugeMode if (mode !== 'carrier' && mode !== 'abnormal') return null // 3D 게이지는 부모 STOCKER 의 real-object 가 그린다(이 컴포넌트가 아님) → 2D 세그먼트 판정 대상 아님. if ((this as any).root?.is3dMode) return null const ox = (event as any)?.offsetX const oy = (event as any)?.offsetY if (typeof ox !== 'number' || typeof oy !== 'number') return null // 표면 픽셀(offsetX/offsetY) → 이 컴포넌트 로컬 프레임. // 주의: transcoordP2S 는 '부모→로컬' 한 단계라 캔버스 픽셀로 단독 호출하면 안 된다. // 엔진 capture() 와 동일하게 루트부터 전체 체인을 적용하는 helper 가 toLocal. let local: { x: number; y: number } | undefined try { local = (this as any).toLocal(ox, oy) } catch { return null } if (!local) return null const { left, top, width, height } = (this as any).bounds if (!(width > 0) || !(height > 0)) return null // 컴포넌트 로컬프레임 좌표 → bounds 기준 within-box. 경계 반올림 방어로 clamp. const lx = Math.min(Math.max(local.x - left, 0), width) const ly = Math.min(Math.max(local.y - top, 0), height) const landscape = width >= height const pos = landscape ? lx : height - ly // portrait 는 바닥 기준 거리 const span = landscape ? width : height const picked = pickSegmentAt(this.segments(mode), pos, span) if (!picked) return null // label 은 key 로 도출 가능하고 로케일 의존이라 payload 에서 제외 — key(대문자 토큰)로 식별. return { mode, key: picked.key, count: picked.count } } /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */ renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) { const { left, top, width, height } = (this as any).bounds const { lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).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 as any).state.fontSize || 15 context.font = (this as any).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 as any).bounds const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).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 as any).bounds const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = (this as any).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 as any).state return Math.round((currentUsage / maxCapacity) * 100) } set usage(usage: number) { // intentionally have done nothing } onchangeData(after: Record, before: Record): void { const data = (this as any).data && typeof (this as any).data == 'object' ? (this as any).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 as any).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 as any).model.event if (!ev || !ev.hover) { ;(this as any).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 } } } }