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:
windpacer
2026-05-25 17:42:54 +09:00
parent 930fac2b4f
commit c7e2250bd3
20 changed files with 1354 additions and 51 deletions

View File

@@ -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
]

View File

@@ -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초)** 와 함께 교차검증한 뷰:

View 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; }
}

View File

@@ -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; }
}

View 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);
}

View File

@@ -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;

View File

@@ -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
});

View File

@@ -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)

View 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),
};
}
}

View File

@@ -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"),

View 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 }); }
}
}

View File

@@ -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>();

View 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; }

View File

@@ -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>

View File

@@ -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';

View File

@@ -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
View 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

File diff suppressed because one or more lines are too long

View File

@@ -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>

View 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>