feat: 트렌드 워크스페이스(ECharts) 추가 + 이벤트히스토리 sub_area 정렬
트렌드 P1 (Tab 17 "트렌드"): - 단일 ECharts 차트 슬롯구조(trState/TR_LAYERS/trRender) — P2/P3 무중단 증분 기반 - 기능: 그룹빌더(realtime 아날로그 클릭선택)/멀티시리즈·이중축/dataZoom(좌우날짜)/ 범례표(색변경·행클릭 강조·보이는구간 통계)/보이는범위 minmax 마커/라이브 현재값/ 트립·이벤트 오버레이(/api/event-history 재사용)/100%환산/Y줌 - 백엔드: trend_group 테이블 + v_analog_points 뷰(숫자 livevalue=아날로그) + TrendService/TrendController(/api/trend: analog-points·groups CRUD·live) + DI - echarts 5.5.1 로컬 번들, DTO는 [JsonPropertyName]로 camelCase 고정 이벤트히스토리 sub_area 정렬(컬럼 일치): - event_history_table section → sub_area (DDL/인덱스/엔티티/DTO/서비스/컨트롤러/evt UI/MCP/프롬프트) - 이력 조회 PIVOT 재작성(MAX/CASE), ft.category '계기' → 'instrument' Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1622,7 +1622,7 @@ async def query_events(
|
||||
|
||||
sql = f"""
|
||||
SELECT id, tagname, prev_value, curr_value, event_type, event_time,
|
||||
area, section, duration_seconds, metadata
|
||||
area, sub_area, duration_seconds, metadata
|
||||
FROM event_history_table
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY event_time DESC
|
||||
@@ -1638,7 +1638,7 @@ async def query_events(
|
||||
events = [
|
||||
{"id": r[0], "tag_name": r[1], "prev_value": r[2], "curr_value": r[3],
|
||||
"event_type": r[4], "event_time": r[5].isoformat() if r[5] else None,
|
||||
"area": r[6], "section": r[7], "prev_state_duration_s": r[8], "metadata": r[9]}
|
||||
"area": r[6], "sub_area": r[7], "prev_state_duration_s": r[8], "metadata": r[9]}
|
||||
for r in rows
|
||||
]
|
||||
return json.dumps({
|
||||
@@ -1678,13 +1678,13 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
WITH latest AS (
|
||||
SELECT DISTINCT ON (tagname)
|
||||
id, tagname, curr_value, event_type, event_time,
|
||||
area, section, duration_seconds, metadata
|
||||
area, sub_area, duration_seconds, metadata
|
||||
FROM event_history_table
|
||||
WHERE event_time >= NOW() - INTERVAL '30 days'
|
||||
ORDER BY tagname, event_time DESC
|
||||
)
|
||||
SELECT id, tagname, curr_value, event_type, event_time,
|
||||
area, section, duration_seconds, metadata,
|
||||
area, sub_area, duration_seconds, metadata,
|
||||
EXTRACT(EPOCH FROM (NOW() - event_time))::bigint AS age_seconds
|
||||
FROM latest
|
||||
WHERE event_type IN ('ALARM', 'TRIP')
|
||||
@@ -1700,7 +1700,7 @@ async def active_alarms(area: str | None = None, limit: int = 100) -> str:
|
||||
alarms = [
|
||||
{"id": r[0], "tag_name": r[1], "curr_value": r[2], "event_type": r[3],
|
||||
"since": r[4].isoformat() if r[4] else None,
|
||||
"area": r[5], "section": r[6], "prev_state_duration_s": r[7], "metadata": r[8],
|
||||
"area": r[5], "sub_area": r[6], "prev_state_duration_s": r[7], "metadata": r[8],
|
||||
"age_seconds": r[9]}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
> 본 파일은 LLM 채팅의 시스템 프롬프트에 자동 주입됩니다.
|
||||
> 운영 환경에 맞춰 단위(Area / Unit), 계기 prefix, 태그 명명 규칙, 예시 질문 등을 채워주세요.
|
||||
|
||||
## ⚠️ 데이터 정확성 원칙 — MCP 툴 결과 우선 (필수)
|
||||
|
||||
LLM이 내부 지식(훈련 데이터)으로 DB 값을 **추론·보충·번역·가공**하지 마세요.
|
||||
MCP 툴(`find_tags`, `run_sql`, `active_alarms`, `query_events` 등)이 반환한 값을 **있는 그대로** 사용해야 합니다.
|
||||
|
||||
| 규칙 | 내용 |
|
||||
|------|------|
|
||||
| **null/공백 유지** | DB에 `null` 또는 `" "`(공백)인 description/컬럼은 "없음" / "미등록"으로 표시. **임의의 설명을 생성하지 마세요** |
|
||||
| **상태 그대로** | PV 값(`{5 \| R-RUN \| }`, `{0 \| L-STOP \| }`)을 LLM이 재해석/변환하지 말고 원시 값으로 표시 |
|
||||
| **툴 우선** | MCP 툴 결과와 LLM 내부 지식이 충돌하면 **무조건 MCP 툴 결과를 우선**. "아마", "보통은" 같은 추론 금지 |
|
||||
| **새 정보 금지** | 툴이 반환하지 않은 태그명, 설명, 상태를 절대 추가하지 마세요 |
|
||||
| **재검증** | 확신이 없으면 답변 전에 `find_tags` / `run_sql`로 한 번 더 확인 |
|
||||
|
||||
위반 예: `description = null`인데 "원료 투입 펌프"라고 지어냄, `sub_area = "P6-1"`인데 "공용"이라고 지어냄, `pv = L-STOP`인데 "운전 중"이라고 답변.
|
||||
|
||||
## 단위(Area / Unit) 명명
|
||||
|
||||
DB의 area 컬럼은 두 가지 표기가 혼재합니다 — 도구 호출 시 표기를 구분해서 사용해야 합니다.
|
||||
@@ -178,6 +193,38 @@ ORDER BY area_code;
|
||||
- "P6 펌프 어떤 게 돌아가?" → `SELECT running_pump_tags FROM v_plant_running_state WHERE area_code='P6'`
|
||||
- "트립 펌프 있어?" → `SELECT area_code, tripped_pumps FROM v_plant_running_state WHERE tripped_pumps > 0`
|
||||
|
||||
### ⚠️ sub_area 필터 필수 — `v_plant_running_state_agg`는 area 레벨 전용
|
||||
|
||||
`v_plant_running_state_agg`는 **`GROUP BY area_code`** 만 하므로 **sub_area 컬럼이 없습니다.**
|
||||
"6-1차 정지 펌프" 같은 질문에 이 뷰를 사용하면 **같은 area(P6)의 전체 펌프(P6-1 + P6-2)가 섞여서 조회**됩니다.
|
||||
|
||||
| 뷰 | sub_area 지원 | 용도 |
|
||||
|---|:---:|---|
|
||||
| `v_plant_running_state` | ❌ 없음 | area 전체 운전 판정 (1순위) |
|
||||
| `v_plant_running_state_agg` | ❌ 없음 (→ `sub_areas` 배열로 area 내 sub_area 목록 확인만 가능) | area 전체 교차검증 집계 |
|
||||
| `v_plant_running_state_corroborated` | ✅ **있음** (`sub_area` 컬럼) | sub_area 필터링 필요 시 **필수 사용** |
|
||||
|
||||
**올바른 sub_area 쿼리 패턴:**
|
||||
|
||||
```sql
|
||||
-- 6-1차 정지 펌프 목록
|
||||
SELECT base_tag, sub_area, corroborated_status, flow_kg_hr, vacuum_torr
|
||||
FROM v_plant_running_state_corroborated
|
||||
WHERE sub_area LIKE '%P6-1%'
|
||||
ORDER BY base_tag;
|
||||
|
||||
-- 6-1차 운전/정지 집계
|
||||
SELECT corroborated_status, COUNT(*) AS cnt,
|
||||
array_agg(base_tag ORDER BY base_tag) AS tags
|
||||
FROM v_plant_running_state_corroborated
|
||||
WHERE sub_area LIKE '%P6-1%'
|
||||
GROUP BY corroborated_status;
|
||||
```
|
||||
|
||||
`sub_area`가 `'P6-1,P6-2'`처럼 여러 값을 가지는 **공용 태그도 위 LIKE 패턴으로 양쪽 sub_area에 모두 포함**됩니다.
|
||||
|
||||
`v_plant_running_state_agg`의 새 `sub_areas` 컬럼은 `{P6-1,P6-2}` 형태로 area에 속한 sub_area 목록을 보여줍니다. LLM은 이걸로 sub_area 존재를 확인한 뒤, 상세 조회는 반드시 `v_plant_running_state_corroborated`를 사용하세요.
|
||||
|
||||
### 실질 운전 판정 — 교차검증 뷰 (정밀, 선택)
|
||||
|
||||
펌프 상태 워드(RUN)만으로는 deadhead·센서오류·수집 stall(frozen 데이터) 등 **허위 운전**을 못 거른다. 연결된 유량계(kg/hr)·진공압(torr)을 **신선도 게이트(120초)** 와 함께 교차검증한 뷰:
|
||||
|
||||
56
src/Core/Application/DTOs/TrendDtos.cs
Normal file
56
src/Core/Application/DTOs/TrendDtos.cs
Normal file
@@ -0,0 +1,56 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.DTOs;
|
||||
|
||||
// 이 프로젝트의 기본 JSON 직렬화는 PascalCase 패스스루다(기존 컨트롤러는 익명객체로 직접 camelCase 명명).
|
||||
// 트렌드 DTO는 프론트(camelCase) 계약에 맞추기 위해 [JsonPropertyName]으로 camelCase 고정.
|
||||
// (입력 바인딩은 MVC 기본 대소문자 무시라 무관, 출력 일관성 목적)
|
||||
|
||||
/// <summary>트렌드 그룹 멤버 — 태그 + 색상 + Y축(좌/우)</summary>
|
||||
public class TrendMemberDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("color")] public string Color { get; set; } = ""; // #rrggbb
|
||||
[JsonPropertyName("axis")] public string Axis { get; set; } = "left"; // left | right
|
||||
}
|
||||
|
||||
/// <summary>트렌드 그룹 (멤버는 JSONB 저장)</summary>
|
||||
public class TrendGroupDto
|
||||
{
|
||||
[JsonPropertyName("id")] public int Id { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
|
||||
[JsonPropertyName("createdAt")] public DateTime CreatedAt { get; set; }
|
||||
[JsonPropertyName("updatedAt")] public DateTime UpdatedAt { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>그룹 생성/수정 요청</summary>
|
||||
public class TrendGroupUpsertDto
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("members")] public List<TrendMemberDto> Members { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>아날로그 포인트 (그룹 빌더용 — 숫자 livevalue 한정)</summary>
|
||||
public class AnalogPointDto
|
||||
{
|
||||
[JsonPropertyName("tagName")] public string TagName { get; set; } = "";
|
||||
[JsonPropertyName("baseTag")] public string BaseTag { get; set; } = "";
|
||||
[JsonPropertyName("value")] public double? Value { get; set; }
|
||||
[JsonPropertyName("timestamp")] public DateTime Timestamp { get; set; }
|
||||
[JsonPropertyName("unit")] public string? Unit { get; set; }
|
||||
[JsonPropertyName("euLo")] public double? EuLo { get; set; }
|
||||
[JsonPropertyName("euHi")] public double? EuHi { get; set; }
|
||||
[JsonPropertyName("description")] public string? Description { get; set; }
|
||||
[JsonPropertyName("area")] public string? Area { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>실시간 tail 포인트 (현재값)</summary>
|
||||
public class TrendLivePointDto
|
||||
{
|
||||
[JsonPropertyName("tag")] public string Tag { get; set; } = "";
|
||||
[JsonPropertyName("value")] public double? Value { get; set; }
|
||||
[JsonPropertyName("ts")] public DateTime Ts { get; set; }
|
||||
}
|
||||
@@ -162,7 +162,7 @@ public interface IExperionDbService
|
||||
|
||||
/// <summary>이벤트 히스토리 조회</summary>
|
||||
Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
|
||||
string? tagName, string? area, string? section,
|
||||
string? tagName, string? area, string? subArea,
|
||||
string? eventType, DateTime from, DateTime to, int limit = 500);
|
||||
}
|
||||
|
||||
@@ -398,7 +398,7 @@ public class DigitalEventRecord
|
||||
public DateTime EventTime { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public string? Area { get; set; }
|
||||
public string? Section { get; set; }
|
||||
public string? SubArea { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
@@ -413,7 +413,7 @@ public class EventHistoryRow
|
||||
public string EventType { get; set; } = "";
|
||||
public DateTime EventTime { get; set; }
|
||||
public string? Area { get; set; }
|
||||
public string? Section { get; set; }
|
||||
public string? SubArea { get; set; }
|
||||
public int? DurationSeconds { get; set; }
|
||||
public string? Metadata { get; set; }
|
||||
}
|
||||
|
||||
15
src/Core/Application/Interfaces/ITrendService.cs
Normal file
15
src/Core/Application/Interfaces/ITrendService.cs
Normal file
@@ -0,0 +1,15 @@
|
||||
using ExperionCrawler.Core.Application.DTOs;
|
||||
|
||||
namespace ExperionCrawler.Core.Application.Interfaces;
|
||||
|
||||
/// <summary>트렌드 워크스페이스 — 아날로그 포인트 · 그룹 CRUD · 실시간 tail</summary>
|
||||
public interface ITrendService
|
||||
{
|
||||
Task<List<AnalogPointDto>> GetAnalogPointsAsync(string? q, CancellationToken ct);
|
||||
Task<List<TrendGroupDto>> GetGroupsAsync(CancellationToken ct);
|
||||
Task<TrendGroupDto?> GetGroupAsync(int id, CancellationToken ct);
|
||||
Task<TrendGroupDto> CreateGroupAsync(TrendGroupUpsertDto dto, CancellationToken ct);
|
||||
Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct);
|
||||
Task<bool> DeleteGroupAsync(int id, CancellationToken ct);
|
||||
Task<List<TrendLivePointDto>> GetLiveAsync(IReadOnlyList<string> tags, CancellationToken ct);
|
||||
}
|
||||
@@ -241,7 +241,7 @@ public class EventHistoryRecord
|
||||
[Column("event_type")] public string EventType { get; set; } = string.Empty;
|
||||
[Column("event_time")] public DateTime EventTime { get; set; } = DateTime.UtcNow;
|
||||
[Column("area")] public string? Area { get; set; }
|
||||
[Column("section")] public string? Section { get; set; }
|
||||
[Column("sub_area")] public string? SubArea { get; set; }
|
||||
[Column("duration_seconds")] public int? DurationSeconds { get; set; }
|
||||
[Column("metadata")] public string? Metadata { get; set; }
|
||||
[Column("created_at")] public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
@@ -486,7 +486,7 @@ public class ExperionDbService : IExperionDbService
|
||||
event_type TEXT NOT NULL,
|
||||
event_time TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
area TEXT,
|
||||
section TEXT,
|
||||
sub_area TEXT,
|
||||
duration_seconds INT,
|
||||
metadata JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
@@ -494,12 +494,13 @@ public class ExperionDbService : IExperionDbService
|
||||
""");
|
||||
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
DROP INDEX IF EXISTS idx_event_history_section_time;
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_time
|
||||
ON event_history_table(tagname, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_area_time
|
||||
ON event_history_table(area, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_section_time
|
||||
ON event_history_table(section, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_sub_area_time
|
||||
ON event_history_table(sub_area, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_event_type
|
||||
ON event_history_table(event_type, event_time DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_event_history_tagname_event_type
|
||||
@@ -860,7 +861,7 @@ public class ExperionDbService : IExperionDbService
|
||||
'kg/hr'::text AS unit,
|
||||
'topology'::text AS mapping_source
|
||||
FROM pid_equipment ft
|
||||
WHERE ft.category = '계기'
|
||||
WHERE ft.category = 'instrument'
|
||||
AND ft.tag_no LIKE 'FT-%'
|
||||
AND lower(ft.from_tag) LIKE 'p-%'
|
||||
AND ft.from_tag NOT LIKE '%,%'
|
||||
@@ -885,6 +886,40 @@ public class ExperionDbService : IExperionDbService
|
||||
GROUP BY base_tag
|
||||
""");
|
||||
|
||||
// ── 트렌드 워크스페이스 ────────────────────────────────────────────────
|
||||
// trend_group: 멤버는 JSONB([{tag,color,axis}]). 운전원 그룹 영속(공유).
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE TABLE IF NOT EXISTS trend_group (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT,
|
||||
members JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
)
|
||||
""");
|
||||
|
||||
// v_analog_points: 숫자 livevalue = 아날로그 (enum/디지털 '{n | LABEL | }' 자동 제외).
|
||||
// v_instrument_range(단위/레인지) + v_tag_summary(설명/구역) 조인으로 그룹빌더 표 정보 제공.
|
||||
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_analog_points");
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
CREATE VIEW v_analog_points AS
|
||||
SELECT rt.tagname,
|
||||
split_part(rt.tagname, '.', 1) AS base_tag,
|
||||
rt.livevalue::double precision AS value,
|
||||
rt.timestamp,
|
||||
ir.unit,
|
||||
ir.eu_lo,
|
||||
ir.eu_hi,
|
||||
ts.description,
|
||||
ts.area
|
||||
FROM realtime_table rt
|
||||
LEFT JOIN v_instrument_range ir ON ir.base_tag = split_part(rt.tagname, '.', 1)
|
||||
LEFT JOIN v_tag_summary ts ON ts.base_tag = split_part(rt.tagname, '.', 1)
|
||||
WHERE rt.livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||
ORDER BY rt.tagname
|
||||
""");
|
||||
|
||||
// 펌프별 교차검증 상세 — 신선도 게이트(120s) + STALE + 유량(FS 5%, fallback 0.5 kg/hr)·진공(300 torr)
|
||||
await _ctx.Database.ExecuteSqlRawAsync("DROP VIEW IF EXISTS v_plant_running_state_corroborated");
|
||||
await _ctx.Database.ExecuteSqlRawAsync("""
|
||||
@@ -981,7 +1016,8 @@ public class ExperionDbService : IExperionDbService
|
||||
END AS status,
|
||||
array_agg(base_tag) FILTER (WHERE corroborated_status='CONFIRMED_RUNNING') AS confirmed_tags,
|
||||
array_agg(base_tag) FILTER (WHERE corroborated_status='SUSPICIOUS_RUNNING') AS suspicious_tags,
|
||||
array_agg(base_tag) FILTER (WHERE corroborated_status='STALE') AS stale_tags
|
||||
array_agg(base_tag) FILTER (WHERE corroborated_status='STALE') AS stale_tags,
|
||||
array_agg(DISTINCT sub_area) FILTER (WHERE sub_area IS NOT NULL) AS sub_areas
|
||||
FROM v_plant_running_state_corroborated
|
||||
WHERE area_code IS NOT NULL AND area_code <> ''
|
||||
GROUP BY area_code ORDER BY area_code
|
||||
@@ -1601,11 +1637,13 @@ public class ExperionDbService : IExperionDbService
|
||||
var selectParts = new List<string>();
|
||||
selectParts.Add($"time_bucket('{intervalStr}', recorded_at) AS time_bucket");
|
||||
|
||||
// 태그명별로 동적으로 컬럼 생성 (PIVOT)
|
||||
// TimeScaleDB에서는 crosstab 함수를 사용하거나, 동적 SQL로 처리
|
||||
// 여기서는 간단하게 tagname GROUP BY로 조회 후 앱에서 PIVOT
|
||||
selectParts.Add("tagname");
|
||||
selectParts.Add("last(value, recorded_at) AS value");
|
||||
// 태그명별로 동적으로 PIVOT 컬럼 생성 (MAX + CASE)
|
||||
foreach (var tag in tags)
|
||||
{
|
||||
var safeTag = tag.Replace("'", "''");
|
||||
selectParts.Add(
|
||||
$"MAX(CASE WHEN tagname = '{safeTag}' THEN value END) AS \"{tag}\"");
|
||||
}
|
||||
|
||||
var sql = $"SELECT {string.Join(", ", selectParts)} FROM history_table WHERE 1=1";
|
||||
|
||||
@@ -1624,7 +1662,7 @@ public class ExperionDbService : IExperionDbService
|
||||
sql += $" AND recorded_at <= @toTime";
|
||||
}
|
||||
|
||||
sql += $" GROUP BY time_bucket, tagname ORDER BY time_bucket, tagname LIMIT {limit}";
|
||||
sql += $" GROUP BY time_bucket ORDER BY time_bucket LIMIT {limit}";
|
||||
|
||||
return sql;
|
||||
}
|
||||
@@ -2146,7 +2184,7 @@ public class ExperionDbService : IExperionDbService
|
||||
EventTime = record.EventTime,
|
||||
DurationSeconds = record.DurationSeconds,
|
||||
Area = record.Area,
|
||||
Section = record.Section,
|
||||
SubArea = record.SubArea,
|
||||
Metadata = record.Metadata
|
||||
};
|
||||
|
||||
@@ -2166,7 +2204,7 @@ public class ExperionDbService : IExperionDbService
|
||||
EventTime = r.EventTime,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
Area = r.Area,
|
||||
Section = r.Section,
|
||||
SubArea = r.SubArea,
|
||||
Metadata = r.Metadata
|
||||
}).ToList();
|
||||
|
||||
@@ -2175,7 +2213,7 @@ public class ExperionDbService : IExperionDbService
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<EventHistoryRow>> QueryEventHistoryAsync(
|
||||
string? tagName, string? area, string? section,
|
||||
string? tagName, string? area, string? subArea,
|
||||
string? eventType, DateTime from, DateTime to, int limit = 500)
|
||||
{
|
||||
var query = _ctx.EventHistoryRecords
|
||||
@@ -2185,8 +2223,11 @@ public class ExperionDbService : IExperionDbService
|
||||
query = query.Where(r => r.TagName == tagName);
|
||||
if (!string.IsNullOrEmpty(area))
|
||||
query = query.Where(r => r.Area == area);
|
||||
if (!string.IsNullOrEmpty(section))
|
||||
query = query.Where(r => r.Section == section);
|
||||
if (!string.IsNullOrEmpty(subArea))
|
||||
query = query.Where(r => r.SubArea == subArea
|
||||
|| r.SubArea!.StartsWith(subArea + ",")
|
||||
|| r.SubArea.EndsWith("," + subArea)
|
||||
|| r.SubArea.Contains("," + subArea + ","));
|
||||
if (!string.IsNullOrEmpty(eventType))
|
||||
query = query.Where(r => r.EventType == eventType);
|
||||
|
||||
@@ -2205,7 +2246,7 @@ public class ExperionDbService : IExperionDbService
|
||||
EventType = r.EventType,
|
||||
EventTime = r.EventTime,
|
||||
Area = r.Area,
|
||||
Section = r.Section,
|
||||
SubArea = r.SubArea,
|
||||
DurationSeconds = r.DurationSeconds,
|
||||
Metadata = r.Metadata
|
||||
});
|
||||
|
||||
@@ -5,7 +5,6 @@ using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.OpcUa;
|
||||
|
||||
@@ -19,6 +18,7 @@ public class DigitalEventDetectorService : BackgroundService
|
||||
private readonly ILogger<DigitalEventDetectorService> _logger;
|
||||
private readonly ConcurrentDictionary<string, DigitalPointState> _previousStates = new();
|
||||
private readonly ConcurrentDictionary<string, string> _areaCache = new();
|
||||
private readonly ConcurrentDictionary<string, string?> _subAreaCache = new();
|
||||
private readonly int _checkIntervalMs = 1000;
|
||||
private readonly int _debounceSeconds = 5;
|
||||
private HashSet<string> _knownDigitalTags = new();
|
||||
@@ -151,7 +151,7 @@ public class DigitalEventDetectorService : BackgroundService
|
||||
|
||||
var duration = (int)elapsed;
|
||||
var area = await GetAreaAsync(db, tagName);
|
||||
var section = ExtractSection(tagName);
|
||||
var subArea = await GetSubAreaAsync(db, tagName);
|
||||
|
||||
events.Add(new DigitalEventRecord
|
||||
{
|
||||
@@ -163,7 +163,7 @@ public class DigitalEventDetectorService : BackgroundService
|
||||
EventTime = now,
|
||||
DurationSeconds = duration,
|
||||
Area = area,
|
||||
Section = section,
|
||||
SubArea = subArea,
|
||||
Metadata = BuildMetadata(tagName, eventType, currValue)
|
||||
});
|
||||
|
||||
@@ -203,11 +203,13 @@ public class DigitalEventDetectorService : BackgroundService
|
||||
return area;
|
||||
}
|
||||
|
||||
private string? ExtractSection(string tagName)
|
||||
private async Task<string?> GetSubAreaAsync(IExperionDbService db, string tagName)
|
||||
{
|
||||
var match = Regex.Match(tagName, @"-(\d)(\d)\d{2}");
|
||||
if (match.Success) return $"{match.Groups[1]}-{match.Groups[2]}차";
|
||||
return null;
|
||||
if (_subAreaCache.TryGetValue(tagName, out var cached)) return cached;
|
||||
|
||||
var subArea = await db.GetSubAreaByTagNameAsync(tagName);
|
||||
_subAreaCache[tagName] = subArea;
|
||||
return subArea;
|
||||
}
|
||||
|
||||
private string? BuildMetadata(string tagName, string eventType, string currValue)
|
||||
|
||||
180
src/Infrastructure/Trend/TrendService.cs
Normal file
180
src/Infrastructure/Trend/TrendService.cs
Normal file
@@ -0,0 +1,180 @@
|
||||
using ExperionCrawler.Core.Application.DTOs;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using ExperionCrawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ExperionCrawler.Infrastructure.Trend;
|
||||
|
||||
/// <summary>
|
||||
/// 트렌드 워크스페이스 서비스 — v_analog_points 조회, trend_group CRUD(멤버 JSONB), realtime tail.
|
||||
/// 뷰/JSONB 매핑 편의를 위해 raw NpgsqlCommand 사용(프로젝트 QueryHistoryWithIntervalAsync 패턴 답습).
|
||||
/// </summary>
|
||||
public class TrendService : ITrendService
|
||||
{
|
||||
private readonly ExperionDbContext _ctx;
|
||||
private readonly ILogger<TrendService> _logger;
|
||||
private static readonly JsonSerializerOptions _json = new(JsonSerializerDefaults.Web);
|
||||
|
||||
public TrendService(ExperionDbContext ctx, ILogger<TrendService> logger)
|
||||
{
|
||||
_ctx = ctx;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
private NpgsqlConnection NewConn() => new(_ctx.Database.GetConnectionString());
|
||||
|
||||
public async Task<List<AnalogPointDto>> GetAnalogPointsAsync(string? q, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
SELECT tagname, base_tag, value, timestamp, unit, eu_lo, eu_hi, description, area
|
||||
FROM v_analog_points
|
||||
WHERE (@q = '' OR tagname ILIKE '%'||@q||'%' OR COALESCE(description,'') ILIKE '%'||@q||'%')
|
||||
ORDER BY tagname
|
||||
LIMIT 2000
|
||||
""";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("q", q ?? "");
|
||||
|
||||
var list = new List<AnalogPointDto>();
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r.ReadAsync(ct))
|
||||
{
|
||||
list.Add(new AnalogPointDto
|
||||
{
|
||||
TagName = r.GetString(0),
|
||||
BaseTag = r.IsDBNull(1) ? "" : r.GetString(1),
|
||||
Value = r.IsDBNull(2) ? null : r.GetDouble(2),
|
||||
Timestamp = r.GetDateTime(3),
|
||||
Unit = r.IsDBNull(4) ? null : r.GetString(4),
|
||||
EuLo = r.IsDBNull(5) ? null : r.GetDouble(5),
|
||||
EuHi = r.IsDBNull(6) ? null : r.GetDouble(6),
|
||||
Description = r.IsDBNull(7) ? null : r.GetString(7),
|
||||
Area = r.IsDBNull(8) ? null : r.GetString(8),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<List<TrendGroupDto>> GetGroupsAsync(CancellationToken ct)
|
||||
{
|
||||
const string sql = "SELECT id, name, description, members, created_at, updated_at FROM trend_group ORDER BY name";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
|
||||
var list = new List<TrendGroupDto>();
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r.ReadAsync(ct)) list.Add(ReadGroup(r));
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<TrendGroupDto?> GetGroupAsync(int id, CancellationToken ct)
|
||||
{
|
||||
const string sql = "SELECT id, name, description, members, created_at, updated_at FROM trend_group WHERE id = @id";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadGroup(r) : null;
|
||||
}
|
||||
|
||||
public async Task<TrendGroupDto> CreateGroupAsync(TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
INSERT INTO trend_group (name, description, members)
|
||||
VALUES (@name, @desc, @members::jsonb)
|
||||
RETURNING id, name, description, members, created_at, updated_at
|
||||
""";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("name", dto.Name);
|
||||
cmd.Parameters.AddWithValue("desc", (object?)dto.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("members", JsonSerializer.Serialize(dto.Members ?? new(), _json));
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
await r.ReadAsync(ct);
|
||||
return ReadGroup(r);
|
||||
}
|
||||
|
||||
public async Task<TrendGroupDto?> UpdateGroupAsync(int id, TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
const string sql = """
|
||||
UPDATE trend_group
|
||||
SET name = @name, description = @desc, members = @members::jsonb, updated_at = NOW()
|
||||
WHERE id = @id
|
||||
RETURNING id, name, description, members, created_at, updated_at
|
||||
""";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
cmd.Parameters.AddWithValue("name", dto.Name);
|
||||
cmd.Parameters.AddWithValue("desc", (object?)dto.Description ?? DBNull.Value);
|
||||
cmd.Parameters.AddWithValue("members", JsonSerializer.Serialize(dto.Members ?? new(), _json));
|
||||
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
return await r.ReadAsync(ct) ? ReadGroup(r) : null;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteGroupAsync(int id, CancellationToken ct)
|
||||
{
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand("DELETE FROM trend_group WHERE id = @id", conn);
|
||||
cmd.Parameters.AddWithValue("id", id);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
public async Task<List<TrendLivePointDto>> GetLiveAsync(IReadOnlyList<string> tags, CancellationToken ct)
|
||||
{
|
||||
if (tags.Count == 0) return new();
|
||||
const string sql = """
|
||||
SELECT tagname, livevalue::double precision AS value, timestamp
|
||||
FROM realtime_table
|
||||
WHERE tagname = ANY(@tags) AND livevalue ~ '^-?[0-9]+(\.[0-9]+)?$'
|
||||
""";
|
||||
await using var conn = NewConn();
|
||||
await conn.OpenAsync(ct);
|
||||
await using var cmd = new NpgsqlCommand(sql, conn);
|
||||
cmd.Parameters.AddWithValue("tags", tags.ToArray());
|
||||
|
||||
var list = new List<TrendLivePointDto>();
|
||||
await using var r = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await r.ReadAsync(ct))
|
||||
{
|
||||
list.Add(new TrendLivePointDto
|
||||
{
|
||||
Tag = r.GetString(0),
|
||||
Value = r.IsDBNull(1) ? null : r.GetDouble(1),
|
||||
Ts = r.GetDateTime(2),
|
||||
});
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private static TrendGroupDto ReadGroup(NpgsqlDataReader r)
|
||||
{
|
||||
var membersJson = r.IsDBNull(3) ? "[]" : r.GetFieldValue<string>(3);
|
||||
List<TrendMemberDto> members;
|
||||
try { members = JsonSerializer.Deserialize<List<TrendMemberDto>>(membersJson, _json) ?? new(); }
|
||||
catch { members = new(); }
|
||||
|
||||
return new TrendGroupDto
|
||||
{
|
||||
Id = r.GetInt32(0),
|
||||
Name = r.GetString(1),
|
||||
Description = r.IsDBNull(2) ? null : r.GetString(2),
|
||||
Members = members,
|
||||
CreatedAt = r.GetDateTime(4),
|
||||
UpdatedAt = r.GetDateTime(5),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1166,7 +1166,7 @@ public class EventHistoryController : ControllerBase
|
||||
public async Task<IActionResult> Query(
|
||||
[FromQuery] string? tagName,
|
||||
[FromQuery] string? area,
|
||||
[FromQuery] string? section,
|
||||
[FromQuery] string? subArea,
|
||||
[FromQuery] string? eventType,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to,
|
||||
@@ -1176,7 +1176,7 @@ public class EventHistoryController : ControllerBase
|
||||
{
|
||||
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
|
||||
var toDt = to ?? DateTime.UtcNow;
|
||||
var rows = await _db.QueryEventHistoryAsync(tagName, area, section, eventType, fromDt, toDt, limit);
|
||||
var rows = await _db.QueryEventHistoryAsync(tagName, area, subArea, eventType, fromDt, toDt, limit);
|
||||
var list = rows.Select(r => new
|
||||
{
|
||||
id = r.Id,
|
||||
@@ -1187,7 +1187,7 @@ public class EventHistoryController : ControllerBase
|
||||
eventType = r.EventType,
|
||||
eventTime = r.EventTime,
|
||||
area = r.Area,
|
||||
section = r.Section,
|
||||
subArea = r.SubArea,
|
||||
durationSeconds = r.DurationSeconds,
|
||||
metadata = r.Metadata
|
||||
}).ToList();
|
||||
@@ -1204,7 +1204,7 @@ public class EventHistoryController : ControllerBase
|
||||
[HttpGet("summary")]
|
||||
public async Task<IActionResult> GetSummary(
|
||||
[FromQuery] string? area,
|
||||
[FromQuery] string? section,
|
||||
[FromQuery] string? subArea,
|
||||
[FromQuery] DateTime? from,
|
||||
[FromQuery] DateTime? to)
|
||||
{
|
||||
@@ -1212,12 +1212,12 @@ public class EventHistoryController : ControllerBase
|
||||
{
|
||||
var fromDt = from ?? DateTime.UtcNow.AddDays(-1);
|
||||
var toDt = to ?? DateTime.UtcNow;
|
||||
var rows = await _db.QueryEventHistoryAsync(null, area, section, null, fromDt, toDt, 10000);
|
||||
var rows = await _db.QueryEventHistoryAsync(null, area, subArea, null, fromDt, toDt, 10000);
|
||||
|
||||
var summary = rows.GroupBy(r => r.Section ?? "기타")
|
||||
var summary = rows.GroupBy(r => r.SubArea ?? "기타")
|
||||
.Select(g => new
|
||||
{
|
||||
section = g.Key,
|
||||
subArea = g.Key,
|
||||
totalEvents = g.Count(),
|
||||
tripCount = g.Count(r => r.EventType == "TRIP"),
|
||||
runCount = g.Count(r => r.EventType == "RUN"),
|
||||
|
||||
73
src/Web/Controllers/TrendController.cs
Normal file
73
src/Web/Controllers/TrendController.cs
Normal file
@@ -0,0 +1,73 @@
|
||||
using ExperionCrawler.Core.Application.DTOs;
|
||||
using ExperionCrawler.Core.Application.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace ExperionCrawler.Web.Controllers;
|
||||
|
||||
/// <summary>트렌드 워크스페이스 — 아날로그 포인트 · 그룹 CRUD · 실시간 tail. 이력은 기존 /api/history 재사용.</summary>
|
||||
[ApiController]
|
||||
[Route("api/trend")]
|
||||
public class TrendController : ControllerBase
|
||||
{
|
||||
private readonly ITrendService _svc;
|
||||
private readonly ILogger<TrendController> _logger;
|
||||
|
||||
public TrendController(ITrendService svc, ILogger<TrendController> logger)
|
||||
{
|
||||
_svc = svc;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>그룹 빌더용 아날로그 포인트 목록 (숫자 livevalue 한정)</summary>
|
||||
[HttpGet("analog-points")]
|
||||
public async Task<IActionResult> AnalogPoints([FromQuery] string? q, CancellationToken ct)
|
||||
{
|
||||
try { return Ok(new { items = await _svc.GetAnalogPointsAsync(q ?? "", ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] analog-points 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("groups")]
|
||||
public async Task<IActionResult> Groups(CancellationToken ct)
|
||||
{
|
||||
try { return Ok(new { items = await _svc.GetGroupsAsync(ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] groups 조회 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpGet("groups/{id:int}")]
|
||||
public async Task<IActionResult> Group(int id, CancellationToken ct)
|
||||
{
|
||||
var g = await _svc.GetGroupAsync(id, ct);
|
||||
return g is null ? NotFound() : Ok(g);
|
||||
}
|
||||
|
||||
[HttpPost("groups")]
|
||||
public async Task<IActionResult> Create([FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
return BadRequest(new { error = "name 은 필수입니다." });
|
||||
try { return Ok(await _svc.CreateGroupAsync(dto, ct)); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] 그룹 생성 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
}
|
||||
|
||||
[HttpPut("groups/{id:int}")]
|
||||
public async Task<IActionResult> Update(int id, [FromBody] TrendGroupUpsertDto dto, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(dto.Name))
|
||||
return BadRequest(new { error = "name 은 필수입니다." });
|
||||
var g = await _svc.UpdateGroupAsync(id, dto, ct);
|
||||
return g is null ? NotFound() : Ok(g);
|
||||
}
|
||||
|
||||
[HttpDelete("groups/{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
=> await _svc.DeleteGroupAsync(id, ct) ? Ok(new { success = true }) : NotFound();
|
||||
|
||||
/// <summary>실시간 tail — 그룹 멤버 현재값(아날로그)</summary>
|
||||
[HttpGet("live")]
|
||||
public async Task<IActionResult> Live([FromQuery] string? tags, CancellationToken ct)
|
||||
{
|
||||
var list = (tags ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
try { return Ok(new { items = await _svc.GetLiveAsync(list, ct) }); }
|
||||
catch (Exception ex) { _logger.LogError(ex, "[Trend] live 실패"); return StatusCode(500, new { error = ex.Message }); }
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ using ExperionCrawler.Infrastructure.Database;
|
||||
using ExperionCrawler.Infrastructure.Kb;
|
||||
using ExperionCrawler.Infrastructure.Mcp;
|
||||
using ExperionCrawler.Infrastructure.OpcUa;
|
||||
using ExperionCrawler.Infrastructure.Trend;
|
||||
using ExperionCrawler.Web;
|
||||
using Microsoft.AspNetCore.Mvc.ApplicationParts;
|
||||
using Microsoft.AspNetCore.Mvc.Controllers;
|
||||
@@ -56,6 +57,7 @@ Directory.CreateDirectory("data");
|
||||
builder.Services.AddDbContext<ExperionDbContext>(opt =>
|
||||
opt.UseNpgsql(builder.Configuration.GetConnectionString("DefaultConnection")));
|
||||
builder.Services.AddScoped<IExperionDbService, ExperionDbService>();
|
||||
builder.Services.AddScoped<ITrendService, TrendService>();
|
||||
|
||||
// ── Application Services ──────────────────────────────────────────────────────
|
||||
builder.Services.AddScoped<ExperionCrawlService>();
|
||||
|
||||
83
src/Web/wwwroot/css/trend.css
Normal file
83
src/Web/wwwroot/css/trend.css
Normal file
@@ -0,0 +1,83 @@
|
||||
/* ─────────────────────────────────────────────────────────────
|
||||
17 트렌드 워크스페이스 (ECharts)
|
||||
───────────────────────────────────────────────────────────── */
|
||||
|
||||
/* 타임바 */
|
||||
.trend-timebar {
|
||||
display: flex; gap: 8px; align-items: center; flex-wrap: wrap;
|
||||
}
|
||||
.trend-timebar .tr-sep { width: 1px; height: 22px; background: var(--bd); margin: 0 2px; }
|
||||
.tr-quick { display: inline-flex; gap: 4px; }
|
||||
.tr-quick button,
|
||||
.tr-tog {
|
||||
background: var(--s3); border: 1px solid var(--bd); color: var(--t1);
|
||||
border-radius: var(--r); padding: 5px 9px; cursor: pointer; font-size: 12px;
|
||||
transition: all var(--tr);
|
||||
}
|
||||
.tr-quick button:hover, .tr-tog:hover { border-color: var(--bd2); color: var(--t0); }
|
||||
.tr-tog.on { background: var(--a); border-color: var(--a); color: #fff; }
|
||||
#tr-live-btn.live-on { background: var(--red); border-color: var(--red); color: #fff; }
|
||||
.tr-layers { display: inline-flex; gap: 4px; flex-wrap: wrap; }
|
||||
|
||||
.trend-timebar .dt-display { min-width: 150px; }
|
||||
.trend-timebar .inp { width: auto; }
|
||||
|
||||
.tr-groupbar { display: inline-flex; gap: 4px; align-items: center; margin-left: auto; }
|
||||
.tr-groupbar select { min-width: 150px; }
|
||||
|
||||
/* 메인 (차트 + Insights 슬롯) */
|
||||
.trend-main { display: flex; gap: 12px; align-items: stretch; }
|
||||
.trend-chartwrap { flex: 1; min-width: 0; }
|
||||
.trend-chart { width: 100%; height: 480px; }
|
||||
.trend-insights { width: 320px; flex-shrink: 0; }
|
||||
.trend-insights.hidden { display: none; }
|
||||
|
||||
/* 범례 / 통계 표 */
|
||||
.tr-legend, .tr-analog { width: 100%; border-collapse: collapse; font-size: 12px; }
|
||||
.tr-legend th, .tr-analog th {
|
||||
text-align: left; color: var(--t2); font-weight: 600; font-size: 11px;
|
||||
text-transform: uppercase; letter-spacing: .04em;
|
||||
padding: 6px 8px; border-bottom: 1px solid var(--bd); position: sticky; top: 0; background: var(--s2);
|
||||
}
|
||||
.tr-legend td, .tr-analog td { padding: 5px 8px; border-bottom: 1px solid var(--bd); color: var(--t0); }
|
||||
.tr-legend tbody tr { cursor: pointer; transition: background var(--tr); }
|
||||
.tr-legend tbody tr:hover, .tr-analog tbody tr:hover { background: var(--s3); }
|
||||
.tr-legend tbody tr.tr-sel { background: var(--ag); font-weight: 600; }
|
||||
.tr-analog tbody tr.tr-pick { background: rgba(16,185,129,.14); }
|
||||
.tr-tag { font-family: var(--fm); white-space: nowrap; }
|
||||
.tr-legend .val, .tr-analog .val { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--fm); }
|
||||
.tr-legend .mut { color: var(--t2); }
|
||||
.tr-legend input[type=color] { width: 28px; height: 20px; border: none; background: none; padding: 0; cursor: pointer; }
|
||||
.tr-legend select { padding: 2px 4px; font-size: 11px; }
|
||||
.tr-rm { background: none; border: none; color: var(--t2); cursor: pointer; font-size: 14px; line-height: 1; }
|
||||
.tr-rm:hover { color: var(--red); }
|
||||
|
||||
.tr-empty { padding: 28px; text-align: center; color: var(--t2); }
|
||||
|
||||
/* Insights 패널 */
|
||||
.tr-ins-hd { display: flex; justify-content: space-between; align-items: center; font-weight: 600; margin-bottom: 8px; }
|
||||
.tr-ins-body { font-size: 13px; color: var(--t1); line-height: 1.55; overflow-y: auto; max-height: 460px; }
|
||||
|
||||
/* 그룹 빌더 모달 */
|
||||
.tr-modal { position: fixed; inset: 0; z-index: 900; background: rgba(0,0,0,.55);
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
.tr-modal.hidden { display: none; }
|
||||
.tr-modal-box { background: var(--s2); border: 1px solid var(--bd2); border-radius: var(--rl);
|
||||
width: min(960px, 93vw); max-height: 86vh; display: flex; flex-direction: column; }
|
||||
.tr-modal-hd { display: flex; gap: 8px; align-items: center; padding: 16px; border-bottom: 1px solid var(--bd); }
|
||||
.tr-modal-hd .inp { flex: 1; }
|
||||
.tr-analog-wrap { overflow-y: auto; flex: 1; padding: 0 16px; }
|
||||
.tr-modal-ft { display: flex; gap: 8px; align-items: center; justify-content: flex-end; padding: 14px 16px; border-top: 1px solid var(--bd); }
|
||||
.tr-modal-ft #tr-builder-count { margin-right: auto; color: var(--t2); font-size: 12px; }
|
||||
|
||||
/* Y축 zoom 인디케이터 */
|
||||
.tr-yzoom-indicator {
|
||||
display: none; font-size: 11px; color: var(--a); font-weight: 600;
|
||||
padding: 3px 8px; border: 1px solid var(--a); border-radius: var(--r);
|
||||
vertical-align: middle; white-space: nowrap; cursor: pointer;
|
||||
}
|
||||
.tr-yzoom-indicator.active { display: inline-block; }
|
||||
.tr-yzoom-indicator:hover { background: var(--s3); }
|
||||
|
||||
/* 라이브 현재값 강조 */
|
||||
.tr-legend .tr-live-cur { color: var(--grn); font-weight: 700; }
|
||||
@@ -15,6 +15,7 @@
|
||||
<link rel="stylesheet" href="/css/kbadmin.css"/>
|
||||
<link rel="stylesheet" href="/lib/uPlot.min.css"/>
|
||||
<link rel="stylesheet" href="/css/docs.css"/>
|
||||
<link rel="stylesheet" href="/css/trend.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
@@ -105,6 +106,10 @@
|
||||
<span class="ni">16</span>
|
||||
<span class="nl">문서 탐색기</span>
|
||||
</li>
|
||||
<li class="nav-item" data-tab="trend">
|
||||
<span class="ni">17</span>
|
||||
<span class="nl">트렌드</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sb-foot">
|
||||
@@ -131,6 +136,7 @@
|
||||
<section class="pane" id="pane-kbadmin" data-src="/panes/kbadmin.html"></section>
|
||||
<section class="pane" id="pane-write" data-src="/panes/write.html"></section>
|
||||
<section class="pane" id="pane-docs" data-src="/panes/docs.html"></section>
|
||||
<section class="pane" id="pane-trend" data-src="/panes/trend.html"></section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -213,6 +219,7 @@
|
||||
</div>
|
||||
|
||||
<script src="/lib/uPlot.iife.min.js"></script>
|
||||
<script src="/lib/echarts.min.js"></script>
|
||||
<script src="/js/xlsx.full.min.js"></script>
|
||||
<script src="/js/core.js"></script>
|
||||
<script src="/js/cert.js"></script>
|
||||
@@ -231,5 +238,6 @@
|
||||
<script src="/js/kbadmin.js"></script>
|
||||
<script src="/js/write.js"></script>
|
||||
<script src="/js/docs.js"></script>
|
||||
<script src="/js/trend.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -64,7 +64,7 @@ async function activateTab(tab) {
|
||||
// lazy-load pane HTML if not loaded
|
||||
if (el.dataset.loaded !== '1' && el.dataset.src) {
|
||||
try {
|
||||
const res = await fetch(el.dataset.src);
|
||||
const res = await fetch(el.dataset.src, { cache: 'no-cache' });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
el.innerHTML = await res.text();
|
||||
el.dataset.loaded = '1';
|
||||
|
||||
@@ -21,7 +21,7 @@ async function evtQuery() {
|
||||
const tag = document.getElementById('ef-tag').value;
|
||||
const eventType = document.getElementById('ef-event-type').value;
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const subArea = document.getElementById('ef-subarea').value.trim();
|
||||
const limit = document.getElementById('ef-limit').value || 500;
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
@@ -30,7 +30,7 @@ async function evtQuery() {
|
||||
if (tag) params.set('tagName', tag);
|
||||
if (eventType) params.set('eventType', eventType);
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
if (subArea) params.set('subArea', subArea);
|
||||
params.set('limit', limit);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
@@ -54,13 +54,13 @@ async function evtQuery() {
|
||||
|
||||
async function evtSummary() {
|
||||
const area = document.getElementById('ef-area').value.trim();
|
||||
const section = document.getElementById('ef-section').value.trim();
|
||||
const subArea = document.getElementById('ef-subarea').value.trim();
|
||||
const fromRaw = document.getElementById('hf-evt-from').value;
|
||||
const toRaw = document.getElementById('hf-evt-to').value;
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (area) params.set('area', area);
|
||||
if (section) params.set('section', section);
|
||||
if (subArea) params.set('subArea', subArea);
|
||||
if (fromRaw) params.set('from', new Date(fromRaw).toISOString());
|
||||
if (toRaw) params.set('to', new Date(toRaw).toISOString());
|
||||
|
||||
@@ -102,7 +102,7 @@ function _evtBuildTable(rows) {
|
||||
<td style="color:var(--t2)">${esc(r.prevValue ?? '—')}</td>
|
||||
<td style="color:var(--t0);font-weight:600">${esc(r.currValue)}</td>
|
||||
<td>${r.area ? `<span class="nm-cls">${esc(r.area)}</span>` : '—'}</td>
|
||||
<td>${r.section ? `<span class="nm-cls">${esc(r.section)}</span>` : '—'}</td>
|
||||
<td>${r.subArea ? `<span class="nm-cls">${esc(r.subArea)}</span>` : '—'}</td>
|
||||
<td style="font-family:var(--fm);font-size:11px;color:var(--t2)">${r.durationSeconds != null ? r.durationSeconds + 's' : '—'}</td>
|
||||
</tr>`).join('');
|
||||
return `
|
||||
@@ -114,7 +114,7 @@ function _evtBuildTable(rows) {
|
||||
<th>이전값</th>
|
||||
<th>현재값</th>
|
||||
<th>Area</th>
|
||||
<th>Section</th>
|
||||
<th>Sub Area</th>
|
||||
<th>지속(초)</th>
|
||||
</tr></thead>
|
||||
<tbody>${html}</tbody>
|
||||
@@ -126,7 +126,7 @@ function _evtBuildSummary(data) {
|
||||
return '<div style="padding:12px;color:var(--t2)">데이터 없음</div>';
|
||||
return `<div class="evt-summary-grid">${data.map(s => `
|
||||
<div class="evt-summary-item">
|
||||
<div class="evt-summary-section">${esc(s.section)}</div>
|
||||
<div class="evt-summary-section">${esc(s.subArea)}</div>
|
||||
<div class="evt-summary-counts">
|
||||
<div class="evt-count">${_evtBadge('TRIP')} <strong>${s.tripCount}</strong></div>
|
||||
<div class="evt-count">${_evtBadge('RUN')} <strong>${s.runCount}</strong></div>
|
||||
@@ -141,7 +141,7 @@ function evtReset() {
|
||||
document.getElementById('ef-tag').value = '';
|
||||
document.getElementById('ef-event-type').value = '';
|
||||
document.getElementById('ef-area').value = '';
|
||||
document.getElementById('ef-section').value = '';
|
||||
document.getElementById('ef-subarea').value = '';
|
||||
document.getElementById('ef-limit').value = '500';
|
||||
dtClearField('evt-from');
|
||||
dtClearField('evt-to');
|
||||
|
||||
646
src/Web/wwwroot/js/trend.js
Normal file
646
src/Web/wwwroot/js/trend.js
Normal file
@@ -0,0 +1,646 @@
|
||||
/* ════════════════════════════════════════════════════════════════
|
||||
trend.js — 트렌드 워크스페이스 (Apache ECharts)
|
||||
- 단일 차트에 레이어를 쌓는 구조: trState + TR_LAYERS + trRender
|
||||
- P1: 멀티시리즈·dataZoom·범례표(색/강조/통계)·minmax·라이브·그룹
|
||||
- P1+: 트립/이벤트 오버레이(기존 /api/event-history 재사용)
|
||||
════════════════════════════════════════════════════════════════ */
|
||||
|
||||
let trChart = null;
|
||||
let trLiveTimer = null;
|
||||
let trEditingId = null;
|
||||
let trAnalogAll = [];
|
||||
let trAnalogMap = null; // tag → {desc,unit,euLo,euHi}
|
||||
let trBuilderSel = new Set();
|
||||
|
||||
const TR_PALETTE = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4',
|
||||
'#42d4f4','#f032e6','#bfef45','#469990','#fabed4'];
|
||||
|
||||
// ── 중앙 상태 — 모든 레이어가 읽는 단일 진실원 ─────────────────
|
||||
const trState = {
|
||||
group: null,
|
||||
members: [], // [{tag,color,axis,desc,unit,hidden}]
|
||||
seriesData: {}, // { tag: [[ms,val],...] }
|
||||
window: { t0:null, t1:null },
|
||||
selected: null,
|
||||
live: false,
|
||||
euAxis: false, // Y축: false=데이터 자동, true=메타 euLo/euHi
|
||||
yZoom: null, // { min, max } from Y zoom, null = auto
|
||||
normalize: false, // true=전체 태그를 EU 레인지 대비 %(0~100)로 환산
|
||||
liveNow: {}, // { tag: 현재값 } — 라이브 중 실시간 갱신
|
||||
layers: { minmax:false, events:false },
|
||||
cache: { events:[] }
|
||||
};
|
||||
|
||||
// ── 레이어 레지스트리 — P2/P3는 여기 1줄 + 함수만 추가 ────────────
|
||||
const TR_LAYERS = [
|
||||
{ id:'minmax', perSeries: layerMinMax, when: s => s.layers.minmax },
|
||||
{ id:'events', global: layerEvents, when: s => s.layers.events },
|
||||
];
|
||||
|
||||
/* ── 차트 초기화 ─────────────────────────────────────────────── */
|
||||
function trInitChart() {
|
||||
const el = document.getElementById('tr-chart');
|
||||
if (!el || typeof echarts === 'undefined') return;
|
||||
if (trChart) trChart.dispose();
|
||||
trChart = echarts.init(el, null, { renderer: 'canvas' });
|
||||
trChart.setOption({
|
||||
animation: false,
|
||||
grid: { left: 58, right: 58, top: 20, bottom: 96 },
|
||||
tooltip: { trigger: 'axis', axisPointer: { type: 'cross' },
|
||||
formatter: function (ps) { // 헤어라인 툴팁 — 중복(markPoint/markLine/__헬퍼) 제거
|
||||
if (!Array.isArray(ps)) ps = [ps];
|
||||
const seen = new Set(); let head = ''; const lines = [];
|
||||
for (const p of ps) {
|
||||
if (!p.seriesName || p.seriesName.indexOf('__') === 0) continue;
|
||||
const v = Array.isArray(p.value) ? p.value[1] : p.value;
|
||||
if (v == null) continue;
|
||||
if (seen.has(p.seriesName)) continue; seen.add(p.seriesName);
|
||||
if (!head && p.axisValueLabel) head = p.axisValueLabel;
|
||||
// 100% 모드면 [ms, pct, raw] → 원값(환산%) 둘 다 표시
|
||||
const raw = (Array.isArray(p.value) && p.value.length > 2) ? p.value[2] : null;
|
||||
const disp = (trState.normalize && raw != null)
|
||||
? `${(+raw).toFixed(2)} <span style="opacity:.6">(${(+v).toFixed(1)}%)</span>`
|
||||
: (+v).toFixed(2);
|
||||
lines.push(`${p.marker}${esc(p.seriesName)}: <b>${disp}</b>`);
|
||||
}
|
||||
return lines.length ? (head ? head + '<br>' : '') + lines.join('<br>') : '';
|
||||
} },
|
||||
legend: { show: false },
|
||||
xAxis: { type: 'time' },
|
||||
yAxis: [
|
||||
{ type: 'value', scale: true },
|
||||
{ type: 'value', scale: true, position: 'right' }
|
||||
],
|
||||
dataZoom: [
|
||||
{ type: 'inside', filterMode: 'none' },
|
||||
{ type: 'inside', yAxisIndex: [0, 1], filterMode: 'none',
|
||||
zoomOnMouseWheel: false, moveOnMouseMove: false },
|
||||
{ type: 'slider', filterMode: 'none', height: 26, bottom: 44 }
|
||||
],
|
||||
toolbox: { right: 12, feature: {
|
||||
saveAsImage: { title: 'PNG' },
|
||||
dataView: { readOnly: true, title: '표' },
|
||||
restore: { title: '복원' }
|
||||
}},
|
||||
series: []
|
||||
});
|
||||
trChart.on('dataZoom', trOnZoom);
|
||||
trChart.on('restore', () => {
|
||||
trState.yZoom = null;
|
||||
trRender();
|
||||
});
|
||||
window.addEventListener('resize', () => { if (trChart) trChart.resize(); });
|
||||
}
|
||||
|
||||
/* ── 레이어 함수 ─────────────────────────────────────────────── */
|
||||
function layerBaseSeries(m, s) {
|
||||
return {
|
||||
name: m.tag, type: 'line', showSymbol: false, connectNulls: false,
|
||||
yAxisIndex: s.normalize ? 0 : (m.axis === 'right' ? 1 : 0), // 100% 모드는 단일축
|
||||
lineStyle: { color: m.color, width: m.tag === s.selected ? 4 : 1.6, opacity: m.hidden ? 0 : 1 },
|
||||
itemStyle: { color: m.color },
|
||||
emphasis: { focus: 'series', lineStyle: { width: 4 } },
|
||||
data: m.hidden ? [] : trPlotData(m),
|
||||
markPoint: { silent: true, data: [] }
|
||||
};
|
||||
}
|
||||
|
||||
// 멤버 정규화 기준 [lo, span] — EU 레인지 우선, 없으면 데이터 min/max 폴백
|
||||
function trMemberSpan(m) {
|
||||
if (m.euLo != null && m.euHi != null && m.euHi > m.euLo) return [m.euLo, m.euHi - m.euLo];
|
||||
const raw = trState.seriesData[m.tag] || [];
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const pt of raw) { const v = pt[1]; if (v == null) continue; if (v < lo) lo = v; if (v > hi) hi = v; }
|
||||
return (isFinite(lo) && isFinite(hi) && hi > lo) ? [lo, hi - lo] : null;
|
||||
}
|
||||
|
||||
// 플롯용 데이터 — 100% 모드면 [ms, pct, raw], 아니면 원본 [ms, val]
|
||||
function trPlotData(m) {
|
||||
const raw = trState.seriesData[m.tag] || [];
|
||||
if (!trState.normalize) return raw;
|
||||
const sp = trMemberSpan(m);
|
||||
if (!sp) return raw;
|
||||
const [lo, span] = sp;
|
||||
return raw.map(pt => pt[1] == null ? [pt[0], null, null] : [pt[0], (pt[1] - lo) / span * 100, pt[1]]);
|
||||
}
|
||||
|
||||
function layerMinMax(m, s) { // 보이는 구간 최대/최소 핀
|
||||
if (m.hidden) return { markPoint: { data: [] } };
|
||||
const [t0, t1] = trVisibleWindow();
|
||||
const d = s.seriesData[m.tag] || []; // 원값으로 min/max 산출(라벨은 원값)
|
||||
let mn = Infinity, mx = -Infinity, mnt = null, mxt = null;
|
||||
for (const [ms, v] of d) {
|
||||
if (v == null || ms < t0 || ms > t1) continue;
|
||||
if (v > mx) { mx = v; mxt = ms; }
|
||||
if (v < mn) { mn = v; mnt = ms; }
|
||||
}
|
||||
if (mxt == null) return { markPoint: { silent: true, data: [] } };
|
||||
let toY = v => v; // 100% 모드는 핀 좌표도 %로 환산
|
||||
if (s.normalize) { const sp = trMemberSpan(m); if (sp) toY = v => (v - sp[0]) / sp[1] * 100; }
|
||||
const data = [
|
||||
{ coord: [mxt, toY(mx)], symbol: 'pin', symbolSize: 38, itemStyle: { color: m.color },
|
||||
label: { formatter: '▲' + mx.toFixed(1), fontSize: 10, color: '#fff' } },
|
||||
{ coord: [mnt, toY(mn)], symbol: 'pin', symbolRotate: 180, symbolSize: 38, itemStyle: { color: m.color },
|
||||
label: { formatter: '▼' + mn.toFixed(1), fontSize: 10, color: '#fff', offset: [0, 18] } }
|
||||
];
|
||||
return { markPoint: { silent: true, data } };
|
||||
}
|
||||
|
||||
function layerEvents(s) { // 트립/알람 세로 플래그 (전역 helper series)
|
||||
if (!s.cache.events.length) return null;
|
||||
const data = s.cache.events.map(e => ({
|
||||
xAxis: e.ts,
|
||||
lineStyle: { color: e.color, type: 'solid', width: 1.2 },
|
||||
label: { formatter: e.label, color: e.color, rotate: 90, fontSize: 9, position: 'insideEndTop' }
|
||||
}));
|
||||
return { name: '__events', type: 'line', data: [], silent: true, showSymbol: false,
|
||||
markLine: { symbol: 'none', silent: true, data } };
|
||||
}
|
||||
|
||||
function trMerge(a, b) {
|
||||
return { ...a, ...b,
|
||||
lineStyle: { ...(a.lineStyle || {}), ...(b.lineStyle || {}) },
|
||||
markPoint: b.markPoint || a.markPoint,
|
||||
markLine: b.markLine || a.markLine,
|
||||
markArea: b.markArea || a.markArea };
|
||||
}
|
||||
|
||||
/* ── 합성 렌더 — 켜진 레이어를 모아 한 번에 setOption ─────────── */
|
||||
function trRender() {
|
||||
if (!trChart) return;
|
||||
const series = trState.members.map(m => {
|
||||
let s = layerBaseSeries(m, trState);
|
||||
for (const L of TR_LAYERS) {
|
||||
if (L.when && !L.when(trState)) continue;
|
||||
if (L.perSeries) s = trMerge(s, L.perSeries(m, trState));
|
||||
}
|
||||
return s;
|
||||
});
|
||||
for (const L of TR_LAYERS) {
|
||||
if (L.when && !L.when(trState)) continue;
|
||||
if (L.global) { const g = L.global(trState); if (g) series.push(g); }
|
||||
}
|
||||
trChart.setOption({ series, yAxis: trYAxis() }, { replaceMerge: ['series'] });
|
||||
trRenderLegend();
|
||||
}
|
||||
|
||||
/* ── Y축 — normalize → yZoom → EU 모드 → auto 순 우선 ──────────── */
|
||||
function trYAxis() {
|
||||
// 100% 환산: 단일 0~100 스케일(우축 숨김). yZoom 있으면 그 %범위 사용.
|
||||
if (trState.normalize) {
|
||||
const span = trState.yZoom || { min: 0, max: 100 };
|
||||
return [
|
||||
{ type:'value', scale:false, show:true, axisLabel:{ formatter:'{value}%' }, ...span },
|
||||
{ type:'value', scale:false, show:false, position:'right', axisLabel:{ formatter:'{value}%' }, ...span }
|
||||
];
|
||||
}
|
||||
// 비-환산: show/min/max/axisLabel을 명시 리셋해 모드 전환 시 잔상 방지
|
||||
const base = pos => ({ type:'value', scale:true, show:true, min:null, max:null,
|
||||
axisLabel:{ formatter:'{value}' }, ...(pos ? { position: pos } : {}) });
|
||||
if (trState.yZoom) return [{ ...base(), ...trState.yZoom }, { ...base('right'), ...trState.yZoom }];
|
||||
if (!trState.euAxis) return [base(), base('right')];
|
||||
const rng = right => {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const m of trState.members) {
|
||||
if (m.hidden || (m.axis === 'right') !== right) continue;
|
||||
if (m.euLo != null && m.euLo < lo) lo = m.euLo;
|
||||
if (m.euHi != null && m.euHi > hi) hi = m.euHi;
|
||||
}
|
||||
return (isFinite(lo) && isFinite(hi) && hi > lo) ? { min: lo, max: hi } : { min: null, max: null };
|
||||
};
|
||||
return [{ ...base(), ...rng(false) }, { ...base('right'), ...rng(true) }];
|
||||
}
|
||||
function trToggleEuAxis() {
|
||||
trState.euAxis = !trState.euAxis;
|
||||
const b = document.getElementById('tr-euaxis-btn');
|
||||
if (b) b.classList.toggle('on', trState.euAxis);
|
||||
trRender();
|
||||
}
|
||||
function trToggleNormalize() {
|
||||
trState.normalize = !trState.normalize;
|
||||
trState.yZoom = null; // 단위가 바뀌므로 Y줌 리셋
|
||||
if (typeof trResetYZoom === 'function') { // Y줌 인디케이터도 숨김(있으면)
|
||||
const ind = document.getElementById('tr-yzoom-indicator');
|
||||
if (ind) ind.classList.remove('active');
|
||||
}
|
||||
const b = document.getElementById('tr-norm-btn');
|
||||
if (b) b.classList.toggle('on', trState.normalize);
|
||||
trRender();
|
||||
}
|
||||
|
||||
// 비-null 연속점 사이 간격이 과도하면 null 삽입해 선을 끊음(희소/집계 데이터 가짜 연결 방지)
|
||||
function trBreakGaps(arr) {
|
||||
const dx = [];
|
||||
for (let i = 1; i < arr.length; i++)
|
||||
if (arr[i][1] != null && arr[i-1][1] != null) dx.push(arr[i][0] - arr[i-1][0]);
|
||||
if (dx.length < 3) return arr;
|
||||
dx.sort((a, b) => a - b);
|
||||
const lim = dx[Math.floor(dx.length / 2)] * 3;
|
||||
const out = [];
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (i > 0 && arr[i][1] != null && arr[i-1][1] != null && (arr[i][0] - arr[i-1][0]) > lim)
|
||||
out.push([arr[i-1][0] + 1, null]);
|
||||
out.push(arr[i]);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 데이터 조회 ─────────────────────────────────────────────── */
|
||||
async function trQuery() {
|
||||
trStopLive();
|
||||
if (!trChart) trInitChart();
|
||||
const tags = trState.members.map(m => m.tag);
|
||||
if (!tags.length) { trState.seriesData = {}; trRender(); return; }
|
||||
|
||||
const from = document.getElementById('hf-trfrom').value;
|
||||
const to = document.getElementById('hf-trto').value;
|
||||
|
||||
let rows = [], tk = 'recordedAt';
|
||||
try {
|
||||
const p = new URLSearchParams();
|
||||
tags.forEach(t => p.append('tagNames', t));
|
||||
if (from) p.set('from', new Date(from).toISOString());
|
||||
if (to) p.set('to', new Date(to).toISOString());
|
||||
p.set('limit', '5000');
|
||||
const d = await api('GET', `/api/history/query?${p}`);
|
||||
rows = d.rows || []; tk = 'recordedAt';
|
||||
} catch (e) {
|
||||
console.error('trQuery:', e); setGlobal('err', '트렌드 조회 실패');
|
||||
alert('조회 실패: ' + e.message); return;
|
||||
}
|
||||
|
||||
trState.seriesData = {};
|
||||
for (const m of trState.members) trState.seriesData[m.tag] = [];
|
||||
for (const r of rows) {
|
||||
const ms = new Date(r[tk]).getTime();
|
||||
if (!isFinite(ms)) continue; // 잘못된 타임스탬프 스킵(축 왜곡 방지)
|
||||
for (const m of trState.members) {
|
||||
const v = r.values?.[m.tag];
|
||||
trState.seriesData[m.tag].push([ms, (v == null || v === '') ? null : +v]);
|
||||
}
|
||||
}
|
||||
for (const m of trState.members)
|
||||
trState.seriesData[m.tag] = trBreakGaps(trState.seriesData[m.tag]);
|
||||
|
||||
if (trState.layers.events) await trLoadEvents();
|
||||
trState.yZoom = null;
|
||||
trChart.setOption({ dataZoom: [
|
||||
{ start: 0, end: 100, startValue: null, endValue: null },
|
||||
{ start: 0, end: 100, startValue: null, endValue: null },
|
||||
{ start: 0, end: 100, startValue: null, endValue: null }
|
||||
]});
|
||||
trRender();
|
||||
setGlobal('ok', `${rows.length}건`);
|
||||
}
|
||||
|
||||
/* ── 퀵 레인지 / 날짜 ─────────────────────────────────────────── */
|
||||
function trSetDt(target, date) {
|
||||
const p = n => String(n).padStart(2, '0');
|
||||
const v = `${date.getFullYear()}-${p(date.getMonth()+1)}-${p(date.getDate())}T${p(date.getHours())}:${p(date.getMinutes())}`;
|
||||
document.getElementById('hf-' + target).value = v;
|
||||
document.getElementById('dtp-' + target + '-display').textContent = v.replace('T', ' ');
|
||||
}
|
||||
|
||||
function trShiftStart(now) { // 교대 06/14/22
|
||||
const h = now.getHours();
|
||||
let s = h >= 22 ? 22 : h >= 14 ? 14 : h >= 6 ? 6 : 22;
|
||||
const d = new Date(now); d.setMinutes(0, 0, 0);
|
||||
if (s === 22 && h < 6) d.setDate(d.getDate() - 1);
|
||||
d.setHours(s);
|
||||
return d;
|
||||
}
|
||||
|
||||
function trQuickRange(r) {
|
||||
const now = new Date(); let from = new Date(now);
|
||||
if (r === '1h') from.setHours(now.getHours() - 1);
|
||||
else if (r === '8h') from.setHours(now.getHours() - 8);
|
||||
else if (r === '24h') from.setDate(now.getDate() - 1);
|
||||
else if (r === '7d') from.setDate(now.getDate() - 7);
|
||||
else if (r === 'shift') from = trShiftStart(now);
|
||||
trSetDt('trfrom', from); trSetDt('trto', now);
|
||||
trQuery();
|
||||
}
|
||||
|
||||
/* ── 보이는 윈도우 / 통계 ─────────────────────────────────────── */
|
||||
function trVisibleWindow() {
|
||||
let lo = Infinity, hi = -Infinity;
|
||||
for (const k in trState.seriesData)
|
||||
for (const pt of trState.seriesData[k]) { if (pt[0] < lo) lo = pt[0]; if (pt[0] > hi) hi = pt[0]; }
|
||||
if (!isFinite(lo)) return [0, 0];
|
||||
if (!trChart) return [lo, hi];
|
||||
const dz = (trChart.getOption().dataZoom || [])[0] || {};
|
||||
if (dz.startValue != null && dz.endValue != null) return [dz.startValue, dz.endValue];
|
||||
const s = (dz.start ?? 0) / 100, e = (dz.end ?? 100) / 100;
|
||||
return [lo + (hi - lo) * s, lo + (hi - lo) * e];
|
||||
}
|
||||
|
||||
function trStats(d, t0, t1) {
|
||||
if (!d) return {};
|
||||
let mn = Infinity, mx = -Infinity, sum = 0, n = 0, last = null;
|
||||
for (const [ms, v] of d) {
|
||||
if (v == null || ms < t0 || ms > t1) continue;
|
||||
if (v < mn) mn = v; if (v > mx) mx = v; sum += v; n++; last = v;
|
||||
}
|
||||
if (!n) return {};
|
||||
const avg = sum / n; let sq = 0;
|
||||
for (const [ms, v] of d) { if (v == null || ms < t0 || ms > t1) continue; sq += (v - avg) ** 2; }
|
||||
return { min: mn.toFixed(2), max: mx.toFixed(2), avg: avg.toFixed(2),
|
||||
sd: Math.sqrt(sq / n).toFixed(2), last: last.toFixed(2) };
|
||||
}
|
||||
|
||||
/* ── 범례 / 통계 표 ──────────────────────────────────────────── */
|
||||
function trRenderLegend() {
|
||||
const tb = document.getElementById('tr-legend-body');
|
||||
if (!tb) return;
|
||||
if (!trState.members.length) {
|
||||
tb.innerHTML = '<tr><td colspan="12" class="tr-empty">그룹을 불러오거나 +그룹으로 멤버를 추가하세요.</td></tr>';
|
||||
return;
|
||||
}
|
||||
const [t0, t1] = trVisibleWindow();
|
||||
tb.innerHTML = trState.members.map(m => {
|
||||
const st = trStats(trState.seriesData[m.tag], t0, t1);
|
||||
// 현재: 라이브 중이면 실시간 현재값, 아니면 보이는 구간의 마지막 표본
|
||||
const live = (trState.live && trState.liveNow[m.tag] != null) ? trState.liveNow[m.tag] : null;
|
||||
const cur = live != null ? live.toFixed(2) : (st.last ?? '—');
|
||||
return `<tr class="${m.tag === trState.selected ? 'tr-sel' : ''}" onclick="trHighlight('${esc(m.tag)}')">
|
||||
<td><input type="color" value="${m.color}" onclick="event.stopPropagation()" onchange="trSetColor('${esc(m.tag)}',this.value)"></td>
|
||||
<td class="tr-tag">${esc(m.tag)}</td>
|
||||
<td>${esc(m.desc || '')}</td>
|
||||
<td class="val${live != null ? ' tr-live-cur' : ''}">${cur}</td>
|
||||
<td class="val">${st.min ?? '—'}</td>
|
||||
<td class="val">${st.max ?? '—'}</td>
|
||||
<td class="val">${st.avg ?? '—'}</td>
|
||||
<td class="val mut">${st.sd ?? '—'}</td>
|
||||
<td>${esc(m.unit || '')}</td>
|
||||
<td><select onclick="event.stopPropagation()" onchange="trSetAxis('${esc(m.tag)}',this.value)">
|
||||
<option value="left"${m.axis !== 'right' ? ' selected' : ''}>좌</option>
|
||||
<option value="right"${m.axis === 'right' ? ' selected' : ''}>우</option></select></td>
|
||||
<td><input type="checkbox" ${m.hidden ? '' : 'checked'} onclick="event.stopPropagation();trToggleVis('${esc(m.tag)}',this.checked)"></td>
|
||||
<td><button class="tr-rm" onclick="event.stopPropagation();trRemoveMember('${esc(m.tag)}')">×</button></td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function trHighlight(tag) {
|
||||
trState.selected = (trState.selected === tag) ? null : tag;
|
||||
trRender();
|
||||
trChart.dispatchAction({ type: 'downplay' });
|
||||
if (trState.selected) trChart.dispatchAction({ type: 'highlight', seriesName: trState.selected });
|
||||
}
|
||||
function trSetColor(tag, c) { const m = trState.members.find(x => x.tag === tag); if (!m) return; m.color = c; trRender(); trPersist(); }
|
||||
function trSetAxis(tag, a) { const m = trState.members.find(x => x.tag === tag); if (!m) return; m.axis = a; trRender(); trPersist(); }
|
||||
function trToggleVis(tag, vis) { const m = trState.members.find(x => x.tag === tag); if (!m) return; m.hidden = !vis; trRender(); }
|
||||
function trRemoveMember(tag) { trState.members = trState.members.filter(m => m.tag !== tag); delete trState.seriesData[tag]; trRender(); trPersist(); }
|
||||
|
||||
function trOnZoom() {
|
||||
const [t0, t1] = trVisibleWindow();
|
||||
trState.window = { t0, t1 };
|
||||
trRenderLegend();
|
||||
if (trState.layers.minmax) trRender();
|
||||
}
|
||||
|
||||
/* ── Shift+Y축 zoom 모드 ────────────────────────────────────── */
|
||||
let trShiftDown = false;
|
||||
|
||||
function trSetYZoomMode(active) {
|
||||
if (!trChart) return;
|
||||
const dz = (trChart.getOption().dataZoom || [{},{},{}]).map(d => ({ ...d }));
|
||||
dz[0].zoomOnMouseWheel = !active;
|
||||
dz[0].moveOnMouseMove = !active;
|
||||
dz[1].zoomOnMouseWheel = active;
|
||||
dz[1].moveOnMouseMove = active;
|
||||
trChart.setOption({ dataZoom: dz }, { replaceMerge: ['dataZoom'] });
|
||||
document.getElementById('tr-yzoom-indicator')?.classList.toggle('active', active);
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Shift' && !trShiftDown) {
|
||||
trShiftDown = true;
|
||||
trSetYZoomMode(true);
|
||||
}
|
||||
});
|
||||
document.addEventListener('keyup', e => {
|
||||
if (e.key === 'Shift' && trShiftDown) {
|
||||
trShiftDown = false;
|
||||
trSetYZoomMode(false);
|
||||
}
|
||||
});
|
||||
|
||||
/* ── Y축 zoom 원위치 ─────────────────────────────────────────── */
|
||||
function trResetYZoom() {
|
||||
if (!trChart) return;
|
||||
const dz = (trChart.getOption().dataZoom || [{},{},{}]).map(d => ({ ...d }));
|
||||
dz[1].start = 0;
|
||||
dz[1].end = 100;
|
||||
dz[1].startValue = null;
|
||||
dz[1].endValue = null;
|
||||
trChart.setOption({ dataZoom: dz }, { replaceMerge: ['dataZoom'] });
|
||||
}
|
||||
|
||||
/* ── 레이어 토글 ─────────────────────────────────────────────── */
|
||||
async function trToggleLayer(id) {
|
||||
trState.layers[id] = !trState.layers[id];
|
||||
const btn = document.querySelector(`.tr-tog[data-layer="${id}"]`);
|
||||
if (btn) btn.classList.toggle('on', trState.layers[id]);
|
||||
if (id === 'events' && trState.layers.events) await trLoadEvents();
|
||||
trRender();
|
||||
}
|
||||
|
||||
async function trLoadEvents() {
|
||||
const from = document.getElementById('hf-trfrom').value;
|
||||
const to = document.getElementById('hf-trto').value;
|
||||
const p = new URLSearchParams();
|
||||
if (from) p.set('from', new Date(from).toISOString());
|
||||
if (to) p.set('to', new Date(to).toISOString());
|
||||
p.set('limit', '1000');
|
||||
const COL = { TRIP:'#ef4444', ALARM:'#f59e0b', RUN:'#10b981', NORMAL:'#64748b', CHANGE:'#60a5fa' };
|
||||
try {
|
||||
const d = await api('GET', `/api/event-history?${p}`);
|
||||
trState.cache.events = (d.data || [])
|
||||
.filter(r => r.eventType === 'TRIP' || r.eventType === 'ALARM')
|
||||
.map(r => ({ ts: new Date(r.eventTime).getTime(), type: r.eventType,
|
||||
color: COL[r.eventType] || '#888',
|
||||
label: `${(r.tagName || '').toUpperCase()} ${r.eventType}` }));
|
||||
} catch (e) { console.error('trLoadEvents:', e); trState.cache.events = []; }
|
||||
}
|
||||
|
||||
/* ── 실시간 ──────────────────────────────────────────────────── */
|
||||
function trToggleLive() {
|
||||
if (trLiveTimer) { trStopLive(); return; }
|
||||
if (!trState.members.length) { alert('먼저 그룹을 불러오세요.'); return; }
|
||||
const b = document.getElementById('tr-live-btn');
|
||||
b.textContent = '⏸ 정지'; b.classList.add('live-on');
|
||||
trState.live = true;
|
||||
trLiveTimer = setInterval(trLiveTick, 5000);
|
||||
trLiveTick();
|
||||
}
|
||||
function trStopLive() {
|
||||
if (trLiveTimer) { clearInterval(trLiveTimer); trLiveTimer = null; }
|
||||
trState.live = false;
|
||||
const b = document.getElementById('tr-live-btn');
|
||||
if (b) { b.textContent = '▶ 라이브'; b.classList.remove('live-on'); }
|
||||
}
|
||||
async function trLiveTick() {
|
||||
const tags = trState.members.map(m => m.tag);
|
||||
if (!tags.length) return;
|
||||
try {
|
||||
const d = await api('GET', `/api/trend/live?tags=${encodeURIComponent(tags.join(','))}`);
|
||||
let changed = false;
|
||||
for (const pt of (d.items || [])) {
|
||||
if (pt.value == null) continue;
|
||||
trState.liveNow[pt.tag] = +pt.value; // 현재값은 매 틱 갱신(타임스탬프 동일해도)
|
||||
const arr = trState.seriesData[pt.tag]; if (!arr) continue;
|
||||
const ms = new Date(pt.ts).getTime();
|
||||
if (!isFinite(ms)) continue;
|
||||
if (!arr.length || arr[arr.length - 1][0] !== ms) { arr.push([ms, +pt.value]); changed = true; }
|
||||
}
|
||||
trRenderLegend(); // 현재값은 항상 먼저 갱신 (차트 렌더와 분리 — 렌더 에러가 표를 막지 않게)
|
||||
if (changed) { try { trRender(); } catch (e) { console.error('trLiveTick render:', e); } }
|
||||
} catch (e) { console.error('trLiveTick:', e); }
|
||||
}
|
||||
|
||||
/* ── Insights (P3 자리) ──────────────────────────────────────── */
|
||||
function trToggleInsights() { document.getElementById('tr-insights').classList.toggle('hidden'); }
|
||||
function trAnalyze() { document.getElementById('tr-ins-body').textContent = 'P3 단계에서 LLM 분석이 연결됩니다.'; }
|
||||
|
||||
/* ── 그룹 빌더 (아날로그 클릭 선택) ──────────────────────────── */
|
||||
async function trEnsureAnalogMap() {
|
||||
if (trAnalogMap) return trAnalogMap;
|
||||
const d = await api('GET', '/api/trend/analog-points');
|
||||
trAnalogAll = d.items || [];
|
||||
trAnalogMap = {};
|
||||
for (const p of trAnalogAll) trAnalogMap[p.tagName] = { desc: p.description, unit: p.unit, euLo: p.euLo, euHi: p.euHi };
|
||||
return trAnalogMap;
|
||||
}
|
||||
|
||||
async function trOpenBuilder() {
|
||||
trEditingId = null;
|
||||
document.getElementById('tr-builder-name').value = '';
|
||||
document.getElementById('tr-builder-search').value = '';
|
||||
await trOpenBuilderWith(new Set(trState.members.map(m => m.tag)));
|
||||
}
|
||||
async function trOpenBuilderWith(preSel) {
|
||||
document.getElementById('tr-builder').classList.remove('hidden');
|
||||
trBuilderSel = preSel;
|
||||
await trEnsureAnalogMap();
|
||||
trRenderAnalog();
|
||||
}
|
||||
function trCloseBuilder() { document.getElementById('tr-builder').classList.add('hidden'); }
|
||||
|
||||
function trRenderAnalog() {
|
||||
const q = (document.getElementById('tr-builder-search').value || '').toLowerCase();
|
||||
const body = document.getElementById('tr-analog-body');
|
||||
body.innerHTML = trAnalogAll
|
||||
.filter(p => !q || p.tagName.toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
|
||||
.map(p => `<tr class="${trBuilderSel.has(p.tagName) ? 'tr-pick' : ''}" onclick="trTogglePick('${esc(p.tagName)}')">
|
||||
<td>${trBuilderSel.has(p.tagName) ? '✔' : ''}</td>
|
||||
<td class="tr-tag">${esc(p.tagName)}</td>
|
||||
<td>${esc(p.description || '')}</td>
|
||||
<td>${esc(parseEnumPv(p.area) || '')}</td>
|
||||
<td class="val">${p.value ?? '—'}</td>
|
||||
<td>${esc(p.unit || '')}</td>
|
||||
<td class="val">${p.euLo ?? '—'}</td>
|
||||
<td class="val">${p.euHi ?? '—'}</td>
|
||||
</tr>`).join('');
|
||||
document.getElementById('tr-builder-count').textContent = `${trBuilderSel.size}개 선택`;
|
||||
}
|
||||
function trTogglePick(tag) {
|
||||
if (trBuilderSel.has(tag)) trBuilderSel.delete(tag); else trBuilderSel.add(tag);
|
||||
trRenderAnalog();
|
||||
}
|
||||
|
||||
async function trSaveGroup() {
|
||||
const name = document.getElementById('tr-builder-name').value.trim();
|
||||
if (!name) { alert('그룹 이름을 입력하세요.'); return; }
|
||||
if (!trBuilderSel.size) { alert('태그를 최소 1개 선택하세요.'); return; }
|
||||
const members = [...trBuilderSel].map((t, i) => {
|
||||
const ex = trState.members.find(m => m.tag === t);
|
||||
return { tag: t, color: ex?.color || TR_PALETTE[i % TR_PALETTE.length], axis: ex?.axis || 'left' };
|
||||
});
|
||||
const body = { name, members };
|
||||
try {
|
||||
const saved = trEditingId
|
||||
? await api('PUT', `/api/trend/groups/${trEditingId}`, body)
|
||||
: await api('POST', '/api/trend/groups', body);
|
||||
trCloseBuilder();
|
||||
await trLoadGroupList();
|
||||
document.getElementById('tr-group-select').value = saved.id;
|
||||
await trApplyGroup(saved);
|
||||
} catch (e) { console.error('trSaveGroup:', e); alert('저장 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
/* ── 그룹 목록 / 로드 / 삭제 / 편집 ──────────────────────────── */
|
||||
async function trLoadGroupList() {
|
||||
try {
|
||||
const d = await api('GET', '/api/trend/groups');
|
||||
const sel = document.getElementById('tr-group-select');
|
||||
const cur = sel.value;
|
||||
sel.innerHTML = '<option value="">— 그룹 선택 —</option>' +
|
||||
(d.items || []).map(g => `<option value="${g.id}">${esc(g.name)} (${(g.members || []).length})</option>`).join('');
|
||||
if (cur) sel.value = cur;
|
||||
} catch (e) { console.error('trLoadGroupList:', e); }
|
||||
}
|
||||
|
||||
async function trApplyGroup(g) {
|
||||
trState.group = g;
|
||||
await trEnsureAnalogMap();
|
||||
trState.members = (g.members || []).map((m, i) => ({
|
||||
tag: m.tag,
|
||||
color: m.color || TR_PALETTE[i % TR_PALETTE.length],
|
||||
axis: m.axis || 'left',
|
||||
desc: trAnalogMap[m.tag]?.desc || '',
|
||||
unit: trAnalogMap[m.tag]?.unit || '',
|
||||
euLo: trAnalogMap[m.tag]?.euLo,
|
||||
euHi: trAnalogMap[m.tag]?.euHi,
|
||||
hidden: false
|
||||
}));
|
||||
await trQuery();
|
||||
}
|
||||
|
||||
async function trLoadGroup() {
|
||||
const id = document.getElementById('tr-group-select').value;
|
||||
if (!id) { alert('그룹을 선택하세요.'); return; }
|
||||
try { await trApplyGroup(await api('GET', `/api/trend/groups/${id}`)); }
|
||||
catch (e) { console.error('trLoadGroup:', e); alert('불러오기 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
async function trDeleteGroup() {
|
||||
const id = document.getElementById('tr-group-select').value;
|
||||
if (!id) { alert('삭제할 그룹을 선택하세요.'); return; }
|
||||
if (!confirm('이 그룹을 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await api('DELETE', `/api/trend/groups/${id}`);
|
||||
trState.group = null;
|
||||
await trLoadGroupList();
|
||||
document.getElementById('tr-group-select').value = '';
|
||||
} catch (e) { console.error('trDeleteGroup:', e); alert('삭제 실패: ' + e.message); }
|
||||
}
|
||||
|
||||
async function trEditGroup() {
|
||||
const id = document.getElementById('tr-group-select').value;
|
||||
if (!id) { alert('편집할 그룹을 선택하세요.'); return; }
|
||||
try {
|
||||
const g = await api('GET', `/api/trend/groups/${id}`);
|
||||
trEditingId = g.id;
|
||||
document.getElementById('tr-builder-name').value = g.name;
|
||||
document.getElementById('tr-builder-search').value = '';
|
||||
await trOpenBuilderWith(new Set((g.members || []).map(m => m.tag)));
|
||||
} catch (e) { console.error('trEditGroup:', e); }
|
||||
}
|
||||
|
||||
async function trPersist() { // 색상/축 변경을 현재 그룹에 저장
|
||||
if (!trState.group) return;
|
||||
try {
|
||||
await api('PUT', `/api/trend/groups/${trState.group.id}`, {
|
||||
name: trState.group.name,
|
||||
description: trState.group.description,
|
||||
members: trState.members.map(m => ({ tag: m.tag, color: m.color, axis: m.axis }))
|
||||
});
|
||||
} catch (e) { console.error('trPersist:', e); }
|
||||
}
|
||||
|
||||
/* ── 탭 진입 ─────────────────────────────────────────────────── */
|
||||
paneInit.trend = function () {
|
||||
if (!trChart) trInitChart(); else trChart.resize();
|
||||
trLoadGroupList();
|
||||
};
|
||||
45
src/Web/wwwroot/lib/echarts.min.js
vendored
Normal file
45
src/Web/wwwroot/lib/echarts.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -38,8 +38,8 @@
|
||||
<input id="ef-area" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>Section <em>(예: 1-2차)</em></label>
|
||||
<input id="ef-section" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
<label>Sub Area <em>(예: P6-1)</em></label>
|
||||
<input id="ef-subarea" class="inp" type="text" placeholder="비워두면 전체"/>
|
||||
</div>
|
||||
<div class="fg">
|
||||
<label>최대 행 수</label>
|
||||
|
||||
105
src/Web/wwwroot/panes/trend.html
Normal file
105
src/Web/wwwroot/panes/trend.html
Normal file
@@ -0,0 +1,105 @@
|
||||
<header class="pane-hdr">
|
||||
<div>
|
||||
<h1>트렌드 워크스페이스</h1>
|
||||
<p>히스토리·실시간·분석을 한 화면에서. 그룹은 아날로그 포인트로 구성합니다.</p>
|
||||
</div>
|
||||
<div class="pane-tag">TREND / WORKSPACE</div>
|
||||
</header>
|
||||
|
||||
<!-- 타임바 -->
|
||||
<div class="card">
|
||||
<div class="trend-timebar">
|
||||
<div class="tr-quick">
|
||||
<button onclick="trQuickRange('1h')">1h</button>
|
||||
<button onclick="trQuickRange('8h')">8h</button>
|
||||
<button onclick="trQuickRange('24h')">24h</button>
|
||||
<button onclick="trQuickRange('7d')">7d</button>
|
||||
<button onclick="trQuickRange('shift')">교대</button>
|
||||
</div>
|
||||
<span class="tr-sep"></span>
|
||||
<input type="hidden" id="hf-trfrom"/>
|
||||
<div class="dt-display inp" id="dtp-trfrom-display" onclick="dtOpen('trfrom')">— 시작 —</div>
|
||||
<div class="dt-display inp" id="dtp-trto-display" onclick="dtOpen('trto')">— 종료 —</div>
|
||||
<input type="hidden" id="hf-trto"/>
|
||||
<select id="tr-interval" class="inp">
|
||||
<option value="auto">자동 집계</option>
|
||||
<option value="1 minute">원시</option>
|
||||
<option value="5 minutes">5분</option>
|
||||
<option value="10 minutes">10분</option>
|
||||
<option value="30 minutes">30분</option>
|
||||
<option value="1 hour">1시간</option>
|
||||
</select>
|
||||
<button class="btn-a btn-sm" onclick="trQuery()">🔍 조회</button>
|
||||
<button class="btn-b btn-sm" id="tr-live-btn" onclick="trToggleLive()">▶ 라이브</button>
|
||||
<span class="tr-sep"></span>
|
||||
<!-- 레이어 토글(슬롯): P1=minmax, P2/P3는 여기에 버튼만 추가 -->
|
||||
<span class="tr-layers">
|
||||
<button class="tr-tog" data-layer="minmax" onclick="trToggleLayer('minmax')">↕ 최대/최소</button>
|
||||
<button class="tr-tog" data-layer="events" onclick="trToggleLayer('events')">⚠ 트립/이벤트</button>
|
||||
<button class="tr-tog" id="tr-norm-btn" onclick="trToggleNormalize()" title="전체 태그를 EU 레인지 대비 %(0~100)로 환산 — 단위/범위 다른 태그 비교">% 100%환산</button>
|
||||
<span class="tr-yzoom-indicator" id="tr-yzoom-indicator" onclick="trResetYZoom()" title="Y축 원위치">Y축 zoom ↩</span>
|
||||
</span>
|
||||
<!-- 그룹 바 -->
|
||||
<span class="tr-groupbar">
|
||||
<select id="tr-group-select" class="inp"><option value="">— 그룹 선택 —</option></select>
|
||||
<button class="btn-b btn-sm" onclick="trLoadGroup()">불러오기</button>
|
||||
<button class="btn-a btn-sm" onclick="trOpenBuilder()">+ 그룹</button>
|
||||
<button class="btn-b btn-sm" onclick="trEditGroup()">편집</button>
|
||||
<button class="btn-b btn-sm" onclick="trDeleteGroup()" style="color:var(--red)">삭제</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 + Insights 슬롯 -->
|
||||
<div class="trend-main">
|
||||
<div class="card trend-chartwrap">
|
||||
<div id="tr-chart" class="trend-chart"></div>
|
||||
</div>
|
||||
<aside id="tr-insights" class="card trend-insights hidden">
|
||||
<div class="tr-ins-hd">🤖 분석 <button class="btn-a btn-sm" onclick="trAnalyze()">이 구간 설명</button></div>
|
||||
<div id="tr-ins-body" class="tr-ins-body">P3에서 활성화됩니다.</div>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<!-- 멤버 범례 / 보이는 구간 통계 표 -->
|
||||
<div class="card" style="margin-top:12px">
|
||||
<div class="card-cap">멤버 (행 클릭 = 강조, 색상 클릭 = 변경)</div>
|
||||
<table class="tr-legend">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>색</th><th>태그</th><th>설명</th>
|
||||
<th class="val">현재</th><th class="val">최소</th><th class="val">최대</th>
|
||||
<th class="val">평균</th><th class="val">σ</th><th>단위</th><th>축</th><th>표시</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tr-legend-body">
|
||||
<tr><td colspan="12" class="tr-empty">그룹을 불러오거나 +그룹으로 멤버를 추가하세요.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- 그룹 빌더 모달 (아날로그 클릭 선택) -->
|
||||
<div id="tr-builder" class="tr-modal hidden">
|
||||
<div class="tr-modal-box">
|
||||
<div class="tr-modal-hd">
|
||||
<input id="tr-builder-name" class="inp" placeholder="그룹 이름"/>
|
||||
<input id="tr-builder-search" class="inp" placeholder="태그 / 설명 검색" oninput="trRenderAnalog()"/>
|
||||
</div>
|
||||
<div class="tr-analog-wrap">
|
||||
<table class="tr-analog">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th><th>태그</th><th>설명</th><th>구역</th>
|
||||
<th class="val">현재값</th><th>단위</th><th class="val">EU Lo</th><th class="val">EU Hi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tr-analog-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tr-modal-ft">
|
||||
<span id="tr-builder-count">0개 선택</span>
|
||||
<button class="btn-a btn-sm" onclick="trSaveGroup()">저장</button>
|
||||
<button class="btn-b btn-sm" onclick="trCloseBuilder()">취소</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user