@fmsim/machine 2.0.0-beta.104 → 2.0.0-beta.108

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.
@@ -1,138 +1,32 @@
1
- import {
2
- Component,
3
- ComponentNature,
4
- POSITION,
5
- Properties,
6
- RectPath,
7
- Shape,
8
- sceneComponent
9
- } from '@hatiolab/things-scene'
10
- import { themesColorRange } from '@fmsim/api'
1
+ import { ComponentNature, RectPath, Shape, sceneComponent } from '@hatiolab/things-scene'
11
2
  import { MCSStatusMixin } from './features/mcs-status-mixin'
12
- import { LEGEND_CAPACITY, Legend, resolveCarrierLegend } from './features/mcs-status-default'
13
- import { safeRound } from './utils/safe-round'
14
- import { getValueOnRanges } from './utils/get-value-on-ranges'
3
+ import { LEGEND_CAPACITY, Legend } from './features/mcs-status-default'
15
4
  import { ParentObjectMixin, ParentObjectMixinProperties } from './features/parent-object-mixin'
16
- import { carrierSegments, abnormalSegments, carrierColorsFromLegend, GaugeSegment } from './utils/stocker-gauge'
5
+ import { CapacityGaugeMixin, CapacityGaugeMixinProperties } from './features/capacity-gauge-mixin'
17
6
 
18
7
  /**
19
8
  * 스토커 게이지 (통합).
20
9
  *
21
- * 하나의 컴포넌트가 mode 에 따라 3가지 게이지를 표현한다:
22
- * - 'capacity' : 용량 사용률(usage%) 채움 게이지 + high/low watermark (기본)
23
- * - 'carrier' : 캐리어 상태비율 세그먼트 (Full / Empty / EmptyEmpty)
24
- * - 'abnormal' : 캐리어 정상/비정상 비율 세그먼트 (Normal / Abnormal)
10
+ * mode 에 따라 capacity / carrier / abnormal 3가지 게이지를 표현한다. 그리는 규칙은
11
+ * CapacityGaugeMixin 에 있으며 ZoneCapacityBar 와 완전히 공유한다(단일 소스). 스토커는
12
+ * STOCKER 내부에 놓이는 Shape 로, 부모 STOCKER 가 데이터를 push 한다(isIdentifiable=false).
25
13
  *
26
- * mode 소스: root.stockerGaugeMode (기본 'capacity').
27
- * RootContainer 의 **런타임 전용 transient 속성** — 모델에 저장하지 않고 항상 'capacity'
28
- * 로 시작하며 실행 중 동적으로만 변경된다(root-container-override.ts 정의). 3D 표현
29
- * (machine-3d.ts buildGauge)과 동일 소스라 2D/3D 게이지가 항상 일치한다.
30
- *
31
- * 데이터 필드(2D 기준, canonical):
32
- * - capacity : CURRENTCAPACITY / MAXCAPACITY / HIGHWATERMARK / LOWWATERMARK / FULLUP
33
- * - carrier : CARRIER_FULL / CARRIER_EMPTY / CARRIER_EMPTY_EMPTY
34
- * - abnormal : CARRIER_NORMAL / CARRIER_ABNORMAL
14
+ * mode 소스: root.stockerGaugeMode. 3D(machine-3d buildGauge)와 동일 소스라 2D/3D 가 일치한다.
35
15
  */
36
16
 
37
17
  const NATURE: ComponentNature = {
38
18
  mutable: false,
39
19
  resizable: true,
40
20
  rotatable: true,
41
- properties: [
42
- ...ParentObjectMixinProperties,
43
- {
44
- type: 'select',
45
- label: 'legend-name',
46
- name: 'legendName',
47
- property: {
48
- options: themesColorRange
49
- }
50
- },
51
- {
52
- type: 'number',
53
- name: 'currentUsage',
54
- label: 'current-usage'
55
- },
56
- {
57
- type: 'number',
58
- name: 'highWatermark',
59
- label: 'high-watermark'
60
- },
61
- {
62
- type: 'number',
63
- name: 'lowWatermark',
64
- label: 'low-watermark'
65
- },
66
- {
67
- type: 'number',
68
- name: 'maxCapacity',
69
- label: 'max-capacity'
70
- },
71
- {
72
- type: 'boolean',
73
- name: 'fullup',
74
- label: 'fullup'
75
- },
76
- {
77
- type: 'number',
78
- name: 'carrierFull',
79
- label: 'carrier-full'
80
- },
81
- {
82
- type: 'number',
83
- name: 'carrierEmpty',
84
- label: 'carrier-empty'
85
- },
86
- {
87
- type: 'number',
88
- name: 'carrierEmptyEmpty',
89
- label: 'carrier-empty-empty'
90
- },
91
- {
92
- type: 'number',
93
- name: 'carrierNormal',
94
- label: 'carrier-normal'
95
- },
96
- {
97
- type: 'number',
98
- name: 'carrierAbnormal',
99
- label: 'carrier-abnormal'
100
- },
101
- {
102
- type: 'number',
103
- label: 'round',
104
- name: 'round',
105
- property: {
106
- min: 0
107
- }
108
- },
109
- {
110
- type: 'hidden',
111
- name: 'usage',
112
- label: 'usage'
113
- }
114
- ],
21
+ properties: [...ParentObjectMixinProperties, ...CapacityGaugeMixinProperties],
115
22
  'value-property': 'usage'
116
23
  }
117
24
 
118
- var controlHandler = {
119
- ondragmove: function (point: POSITION, index: number, component: Component) {
120
- var { left, top, width, height } = component.model
121
-
122
- var transcoorded = component.transcoordP2S(point.x, point.y)
123
- var round = ((transcoorded.x - left) / (width / 2)) * 100
124
-
125
- round = safeRound(round, width, height)
126
-
127
- component.set({
128
- round
129
- })
130
- }
131
- }
132
-
133
25
  @sceneComponent('StockerCapacityBar')
134
26
  @sceneComponent('MCSGaugeCapacityBar')
135
- export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin(RectPath(Shape))) {
27
+ export default class StockerCapacityBar extends CapacityGaugeMixin(
28
+ ParentObjectMixin(MCSStatusMixin(RectPath(Shape)))
29
+ ) {
136
30
  static get nature() {
137
31
  return NATURE
138
32
  }
@@ -142,473 +36,22 @@ export default class StockerCapacityBar extends ParentObjectMixin(MCSStatusMixin
142
36
  return ['STOCKER']
143
37
  }
144
38
 
145
- /** 게이지 모드: RootContainer 런타임 transient 속성. 3D(machine-3d) 와 동일 소스. */
146
- get gaugeMode(): string {
147
- return (this.root as any)?.stockerGaugeMode || 'capacity'
148
- }
149
-
150
- get controls() {
151
- var { left, top, width, round, height } = this.state
152
- round = round == undefined ? 0 : safeRound(round, width, height)
153
-
154
- return [
155
- {
156
- x: left + (width / 2) * (round / 100),
157
- y: top,
158
- handler: controlHandler
159
- }
160
- ]
161
- }
162
-
39
+ /** 게이지는 자체 id 로 매핑하지 않는다 — 부모 STOCKER 가 데이터를 push. */
163
40
  isIdentifiable() {
164
41
  return false
165
42
  }
166
43
 
167
- getTheme() {
168
- const { legendName } = this.state
169
-
170
- if (legendName) {
171
- return (this.root as any)?.style?.[legendName]
172
- }
173
- }
174
-
175
- get legend(): Legend {
176
- return this.getTheme() || this.getLegendFallback()
177
- }
178
-
179
- getLegendFallback() {
44
+ getLegendFallback(): Legend {
180
45
  return (this.root as any).stockerCapacityLegendTheme || LEGEND_CAPACITY
181
46
  }
182
47
 
183
- get statusColor() {
184
- const { fullup } = this.state
185
- return fullup ? 'red' : getValueOnRanges(this.usage, this.legend)
186
- }
187
-
188
- get text() {
189
- // usage% 값은 capacity 모드에서만 표시. carrier/abnormal 은 세그먼트별 자체 라벨을 그리므로
190
- // base 의 value 텍스트(value-property=usage)를 억제한다.
191
- if (this.gaugeMode !== 'capacity') {
192
- return ''
193
- }
194
- return this.usage + '%'
195
- }
196
-
197
- render(context: CanvasRenderingContext2D) {
198
- const mode = this.gaugeMode
199
-
200
- if (mode === 'carrier' || mode === 'abnormal') {
201
- this.renderSegmentGauge(context, this.segments(mode))
202
- return
203
- }
204
-
205
- // capacity (기본)
206
- const { width, height } = this.bounds
207
- if (width >= height) {
208
- this.renderLandscape(context)
209
- } else {
210
- this.renderPortrait(context)
211
- }
212
- }
213
-
214
48
  /**
215
49
  * Shape.postrender 의 drawFill/drawStroke 를 건너뛴다.
216
- * 각 모드 render 가 자체 배경(fillStyle)·외곽선(strokeStyle)을 이미 그리므로, base 가
217
- * fillStyle 로 게이지 전체를 다시 덮어(각 모드 표현이 사라지는) 것을 방지한다.
50
+ * render 가 자체 배경(fillStyle)·외곽선(strokeStyle)을 이미 그리므로, base 가
51
+ * fillStyle 로 게이지 전체를 다시 덮는(각 모드 표현이 사라지는) 것을 방지한다.
218
52
  * 값 텍스트(drawText)는 Component.postrender 와 동일하게 유지한다.
219
53
  */
220
54
  postrender(context: CanvasRenderingContext2D) {
221
- this.drawText(context)
222
- }
223
-
224
- /**
225
- * carrier/abnormal mode 의 세그먼트 목록 (공유 유틸 = 3D 와 동일 계산).
226
- * 배열 순서 = portrait 아래→위 / landscape 왼→오.
227
- */
228
- segments(mode: string): GaugeSegment[] {
229
- if (mode === 'abnormal') {
230
- const { carrierNormal = 0, carrierAbnormal = 0 } = this.state
231
- return abnormalSegments(carrierNormal, carrierAbnormal)
232
- }
233
- const { carrierFull = 0, carrierEmpty = 0, carrierEmptyEmpty = 0 } = this.state
234
- // 색상은 carrier 컴포넌트와 동일한 legend(테마)에서 취득 (carrierColorsFromLegend).
235
- const colors = carrierColorsFromLegend(resolveCarrierLegend(this))
236
- return carrierSegments(carrierFull, carrierEmpty, carrierEmptyEmpty, colors)
237
- }
238
-
239
- /** 세그먼트 게이지 렌더 (carrier/abnormal 공통). bounds 방향으로 landscape/portrait 자동. */
240
- renderSegmentGauge(context: CanvasRenderingContext2D, segments: GaugeSegment[]) {
241
- const { left, top, width, height } = this.bounds
242
- const { lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
243
- const landscape = width >= height
244
-
245
- context.save()
246
- context.translate(left, top)
247
-
248
- // background
249
- context.beginPath()
250
- context.fillStyle = fillStyle
251
- context.roundRect(0, 0, width, height, round)
252
- context.fill()
253
- context.clip()
254
-
255
- if (landscape) {
256
- // 왼→오
257
- let x = 0
258
- for (const seg of segments) {
259
- const segWidth = width * seg.ratio
260
- if (segWidth <= 0) continue
261
-
262
- context.beginPath()
263
- context.fillStyle = seg.color
264
- context.rect(x, 0, segWidth, height)
265
- context.fill()
266
-
267
- this._drawSegmentLabel(context, seg, x, 0, segWidth, height)
268
- x += segWidth
269
- }
270
- } else {
271
- // 아래→위 (배열 첫 세그먼트가 바닥)
272
- let y = height
273
- for (const seg of segments) {
274
- const segHeight = height * seg.ratio
275
- if (segHeight <= 0) continue
276
-
277
- y -= segHeight
278
-
279
- context.beginPath()
280
- context.fillStyle = seg.color
281
- context.rect(0, y, width, segHeight)
282
- context.fill()
283
-
284
- this._drawSegmentLabel(context, seg, 0, y, width, segHeight)
285
- }
286
- }
287
-
288
- // outline
289
- context.beginPath()
290
- context.setLineDash([])
291
- context.strokeStyle = strokeStyle
292
- context.lineWidth = lineWidth
293
- context.roundRect(0, 0, width, height, round)
294
- context.stroke()
295
-
296
- context.translate(-left, -top)
297
- context.restore()
298
- }
299
-
300
- private _drawSegmentLabel(
301
- context: CanvasRenderingContext2D,
302
- seg: GaugeSegment,
303
- x: number,
304
- y: number,
305
- w: number,
306
- h: number
307
- ) {
308
- // 이름 라벨(Full/Empty/Abnormal…)은 그리지 않는다. 각 세그먼트 영역 중심에 숫자(%)만 표시.
309
- // 컴포넌트의 폰트 설정(fontFamily/fontSize/bold/italic)을 그대로 적용 — this.font.
310
- const fontSize = this.state.fontSize || 15
311
- context.font = this.font
312
-
313
- // 공간에 따라 단계적 축소: 넉넉하면 '값%', 좁으면 '%' 떼고 값만, 더 좁으면 생략.
314
- const value = `${Math.round(seg.ratio * 100)}`
315
- const fits = (t: string) => fontSize <= h && context.measureText(t).width <= w
316
-
317
- let text: string | null = null
318
- if (fits(`${value}%`)) {
319
- text = `${value}%`
320
- } else if (fits(value)) {
321
- text = value
322
- }
323
- if (text === null) {
324
- return
325
- }
326
-
327
- context.fillStyle = '#333'
328
- context.textAlign = 'center'
329
- context.textBaseline = 'middle'
330
- context.fillText(text, x + w / 2, y + h / 2)
331
- }
332
-
333
- renderLandscape(context: CanvasRenderingContext2D) {
334
- const { left, top, width, height } = this.bounds
335
- const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
336
-
337
- context.save()
338
- context.translate(left, top)
339
-
340
- // gauge background
341
- context.beginPath()
342
-
343
- context.fillStyle = fillStyle
344
- context.roundRect(0, 0, width, height, round)
345
-
346
- context.fill()
347
-
348
- context.clip()
349
-
350
- // gauge foreground
351
- context.beginPath()
352
-
353
- context.fillStyle = this.statusColor!
354
-
355
- context.rect(0, 0, (width / 100) * this.usage, height)
356
- context.fill()
357
-
358
- // high-watermark level
359
-
360
- if (highWatermark && highWatermark > 0 && highWatermark < 100) {
361
- context.beginPath()
362
-
363
- context.strokeStyle = 'black'
364
- context.lineWidth = 1
365
- context.setLineDash([3])
366
-
367
- const x = (width / 100) * highWatermark
368
- context.moveTo(x, 0)
369
- context.lineTo(x, height)
370
-
371
- context.stroke()
372
- }
373
-
374
- // low-watermark level
375
-
376
- if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) {
377
- context.beginPath()
378
-
379
- context.strokeStyle = 'black'
380
- context.lineWidth = 1
381
- context.setLineDash([3])
382
-
383
- const x = (width / 100) * lowWatermark
384
- context.moveTo(x, 0)
385
- context.lineTo(x, height)
386
-
387
- context.stroke()
388
- }
389
-
390
- // gauge outline
391
- context.beginPath()
392
-
393
- context.setLineDash([])
394
- context.strokeStyle = strokeStyle
395
- context.lineWidth = lineWidth
396
- context.roundRect(0, 0, width, height, round)
397
- context.stroke()
398
-
399
- context.beginPath()
400
-
401
- context.translate(-left, -top)
402
- context.restore()
403
- }
404
-
405
- renderPortrait(context: CanvasRenderingContext2D) {
406
- const { left, top, width, height } = this.bounds
407
- const { highWatermark, lowWatermark, lineWidth, strokeStyle, round, fillStyle = 'white' } = this.state
408
-
409
- context.save()
410
- context.translate(left, top)
411
-
412
- // gauge background
413
- context.beginPath()
414
-
415
- context.fillStyle = fillStyle
416
- context.roundRect(0, 0, width, height, round)
417
-
418
- context.fill()
419
-
420
- context.clip()
421
-
422
- // gauge foreground
423
- context.beginPath()
424
-
425
- context.fillStyle = this.statusColor!
426
-
427
- context.rect(0, height - (height / 100) * this.usage, width, (height / 100) * this.usage)
428
- context.fill()
429
-
430
- // high-watermark level
431
-
432
- if (highWatermark && highWatermark > 0 && highWatermark < 100) {
433
- context.beginPath()
434
-
435
- context.strokeStyle = 'black'
436
- context.lineWidth = 1
437
- context.setLineDash([3])
438
-
439
- const y = height - (height / 100) * highWatermark
440
- context.moveTo(0, y)
441
- context.lineTo(width, y)
442
-
443
- context.stroke()
444
- }
445
-
446
- // low-watermark level
447
-
448
- if (lowWatermark && lowWatermark > 0 && lowWatermark < 100) {
449
- context.beginPath()
450
-
451
- context.strokeStyle = 'black'
452
- context.lineWidth = 1
453
- context.setLineDash([3])
454
-
455
- const y = height - (height / 100) * lowWatermark
456
- context.moveTo(0, y)
457
- context.lineTo(width, y)
458
-
459
- context.stroke()
460
- }
461
-
462
- // gauge outline
463
- context.beginPath()
464
-
465
- context.setLineDash([])
466
- context.strokeStyle = strokeStyle
467
- context.lineWidth = lineWidth
468
- context.roundRect(0, 0, width, height, round)
469
- context.stroke()
470
-
471
- context.beginPath()
472
-
473
- context.translate(-left, -top)
474
- context.restore()
475
- }
476
-
477
- get usage() {
478
- const { currentUsage = 0, maxCapacity = 100 } = this.state
479
- return Math.round((currentUsage / maxCapacity) * 100)
480
- }
481
-
482
- set usage(usage: number) {
483
- // intentionally have done nothing
484
- }
485
-
486
- onchangeData(after: Record<string, any>, before: Record<string, any>): void {
487
- const data = this.data && typeof this.data == 'object' ? this.data : {}
488
- const {
489
- CURRENTCAPACITY = 0,
490
- MAXCAPACITY = 100,
491
- HIGHWATERMARK = 100,
492
- LOWWATERMARK = 0,
493
- FULLUP = 'F',
494
- CARRIER_FULL = 0,
495
- CARRIER_EMPTY = 0,
496
- CARRIER_EMPTY_EMPTY = 0,
497
- CARRIER_NORMAL = 0,
498
- CARRIER_ABNORMAL = 0
499
- } = data
500
-
501
- const currentUsage = Number(CURRENTCAPACITY) || 0
502
- const maxCapacity = Number(MAXCAPACITY) || 100
503
- const highWatermark = Number(HIGHWATERMARK) || 100
504
- const lowWatermark = Number(LOWWATERMARK) || 0
505
-
506
- this.setState({
507
- currentUsage,
508
- maxCapacity,
509
- highWatermark,
510
- lowWatermark,
511
- fullup: FULLUP == 'T',
512
- usage: Math.round((currentUsage / maxCapacity) * 100),
513
- carrierFull: Number(CARRIER_FULL) || 0,
514
- carrierEmpty: Number(CARRIER_EMPTY) || 0,
515
- carrierEmptyEmpty: Number(CARRIER_EMPTY_EMPTY) || 0,
516
- carrierNormal: Number(CARRIER_NORMAL) || 0,
517
- carrierAbnormal: Number(CARRIER_ABNORMAL) || 0
518
- })
519
- }
520
-
521
- // ── hover 툴팁 (carrier/abnormal 모드 세그먼트별 수치) ───────────────────────────
522
- // 색상만으로 타입 구분이 어려울 때, 마우스 hover 로 정확한 수치 확인. 뷰어 모드 전용
523
- // (model-layer 가 편집 모드에선 hover dispatch 안 함).
524
-
525
- async ready(): Promise<void> {
526
- await super.ready()
527
- // hover 이벤트 수신 opt-in: model-layer _onmouseenter_all guard(hasLegacyHover) 통과.
528
- const ev = this.model.event
529
- if (!ev || !ev.hover) {
530
- this.model.event = { ...(ev || {}), hover: true }
531
- }
532
- }
533
-
534
- onMouseEnter(): void {
535
- // capacity 는 값이 한 개(usage%)라 툴팁 불필요. carrier/abnormal 만.
536
- if (this.gaugeMode === 'capacity') return
537
- this._showGaugeTooltip()
538
- }
539
-
540
- onMouseLeave(): void {
541
- this._hideGaugeTooltip()
542
- }
543
-
544
- dispose(): void {
545
- this._hideGaugeTooltip()
546
- super.dispose()
547
- }
548
-
549
- private _tooltipEl?: HTMLDivElement
550
- private _tooltipMove?: (e: MouseEvent) => void
551
-
552
- private _showGaugeTooltip(): void {
553
- if (typeof document === 'undefined') return
554
- const segments = this.segments(this.gaugeMode).filter(s => s.count > 0)
555
- if (!segments.length) return
556
-
557
- this._hideGaugeTooltip()
558
-
559
- const el = document.createElement('div')
560
- el.style.cssText = [
561
- 'position:fixed',
562
- 'left:-9999px',
563
- 'top:0',
564
- 'z-index:2147483647',
565
- 'pointer-events:none',
566
- 'background:rgba(30,30,30,0.92)',
567
- 'color:#fff',
568
- 'padding:6px 9px',
569
- 'border-radius:6px',
570
- 'font:12px/1.5 sans-serif',
571
- 'box-shadow:0 2px 10px rgba(0,0,0,0.35)',
572
- 'white-space:nowrap'
573
- ].join(';')
574
-
575
- for (const seg of segments) {
576
- const row = document.createElement('div')
577
- row.style.cssText = 'display:flex;align-items:center;gap:6px'
578
-
579
- const swatch = document.createElement('span')
580
- swatch.style.cssText = `display:inline-block;width:10px;height:10px;border-radius:2px;background:${seg.color}`
581
-
582
- const label = document.createElement('span')
583
- label.textContent = seg.label.replace(/\n/g, ' ')
584
- label.style.cssText = 'flex:1'
585
-
586
- const value = document.createElement('span')
587
- value.textContent = String(seg.count)
588
- value.style.cssText = 'font-weight:700;margin-left:12px'
589
-
590
- row.append(swatch, label, value)
591
- el.appendChild(row)
592
- }
593
-
594
- document.body.appendChild(el)
595
- this._tooltipEl = el
596
-
597
- this._tooltipMove = (e: MouseEvent) => {
598
- el.style.left = `${e.clientX + 14}px`
599
- el.style.top = `${e.clientY + 14}px`
600
- }
601
- document.addEventListener('mousemove', this._tooltipMove, true)
602
- }
603
-
604
- private _hideGaugeTooltip(): void {
605
- if (this._tooltipMove) {
606
- document.removeEventListener('mousemove', this._tooltipMove, true)
607
- this._tooltipMove = undefined
608
- }
609
- if (this._tooltipEl) {
610
- this._tooltipEl.remove()
611
- this._tooltipEl = undefined
612
- }
55
+ ;(this as any).drawText(context)
613
56
  }
614
57
  }
@@ -16,6 +16,8 @@ import { Legend } from '../features/mcs-status-default'
16
16
  export type StockerGaugeMode = 'capacity' | 'carrier' | 'abnormal'
17
17
 
18
18
  export interface GaugeSegment {
19
+ /** 안정적 식별 토큰 (대문자, 클릭 이벤트 payload·데이터 필드 관례와 일치). */
20
+ key: 'FULL' | 'EMPTY' | 'EMPTYEMPTY' | 'NORMAL' | 'ABNORMAL'
19
21
  /** 세그먼트 라벨(2D per-segment 표시용, '\n' 은 줄바꿈). */
20
22
  label: string
21
23
  /** 카운트 원본값. */
@@ -80,9 +82,9 @@ export function carrierSegments(
80
82
  const total = f + e + ee
81
83
  const r = (c: number) => (total > 0 ? c / total : 0)
82
84
  return [
83
- { label: 'Full', count: f, ratio: r(f), color: colors.full },
84
- { label: 'Empty', count: e, ratio: r(e), color: colors.empty },
85
- { label: 'Empty\nEmpty', count: ee, ratio: r(ee), color: colors.emptyEmpty }
85
+ { key: 'FULL', label: 'Full', count: f, ratio: r(f), color: colors.full },
86
+ { key: 'EMPTY', label: 'Empty', count: e, ratio: r(e), color: colors.empty },
87
+ { key: 'EMPTYEMPTY', label: 'Empty\nEmpty', count: ee, ratio: r(ee), color: colors.emptyEmpty }
86
88
  ]
87
89
  }
88
90
 
@@ -93,11 +95,32 @@ export function abnormalSegments(normal: unknown, abnormal: unknown): GaugeSegme
93
95
  const total = n + a
94
96
  const r = (c: number) => (total > 0 ? c / total : 0)
95
97
  return [
96
- { label: 'Normal', count: n, ratio: r(n), color: ABNORMAL_COLORS.normal },
97
- { label: 'Abnormal', count: a, ratio: r(a), color: ABNORMAL_COLORS.abnormal }
98
+ { key: 'NORMAL', label: 'Normal', count: n, ratio: r(n), color: ABNORMAL_COLORS.normal },
99
+ { key: 'ABNORMAL', label: 'Abnormal', count: a, ratio: r(a), color: ABNORMAL_COLORS.abnormal }
98
100
  ]
99
101
  }
100
102
 
103
+ /**
104
+ * 누적 비율 위치(pos, 0..span 범위)가 속한 세그먼트를 반환한다. ratio 0 세그먼트는
105
+ * 화면에 없으므로 건너뛴다. 경계(pos == span)·초과 클릭은 마지막 비영 세그먼트로 귀속하고,
106
+ * 데이터가 전혀 없으면(모든 ratio 0) null.
107
+ *
108
+ * renderSegmentGauge 의 배치와 동일 규칙(누적 채움)이며, 2D 렌더와 클릭 판정이 이 함수를
109
+ * 공유해 drift 를 막는다. 방향(landscape 왼→오 / portrait 아래→위)은 호출부가 pos 로 흡수한다.
110
+ */
111
+ export function pickSegmentAt(segments: GaugeSegment[], pos: number, span: number): GaugeSegment | null {
112
+ let acc = 0
113
+ let lastNonZero: GaugeSegment | null = null
114
+ for (const seg of segments) {
115
+ const segLen = span * seg.ratio
116
+ if (segLen <= 0) continue
117
+ lastNonZero = seg
118
+ if (pos < acc + segLen) return seg
119
+ acc += segLen
120
+ }
121
+ return lastNonZero
122
+ }
123
+
101
124
  /** 캐리어 세그먼트 — canonical raw data(CARRIER_*) 에서 직접. 3D 처럼 raw data 소스용. */
102
125
  export function carrierSegmentsFromData(data: any, colors?: CarrierColors): GaugeSegment[] {
103
126
  const d = data && typeof data === 'object' ? data : {}