/* * 주의 신호 요약 — **순수**. "화면이 실제로 쓰는 두 가지만 값싸게 만든다." * * ── 왜 있나 (2026-08-05) ──────────────────────────────────────────────────── * 축 선택으로 리프레시를 80KB → 2KB 로 줄였는데, **신호가 많은 트윈은 거기서 막혔다**: `hatio-yard` * 는 신호 397건이 108KB 라 5배 감소에 그쳤다. 그런데 화면이 그 397건으로 하는 일은 둘뿐이다. * * ① 레일에 **20건**만 보여주고 나머지는 "N건 더" 라고만 말한다. * ② 자리(폴리곤)를 칠할 **자리별 최고 심각도**를 뽑는다 — 397건이 자리 7곳의 색 7개로 줄어든다. * * 즉 전량을 내려보내 클라이언트가 계산하는 구조였다. 계산하는 것은 값싸므로 **서버가 계산해서 보낸다.** * * ── 알리지 않고 자르지 않는다 ──────────────────────────────────────────────────── * 잘랐다는 사실과 **전체 개수**를 함께 보낸다. 경고 목록에서 들어온 것이 없는 누락은 "괜찮은 줄 알았다" 로 * 이어진다 — 레일이 이미 "N건 더" 를 말하고 있었고, 그 N 은 전체 개수에서 나와야 맞다. * * ── 정렬을 한 곳에 둔다 ───────────────────────────────────────────────────── * 상위 N 을 서버가 고르므로 **클라이언트와 같은 순서**여야 한다. 순서가 다르면 서버가 고른 20건이 * 화면이 고를 20건과 달라지고, 그 어긋남은 아무 데서도 오류로 드러나지 않는다. * 순서 = 확인된 것은 아래로, 그다음 심각도 높은 것부터(클라이언트 정렬과 동일). */ /** 알람 표준(ISA-18.2) 우선순위 — 큰 값이 급하다. */ const RANK: Record = { low: 1, medium: 2, high: 3, critical: 4 } interface AttentionLike { id?: string /** 어느 트윈(렌즈)에서 온 신호인가 — 공간에서 모을 때 붙는다. */ instanceId?: string severity?: string state?: string anchor?: { locationId?: string } } export interface AttentionDigestResult { /** 상위 N — 레일이 그리는 것. */ attentions: AttentionLike[] /** 전체 개수 — 레일의 "N건 더" 가 이 값에서 나온다(자른 사실을 밝힌다). */ attentionTotal: number /** * 자리별 최고 심각도 — **전량을 근거로** 계산한다. * * 상위 N 만으로 계산하면 20위 밖의 신호를 가진 자리가 색을 잃는다. 그것이 이 요약의 핵심 이유다: * 레일은 잘라도 되지만 **지도 색은 잘라선 안 된다.** */ severityByLocation: Record } /** 화면 정렬과 같은 순서 — 확인된 것은 아래로, 그다음 심각도 내림차순. */ export const compareAttentions = (a: AttentionLike, b: AttentionLike): number => (a.state === 'acknowledged' ? 1 : 0) - (b.state === 'acknowledged' ? 1 : 0) || (RANK[b.severity ?? ''] ?? 0) - (RANK[a.severity ?? ''] ?? 0) /** * 신호를 요약한다. * * `limit` 이 없거나 0 이하면 **자르지 않는다** — 자르는 쪽이 기본이면 이 값을 모르는 소비처가 * 알리지 않고 일부만 받는다(요약을 도입하다 만드는 전형적인 사고다). */ export function digestAttentions(attentions: readonly AttentionLike[] | undefined | null, limit?: number): AttentionDigestResult { const all = Array.isArray(attentions) ? attentions : [] /* 자리 색은 전량으로 처리한다 — 잘린 뒤에 계산하면 색을 잃는 자리가 생긴다. */ const severityByLocation: Record = {} for (const a of all) { const nid = a?.anchor?.locationId if (!nid) continue const cur = severityByLocation[nid] if (!cur || (RANK[a.severity ?? ''] ?? 0) > (RANK[cur] ?? 0)) severityByLocation[nid] = a.severity ?? '' } const sorted = [...all].sort(compareAttentions) const capped = limit && limit > 0 ? sorted.slice(0, limit) : sorted return { attentions: capped, attentionTotal: all.length, severityByLocation } } /** 렌즈 하나가 낸 신호 — 어느 트윈에서 왔는지와 함께. */ export interface LensAttentions { instanceId: string attentions: readonly AttentionLike[] | undefined | null } /** * 여러 렌즈의 신호를 **한 현장의 목록으로** 모은다. * * 한 공장에 WMS·MES·YMS 가 함께 있으므로 "이 현장에서 봐야 할 것" 은 렌즈 하나로 답할 수 없다. * 모으면서 지키는 것 셋: * * ① **어느 트윈에서 왔는지 붙인다**(`instanceId`) — 조치는 결국 그 트윈에 보내야 하고, 공간에서 보면 * 같은 자리 id 가 렌즈마다 다른 것을 가리킬 수 있다. * ② **순서는 렌즈 순서가 아니라 급한 순서다** — 앞 렌즈의 낮은 신호가 뒤 렌즈의 심각한 신호를 * 밀어내면, 상위 N 만 보는 화면에서 정작 급한 것이 안 보인다. * ③ **자리 색은 렌즈를 가로질러 가장 급한 것으로** 계산한다 — 두 렌즈가 같은 자리를 가리키면 그 자리는 * 하나의 현실이므로 더 급한 쪽이 이긴다(둘 중 하나를 임의로 고르면 색이 렌즈 순서에 따라 달라진다). */ export function mergeLensAttentions(lenses: readonly LensAttentions[], limit?: number): AttentionDigestResult { const all: AttentionLike[] = [] for (const lens of lenses) { for (const a of lens.attentions ?? []) all.push({ ...a, instanceId: lens.instanceId } as AttentionLike) } return digestAttentions(all, limit) }