Files
bio_frontend/pages/[tabId]/test/culture-graph.vue

1649 lines
42 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div>
<PageDescription>
<h1>배양 그래프</h1>
<div class="box">
<h2>1. 그래프 구성 기능</h2>
<ul>
<li>기본 형태의 <strong>배양 그래프</strong>입니다.</li>
<li>
<code>Real Data</code> 영역의 <strong>체크박스</strong> 통해
시리즈(데이터 라인) 출력 여부를 선택할 있습니다.
</li>
<li>
<strong>Y축에는 최대 2개의 Real Data 시리즈</strong> 동시에
출력됩니다.
</li>
<li><strong>가로 스크롤</strong> 시간축을 이동할 있습니다.</li>
<li>
그래프 내에서 <strong>마우스 우클릭 컨텍스트 메뉴</strong>
나타납니다.
</li>
<li>
그래프 내에서 <strong>마우스 액션으로 확대/축소</strong>
됩니다.
</li>
<li>
그래프 우측 상단에서 <strong>확대/축소 메뉴</strong> 있습니다.
</li>
<li>
<strong>세로선(Line Marker)</strong> 통해 특정 시점의
<strong>피드 정보</strong> 확인할 있습니다.
</li>
</ul>
</div>
<div class="box">
<h2>2. 출력되는 데이터 개수</h2>
<h3>Interval 1 (100시간, 시리즈 30)</h3>
<ul>
<li>
시리즈 30 × 6,000 = <span class="highlight">180,000</span>
</li>
</ul>
<h3>Interval 5 (100시간, 시리즈 30)</h3>
<ul>
<li>
시리즈 30 × 72,000 =
<span class="highlight">2,160,000</span>
</li>
</ul>
</div>
</PageDescription>
<div
class="experiment-graph-page"
@contextmenu="onContextMenu"
@click="onClickAnywhere"
>
<div class="info-bar">
<div class="info-row">
<span class="info-label">Experiment :</span>
<span class="info-value">Sample001</span>
<span class="info-divider">|</span>
<span class="info-label">Strain :</span>
<span class="info-value">균주명(Test)</span>
<span class="info-divider">|</span>
<span class="info-label">Time :</span>
<span class="info-value"
>2025.07.01 - 00:00 ~ 2025.07.03 - 00:00</span
>
</div>
</div>
<div class="realdata-checkbox-bar">
<span class="realdata-label">Real Data</span>
<span style="font-size: 12px; color: #666; margin-left: 10px">
({{ checkedList.length }}/{{ yAxisList.length }})
</span>
<label
v-for="col in yAxisList"
:key="col.name"
class="realdata-checkbox-item"
>
<input
:checked="checkedList.includes(col.name)"
type="checkbox"
:value="col.name"
@change="handleCheckboxChange(col.name, $event)"
/>
<span
:style="{
color: col.color,
fontWeight: checkedList.includes(col.name) ? 'bold' : 'normal',
}"
>{{ col.name }}</span
>
</label>
</div>
<!-- interval 입력 라디오 버튼으로 변경 -->
<div
class="interval-bar"
style="
padding: 8px 32px 8px 32px;
background: #fff;
border-bottom: 1px solid #e0e0e0;
display: flex;
align-items: center;
gap: 10px;
"
>
<span style="font-weight: bold; color: #444">Interval</span>
<label style="margin-right: 10px">
<input v-model.number="intervalValue" type="radio" :value="5" /> 5
</label>
<label>
<input v-model.number="intervalValue" type="radio" :value="60" /> 1
</label>
</div>
<div class="main-graph-area">
<div class="echarts-container">
<div class="zoom-controls">
<button class="zoom-btn" title="확대" @click="zoomIn">
<span>+</span>
</button>
<button class="zoom-btn" title="축소" @click="zoomOut">
<span></span>
</button>
<button class="zoom-btn reset" title="초기화" @click="resetZoom">
<span></span>
</button>
</div>
<div ref="growthChartRef" class="echarts-graph"></div>
<div class="yaxis-scroll-bar-overlay">
<input
v-model.number="yAxisScrollIndex"
type="range"
min="0"
:max="Math.max(0, filteredYAxisList.length - 2)"
:disabled="filteredYAxisList.length <= 2"
/>
</div>
</div>
</div>
<CustomContextMenu :visible="menuVisible" :x="menuX" :y="menuY" />
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
import * as echarts from "echarts";
import CustomContextMenu from "~/components/domain/cultureGraph/CustomContextMenu.vue";
// 타입 인터페이스 정의
interface YAxis {
name: string;
unit: string;
color: string;
min: number;
max: number;
ticks: number[];
fontWeight: string;
}
interface RealTimeData {
[key: string]: [number, number][];
}
const growthChartRef = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
const pastelColors = [
"#A3D8F4", // 파스텔 블루
"#F7B7A3", // 파스텔 오렌지
"#B5EAD7", // 파스텔 민트
"#FFDAC1", // 파스텔 살구
"#C7CEEA", // 파스텔 퍼플
"#FFF1BA", // 파스텔 옐로우
"#FFB7B2", // 파스텔 핑크
"#B4A7D6", // 파스텔 연보라
"#AED9E0", // 추가 파스텔 블루
"#FFC3A0", // 추가 파스텔 오렌지
"#E2F0CB", // 추가 파스텔 민트
"#FFB347", // 추가 파스텔 살구
"#C1C8E4", // 추가 파스텔 퍼플
"#FFFACD", // 추가 파스텔 옐로우
"#FFD1DC", // 추가 파스텔 핑크
];
const yAxisList: YAxis[] = [
{
name: "ORP",
unit: "",
color: pastelColors[0],
min: 0,
max: 1000,
ticks: [1000, 800, 600, 400, 200, 0],
fontWeight: "bold",
},
{
name: "Air flow",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 30,
ticks: [30, 25, 20, 15, 10, 5, 0],
fontWeight: "bold",
},
{
name: "DO",
unit: "",
color: pastelColors[2],
min: 0,
max: 200,
ticks: [200, 150, 100, 50, 0],
fontWeight: "bold",
},
{
name: "Feed TK1",
unit: "(L)",
color: pastelColors[3],
min: 0,
max: 10,
ticks: [10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "Feed TK2",
unit: "(L)",
color: pastelColors[4],
min: 0,
max: 10,
ticks: [10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "pH",
unit: "",
color: pastelColors[5],
min: 6.0,
max: 8.0,
ticks: [8.0, 7.5, 7.0, 6.5, 6.0],
fontWeight: "bold",
},
{
name: "Pressure",
unit: "(bar)",
color: pastelColors[6],
min: 0,
max: 2,
ticks: [2, 1.5, 1, 0.5, 0],
fontWeight: "bold",
},
{
name: "RPM",
unit: "",
color: pastelColors[7],
min: 0,
max: 3000,
ticks: [3000, 2400, 1800, 1200, 600, 0],
fontWeight: "bold",
},
{
name: "CO2",
unit: "(%)",
color: pastelColors[8],
min: 0,
max: 10,
ticks: [10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "JAR Vol",
unit: "(L)",
color: pastelColors[9],
min: 0,
max: 20,
ticks: [20, 16, 12, 8, 4, 0],
fontWeight: "bold",
},
{
name: "WEIGHT",
unit: "(kg)",
color: pastelColors[10],
min: 0,
max: 100,
ticks: [100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
{
name: "O2",
unit: "(%)",
color: pastelColors[11],
min: 0,
max: 100,
ticks: [100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
{
name: "NV",
unit: "",
color: pastelColors[12],
min: 0,
max: 10,
ticks: [10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "NIR",
unit: "",
color: pastelColors[13],
min: 0,
max: 10,
ticks: [10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "Temperature",
unit: "(℃)",
color: pastelColors[14],
min: 0,
max: 50,
ticks: [50, 40, 30, 20, 10, 0],
fontWeight: "bold",
},
{
name: "Humidity",
unit: "(%)",
color: pastelColors[0],
min: 0,
max: 100,
ticks: [100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
{
name: "Flow Rate",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 60,
ticks: [60, 50, 40, 30, 20, 10, 0],
fontWeight: "bold",
},
{
name: "Conductivity",
unit: "(μS/cm)",
color: pastelColors[2],
min: 0,
max: 600,
ticks: [600, 500, 400, 300, 200, 100, 0],
fontWeight: "bold",
},
{
name: "Turbidity",
unit: "(NTU)",
color: pastelColors[3],
min: 0,
max: 12,
ticks: [12, 10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "Dissolved Solids",
unit: "(mg/L)",
color: pastelColors[4],
min: 0,
max: 250,
ticks: [250, 200, 150, 100, 50, 0],
fontWeight: "bold",
},
{
name: "Alkalinity",
unit: "(mg/L)",
color: pastelColors[5],
min: 0,
max: 120,
ticks: [120, 100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
{
name: "Hardness",
unit: "(mg/L)",
color: pastelColors[6],
min: 0,
max: 100,
ticks: [100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
{
name: "Chlorine",
unit: "(mg/L)",
color: pastelColors[7],
min: 0,
max: 6,
ticks: [6, 5, 4, 3, 2, 1, 0],
fontWeight: "bold",
},
{
name: "Nitrate",
unit: "(mg/L)",
color: pastelColors[8],
min: 0,
max: 25,
ticks: [25, 20, 15, 10, 5, 0],
fontWeight: "bold",
},
{
name: "Phosphate",
unit: "(mg/L)",
color: pastelColors[9],
min: 0,
max: 12,
ticks: [12, 10, 8, 6, 4, 2, 0],
fontWeight: "bold",
},
{
name: "Sulfate",
unit: "(mg/L)",
color: pastelColors[10],
min: 0,
max: 60,
ticks: [60, 50, 40, 30, 20, 10, 0],
fontWeight: "bold",
},
{
name: "Ammonia",
unit: "(mg/L)",
color: pastelColors[11],
min: 0,
max: 18,
ticks: [18, 15, 12, 9, 6, 3, 0],
fontWeight: "bold",
},
{
name: "Nitrite",
unit: "(mg/L)",
color: pastelColors[12],
min: 0,
max: 6,
ticks: [6, 5, 4, 3, 2, 1, 0],
fontWeight: "bold",
},
{
name: "BOD",
unit: "(mg/L)",
color: pastelColors[13],
min: 0,
max: 35,
ticks: [35, 30, 25, 20, 15, 10, 5, 0],
fontWeight: "bold",
},
{
name: "COD",
unit: "(mg/L)",
color: pastelColors[14],
min: 0,
max: 120,
ticks: [120, 100, 80, 60, 40, 20, 0],
fontWeight: "bold",
},
];
// intervalValue를 라디오 버튼으로 선택 (5초, 1분)
const intervalValue = ref(60); // 기본값 1분
const totalSeconds = 100 * 60 * 60; // 100시간 = 360000초
const pointsPerLine = computed(
() => Math.floor(totalSeconds / intervalValue.value) + 1
);
const xLabels = computed(() =>
Array.from(
{ length: pointsPerLine.value },
(_, i) => i * intervalValue.value // 초 단위
)
);
// 전체 데이터에서 4개의 마커 포인트를 선택
const globalMarkerPoints: Array<{ seriesIndex: number; timeIndex: number }> =
[];
while (globalMarkerPoints.length < 4) {
const seriesIndex = Math.floor(Math.random() * 30); // 0~29 시리즈
const timeIndex = Math.floor(Math.random() * 100); // 0~99 시간
// 중복 체크
const exists = globalMarkerPoints.some(
p => p.seriesIndex === seriesIndex && p.timeIndex === timeIndex
);
if (!exists) {
globalMarkerPoints.push({ seriesIndex, timeIndex });
}
}
// 부드러운 곡선형 + 구간별 변화 가데이터 생성 함수 (xLabels를 파라미터로 받도록 변경)
function smoothData(
min: number,
max: number,
xLabels: number[],
phase = 0,
_amp = 1, // amp는 사용하지 않으므로 _amp로 변경
offset = 0,
seriesIndex: number
) {
let _prevValue = 0.5;
const values = [];
// 데이터 범위 축소: min+10% ~ max-10%
const rangeMin = min + (max - min) * 0.1;
const rangeMax = max - (max - min) * 0.1;
// 시리즈별 패턴 다양화: 증가/감소/진동/트렌드 섞기
// 패턴 결정 (시리즈 인덱스에 따라)
const trendType = seriesIndex % 4; // 0:증가, 1:감소, 2:진동, 3:랜덤
// 진동폭(ampVar, randomFactor)은 원복 (더 낮게)
const ampVar = 0.2 + (seriesIndex % 5) * 0.1; // 0.2~0.6
const randomFactor = 0.01 + 0.01 * (seriesIndex % 4); // 0.01~0.04
for (let i = 0; i < xLabels.length; i++) {
const t = i / (xLabels.length - 1);
let base;
// 오르내림(파동 주기/계수)을 키움 (파동이 더 자주, 더 크게)
if (trendType === 0) {
base = 0.2 + 0.6 * t + 0.13 * Math.sin(phase + t * Math.PI * ampVar * 8);
} else if (trendType === 1) {
base = 0.8 - 0.6 * t + 0.13 * Math.cos(phase + t * Math.PI * ampVar * 8);
} else if (trendType === 2) {
base = 0.5 + 0.22 * Math.sin(phase + t * Math.PI * ampVar * 12);
} else {
base = 0.5 + 0.08 * Math.sin(phase + t * Math.PI * ampVar * 6) + 0.2 * t;
}
// 노이즈는 낮게
const noise = (Math.random() - 0.5) * randomFactor;
base += noise;
base = Math.max(0, Math.min(1, base));
const value = +(rangeMin + (rangeMax - rangeMin) * base + offset).toFixed(
2
);
_prevValue = base;
values.push([xLabels[i], value]);
}
console.log(values);
return values;
}
// seriesList를 intervalValue, xLabels에 따라 동적으로 생성
const seriesList = computed(() => [
{
name: "ORP",
color: pastelColors[0],
yAxisIndex: 0,
data: smoothData(400, 900, xLabels.value, 0, 1, 0, 0),
},
{
name: "Air flow",
color: pastelColors[1],
yAxisIndex: 1,
data: smoothData(5, 25, xLabels.value, Math.PI / 2, 1, 0, 1),
},
{
name: "DO",
color: pastelColors[2],
yAxisIndex: 2,
data: smoothData(80, 180, xLabels.value, Math.PI / 3, 1, 0, 2),
},
{
name: "Feed TK1",
color: pastelColors[3],
yAxisIndex: 3,
data: smoothData(2, 8, xLabels.value, Math.PI / 4, 1, 0, 3),
},
{
name: "Feed TK2",
color: pastelColors[4],
yAxisIndex: 4,
data: smoothData(2, 8, xLabels.value, Math.PI / 5, 1, 0, 4),
},
{
name: "pH",
color: pastelColors[5],
yAxisIndex: 5,
data: smoothData(6.7, 7.6, xLabels.value, Math.PI / 6, 1, 0, 5),
},
{
name: "Pressure",
color: pastelColors[6],
yAxisIndex: 6,
data: smoothData(0.5, 1.5, xLabels.value, Math.PI / 7, 1, 0, 6),
},
{
name: "RPM",
color: pastelColors[7],
yAxisIndex: 7,
data: smoothData(1000, 2500, xLabels.value, Math.PI / 8, 1, 0, 7),
},
{
name: "CO2",
color: pastelColors[8],
yAxisIndex: 8,
data: smoothData(2, 8, xLabels.value, Math.PI / 9, 1, 0, 8),
},
{
name: "JAR Vol",
color: pastelColors[9],
yAxisIndex: 9,
data: smoothData(5, 18, xLabels.value, Math.PI / 10, 1, 0, 9),
},
{
name: "WEIGHT",
color: pastelColors[10],
yAxisIndex: 10,
data: smoothData(20, 90, xLabels.value, Math.PI / 11, 1, 0, 10),
},
{
name: "O2",
color: pastelColors[11],
yAxisIndex: 11,
data: smoothData(20, 90, xLabels.value, Math.PI / 12, 1, 0, 11),
},
{
name: "NV",
color: pastelColors[12],
yAxisIndex: 12,
data: smoothData(2, 8, xLabels.value, Math.PI / 13, 1, 0, 12),
},
{
name: "NIR",
color: pastelColors[13],
yAxisIndex: 13,
data: smoothData(2, 8, xLabels.value, Math.PI / 14, 1, 0, 13),
},
{
name: "Temperature",
color: pastelColors[14],
yAxisIndex: 14,
data: smoothData(30, 38, xLabels.value, Math.PI / 15, 1, 0, 14),
},
{
name: "Humidity",
color: pastelColors[0],
yAxisIndex: 15,
data: smoothData(40, 80, xLabels.value, Math.PI / 16, 1, 0, 15),
},
{
name: "Flow Rate",
color: pastelColors[1],
yAxisIndex: 16,
data: smoothData(10, 50, xLabels.value, Math.PI / 17, 1, 0, 16),
},
{
name: "Conductivity",
color: pastelColors[2],
yAxisIndex: 17,
data: smoothData(100, 500, xLabels.value, Math.PI / 18, 1, 0, 17),
},
{
name: "Turbidity",
color: pastelColors[3],
yAxisIndex: 18,
data: smoothData(0, 10, xLabels.value, Math.PI / 19, 1, 0, 18),
},
{
name: "Dissolved Solids",
color: pastelColors[4],
yAxisIndex: 19,
data: smoothData(50, 200, xLabels.value, Math.PI / 20, 1, 0, 19),
},
{
name: "Alkalinity",
color: pastelColors[5],
yAxisIndex: 20,
data: smoothData(20, 100, xLabels.value, Math.PI / 21, 1, 0, 20),
},
{
name: "Hardness",
color: pastelColors[6],
yAxisIndex: 21,
data: smoothData(10, 80, xLabels.value, Math.PI / 22, 1, 0, 21),
},
{
name: "Chlorine",
color: pastelColors[7],
yAxisIndex: 22,
data: smoothData(0, 5, xLabels.value, Math.PI / 23, 1, 0, 22),
},
{
name: "Nitrate",
color: pastelColors[8],
yAxisIndex: 23,
data: smoothData(0, 20, xLabels.value, Math.PI / 24, 1, 0, 23),
},
{
name: "Phosphate",
color: pastelColors[9],
yAxisIndex: 24,
data: smoothData(0, 10, xLabels.value, Math.PI / 25, 1, 0, 24),
},
{
name: "Sulfate",
color: pastelColors[10],
yAxisIndex: 25,
data: smoothData(0, 50, xLabels.value, Math.PI / 26, 1, 0, 25),
},
{
name: "Ammonia",
color: pastelColors[11],
yAxisIndex: 26,
data: smoothData(0, 15, xLabels.value, Math.PI / 27, 1, 0, 26),
},
{
name: "Nitrite",
color: pastelColors[12],
yAxisIndex: 27,
data: smoothData(0, 5, xLabels.value, Math.PI / 28, 1, 0, 27),
},
{
name: "BOD",
color: pastelColors[13],
yAxisIndex: 28,
data: smoothData(0, 30, xLabels.value, Math.PI / 29, 1, 0, 28),
},
{
name: "COD",
color: pastelColors[14],
yAxisIndex: 29,
data: smoothData(0, 100, xLabels.value, Math.PI / 30, 1, 0, 29),
},
]);
const checkedList = ref(yAxisList.map(y => y.name)); // 기본 전체 체크
const filteredYAxisList = computed(() =>
yAxisList.filter(y => checkedList.value.includes(y.name))
);
const filteredSeriesList = computed(() =>
seriesList.value.filter(s => checkedList.value.includes(s.name))
);
const yAxisScrollIndex = ref(0);
// 실시간 데이터 관련 상태 추가
const isRealTimeMode = ref(false);
const realTimeData = ref<RealTimeData>({});
const realTimeTimer = ref<NodeJS.Timeout | null>(null);
const currentRealTimeIndex = ref(0);
// 실시간 데이터 시작 시간 (48시간 이후)
const realTimeStartTime = 48;
const realTimeDuration = 72; // 72개 포인트 (24초 × 3개/초)
const visibleYAxisList = computed(() =>
filteredYAxisList.value.slice(
yAxisScrollIndex.value,
yAxisScrollIndex.value + 2
)
);
const seriesWithRealTimeData = computed(() => {
return filteredSeriesList.value.map(series => {
const combinedData = [...series.data];
// 실시간 모드일 때 실시간 데이터 추가
if (isRealTimeMode.value && realTimeData.value[series.name]) {
combinedData.push(...realTimeData.value[series.name]);
}
return {
...series,
data: combinedData,
type: "line",
};
});
});
const mappedSeriesList = computed(() => {
// 시간(시) → 초 단위 변환 함수
const hourToSeconds = (h: number) => h * 3600;
return seriesWithRealTimeData.value
.map((s, idx) => {
const globalIdx = filteredYAxisList.value.findIndex(
y => y.name === s.name
);
if (globalIdx === -1) return null;
// 첫 번째 시리즈에만 markLine 추가
const markLine =
idx === 0
? {
symbol: "none",
label: {
show: true,
position: "insideEndTop",
fontWeight: "bold",
fontSize: 13,
color: "#222",
formatter: function (params: { data?: { name?: string } }) {
return params.data && params.data.name
? params.data.name
: "";
},
},
lineStyle: { color: "#888", width: 2, type: "solid" },
data: [
{ xAxis: hourToSeconds(6), name: "1F" },
{ xAxis: hourToSeconds(10), name: "2F" },
{ xAxis: hourToSeconds(12), name: "Seed" },
{ xAxis: hourToSeconds(22), name: "Main" },
],
}
: undefined;
return {
...s,
yAxisIndex: globalIdx,
type: "line",
symbol: "none",
sampling: "lttb",
lineStyle: { width: 0.5 },
...(isLargeMode
? {
large: true,
progressive: 500,
progressiveThreshold: 10000,
}
: {}),
...(markLine ? { markLine } : {}),
};
})
.filter(Boolean);
});
const handleCheckboxChange = (itemName: string, event: Event) => {
const target = event.target as HTMLInputElement;
const isChecked = target.checked;
if (isChecked) {
if (!checkedList.value.includes(itemName)) {
checkedList.value = [...checkedList.value, itemName];
}
} else {
if (checkedList.value.length === 1) {
alert("최소 1개의 데이터를 선택해주세요");
target.checked = true;
return;
}
checkedList.value = checkedList.value.filter(name => name !== itemName);
}
};
const isLargeMode = pointsPerLine.value * yAxisList.length >= 10000;
function updateGraphicLabels() {
if (!chartInstance) return;
const x1 = chartInstance.convertToPixel({ xAxisIndex: 0 }, 6);
const x2 = chartInstance.convertToPixel({ xAxisIndex: 0 }, 10);
const x3 = chartInstance.convertToPixel({ xAxisIndex: 0 }, 12);
const x4 = chartInstance.convertToPixel({ xAxisIndex: 0 }, 22);
chartInstance.setOption({
graphic: [
{ id: "label-1f", left: x1 - 9, top: 10 },
{ id: "label-2f", left: x2 - 9, top: 10 },
{ id: "label-seed", left: x3 - 10, top: 10 },
{ id: "label-main", left: x4 - 10, top: 10 },
{
id: "line-1f",
type: "line",
shape: { x1: x1, y1: 0, x2: x1, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-2f",
type: "line",
shape: { x1: x2, y1: 0, x2: x2, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-seed",
type: "line",
shape: { x1: x3, y1: 0, x2: x3, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-main",
type: "line",
shape: { x1: x4, y1: 0, x2: x4, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
],
});
}
const renderChart = () => {
if (!growthChartRef.value) return;
if (!chartInstance) {
chartInstance = echarts.init(growthChartRef.value);
}
// yAxis는 visibleYAxisList 2개만, 나머지는 show: false
const yAxis = filteredYAxisList.value.map((y, i) => {
const visibleIdx = visibleYAxisList.value.findIndex(
vy => vy.name === y.name
);
return {
type: "value",
min: y.min,
max: y.max,
show: visibleIdx !== -1,
position: "left",
offset: visibleIdx === 1 ? 80 : 0,
z: 10 + i,
name: visibleIdx !== -1 ? y.name + (y.unit ? ` ${y.unit}` : "") : "",
nameLocation: "middle",
nameGap: 50,
nameTextStyle: {
color: y.color,
fontWeight: "bold",
fontSize: 11,
writing: "tb-rl",
},
axisLabel: {
show: visibleIdx !== -1,
fontSize: 12,
color: y.color,
fontWeight: "bold",
},
splitLine: {
show: visibleIdx === 1 || visibleIdx === 0,
lineStyle: { color: "#f0f0f0", width: 1 },
},
axisLine: {
show: visibleIdx !== -1,
lineStyle: { color: y.color, width: 2 },
},
axisTick: {
show: visibleIdx !== -1,
lineStyle: { color: y.color },
},
};
});
const option = {
grid: {
left: 120,
right: 60,
top: 40,
bottom: 120,
containLabel: true,
},
// brush: {
// xAxisIndex: "all",
// brushLink: "all",
// outOfBrush: { colorAlpha: 0.1 },
// brushType: "rect",
// throttleType: "debounce",
// throttleDelay: 300,
// },
toolbox: {
feature: {
dataZoom: {
yAxisIndex: "none",
},
},
},
xAxis: [
{
type: "value",
// min: 0, // <= 이 부분 주석 처리 또는 삭제
// max: 100, // <= 이 부분 주석 처리 또는 삭제
splitNumber: 20, // 20개 정도 라벨
position: "bottom",
axisLabel: {
fontSize: 12,
color: "#666",
fontWeight: "normal",
interval: 0, // 모든 라벨 표시 (1분 단위)
hideOverlap: true, // 겹치는 라벨 자동 숨김
formatter: function (value: number) {
// value는 초 단위
const totalSeconds = Math.round(value);
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60);
const seconds = totalSeconds % 60;
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
},
},
splitLine: {
show: true,
lineStyle: { color: "#e0e0e0", width: 1 },
},
axisLine: {
show: true,
lineStyle: { color: "#ccc", width: 1 },
},
axisTick: {
show: true,
lineStyle: { color: "#ccc" },
},
},
],
yAxis,
series: mappedSeriesList.value,
legend: { show: false },
animation: false,
dataZoom: [
{
type: "inside",
xAxisIndex: 0,
start: 0,
end: 100,
minValueSpan: 600, // 최소 10분까지만 확대
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
},
{
type: "slider",
xAxisIndex: 0,
start: 0,
end: 100,
minValueSpan: 600, // 최소 10분까지만 확대
bottom: 10,
height: 20,
borderColor: "transparent",
backgroundColor: "#f5f5f5",
fillerColor: "rgba(25,118,210,0.2)",
handleStyle: {
color: "#1976d2",
},
textStyle: {
color: "#666",
},
},
],
tooltip: {
trigger: "axis",
position: function (pt: number[]) {
return [pt[0], "10%"];
},
axisPointer: {
type: "line",
animation: false,
lineStyle: {
color: "#5470c6",
width: 1,
type: "dashed",
},
},
backgroundColor: "rgba(255, 255, 255, 0.95)",
borderColor: "#ccc",
borderWidth: 1,
textStyle: {
color: "#333",
fontSize: 13,
fontWeight: "normal",
},
extraCssText:
"box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 4px;",
formatter: function (
params: Array<{
seriesName: string;
color: string;
value: [number, number];
}>
) {
if (!Array.isArray(params)) params = [params];
const baseDate = new Date("2025-07-01T00:00:00");
const hour = Array.isArray(params[0].value) ? params[0].value[0] : 0;
const date = new Date(baseDate.getTime());
date.setHours(baseDate.getHours() + hour);
const yyyy = date.getFullYear();
const mm = (date.getMonth() + 1).toString().padStart(2, "0");
const dd = date.getDate().toString().padStart(2, "0");
const HH = date.getHours().toString().padStart(2, "0");
const dateStr = `${yyyy}.${mm}.${dd} - ${HH}:00`;
let tooltipContent = `<div style="font-weight: bold; color: #5470c6; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px;">${dateStr}</div>`;
params.forEach(param => {
let unit = "";
if (param.seriesName && yAxisList) {
const y = yAxisList.find(y => y.name === param.seriesName);
if (y && y.unit) unit = " " + y.unit;
}
const value = Array.isArray(param.value) ? param.value[1] : "";
tooltipContent += `<div style="display: flex; justify-content: space-between; align-items: center; margin: 4px 0;">
<span style="display: flex; align-items: center;">
<span style="display: inline-block; width: 12px; height: 12px; background-color: ${param.color}; margin-right: 8px; border-radius: 2px;"></span>
<span style="font-weight: 500;">${param.seriesName}${unit}</span>
</span>
<span style="font-weight: bold; color: #333; margin-left: 16px;">${value}</span>
</div>`;
});
return tooltipContent;
},
},
...(isLargeMode
? {}
: {
graphic: [
// updateGraphicLabels에서 동적으로 위치가 바뀌는 라벨/라인 정의 (초기값)
{
id: "label-1f",
type: "text",
z: 100,
left: 0,
top: 0,
style: {
text: "1F",
font: "bold 15px sans-serif",
fill: "#222",
textAlign: "center",
},
},
{
id: "label-2f",
type: "text",
z: 100,
left: 0,
top: 0,
style: {
text: "2F",
font: "bold 15px sans-serif",
fill: "#222",
textAlign: "center",
},
},
{
id: "label-seed",
type: "text",
z: 100,
left: 0,
top: 0,
style: {
text: "Seed",
font: "bold 15px sans-serif",
fill: "#222",
textAlign: "center",
},
},
{
id: "label-main",
type: "text",
z: 100,
left: 0,
top: 0,
style: {
text: "Main",
font: "bold 15px sans-serif",
fill: "#222",
textAlign: "center",
},
},
{
id: "line-1f",
type: "line",
shape: { x1: 0, y1: 0, x2: 0, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-2f",
type: "line",
shape: { x1: 0, y1: 0, x2: 0, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-seed",
type: "line",
shape: { x1: 0, y1: 0, x2: 0, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
{
id: "line-main",
type: "line",
shape: { x1: 0, y1: 0, x2: 0, y2: "100%" },
style: { stroke: "#888", lineWidth: 1 },
},
],
}),
};
chartInstance.setOption(option, true);
if (!isLargeMode) updateGraphicLabels();
// brushEnd 이벤트로 드래그 영역 줌인
if (chartInstance) {
chartInstance.off("brushEnd");
chartInstance.on("brushEnd", function (params) {
const p = params as { areas?: { coordRange?: [number, number] }[] };
if (p.areas && p.areas.length > 0) {
const area = p.areas[0];
if (area.coordRange && chartInstance) {
chartInstance.dispatchAction({
type: "dataZoom",
startValue: area.coordRange[0],
endValue: area.coordRange[1],
});
}
}
});
}
};
watch(
[checkedList, yAxisScrollIndex],
([newChecked, _newScroll], [oldChecked, _oldScroll]) => {
const newCheckedArray = [...newChecked];
const oldCheckedArray = oldChecked ? [...oldChecked] : [];
// 체크박스 변경이 있었는지 확인 (슬라이더 이동만으로는 실행하지 않음)
const checkboxChanged = newChecked !== oldChecked;
if (!checkboxChanged) {
renderChart();
return;
}
// 새로 체크된 y축이 있으면, 그 y축이 보이도록 슬라이더 이동
if (
oldCheckedArray.length > 0 &&
newCheckedArray.length > oldCheckedArray.length
) {
const added = newCheckedArray.find(x => !oldCheckedArray.includes(x));
const idx = filteredYAxisList.value.findIndex(y => y.name === added);
if (idx !== -1) {
// 필터된 리스트가 2개 이하면 슬라이더를 0으로 고정
if (filteredYAxisList.value.length <= 2) {
yAxisScrollIndex.value = 0;
} else {
// 새로 추가된 항목이 현재 보이는 범위에 있는지 확인
const currentStart = yAxisScrollIndex.value;
const currentEnd = currentStart + 1;
// 현재 범위에 없으면 슬라이더 이동
if (idx < currentStart || idx > currentEnd) {
const newScrollIndex = Math.max(
0,
Math.min(idx, filteredYAxisList.value.length - 2)
);
yAxisScrollIndex.value = newScrollIndex;
}
}
}
}
// 체크박스 개수가 2개 이하면 슬라이더를 0으로 고정 (추가되지 않은 경우)
else if (filteredYAxisList.value.length <= 2) {
yAxisScrollIndex.value = 0;
}
// 체크박스 해제 시 슬라이더를 가장 왼쪽으로 이동
else if (
oldCheckedArray.length > 0 &&
newCheckedArray.length < oldCheckedArray.length
) {
yAxisScrollIndex.value = 0;
}
renderChart();
}
);
// 실시간 데이터 변경 시 차트 업데이트
watch(
realTimeData,
() => {
if (isRealTimeMode.value) {
renderChart();
}
},
{ deep: true }
);
const menuVisible = ref(false);
const menuX = ref(0);
const menuY = ref(0);
const currentZoomLevel = ref(1);
const zoomStep = 0.2;
const zoomIn = () => {
if (chartInstance) {
currentZoomLevel.value = Math.min(currentZoomLevel.value + zoomStep, 3);
applyZoom();
}
};
const zoomOut = () => {
if (chartInstance) {
currentZoomLevel.value = Math.max(currentZoomLevel.value - zoomStep, 0.5);
applyZoom();
}
};
const resetZoom = () => {
if (chartInstance) {
currentZoomLevel.value = 1;
applyZoom();
}
};
const applyZoom = () => {
if (!chartInstance) return;
const option = chartInstance.getOption();
const xDataZoom = {
type: "inside",
xAxisIndex: 0,
start: 0,
end: 100 / currentZoomLevel.value,
zoomOnMouseWheel: true,
moveOnMouseMove: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
};
const yDataZoom = visibleYAxisList.value.map((_, index) => ({
type: "inside",
yAxisIndex: index,
start: 0,
end: 100 / currentZoomLevel.value,
zoomOnMouseWheel: true,
moveOnMouseWheel: false,
preventDefaultMouseMove: true,
}));
option.dataZoom = [xDataZoom, ...yDataZoom];
chartInstance.setOption(option, false);
};
function onContextMenu(e: MouseEvent) {
e.preventDefault();
menuX.value = e.clientX;
menuY.value = e.clientY;
menuVisible.value = true;
}
function onClickAnywhere() {
menuVisible.value = false;
}
onMounted(() => {
renderChart();
if (chartInstance && !isLargeMode) {
chartInstance.on("finished", updateGraphicLabels);
chartInstance.on("dataZoom", updateGraphicLabels);
window.addEventListener("resize", updateGraphicLabels);
}
// 실시간 모드 자동 시작 비활성화
// setTimeout(() => {
// startRealTimeMode();
// }, 1000); // 1초 후 시작
});
onUnmounted(() => {
stopRealTimeMode();
if (chartInstance) {
chartInstance.dispose();
}
if (!isLargeMode) window.removeEventListener("resize", updateGraphicLabels);
});
// 각 시리즈별 평균 기울기 계산 함수
const calculateAverageSlope = (seriesData: [number, number][]) => {
if (seriesData.length < 2) return 0;
let totalSlope = 0;
let count = 0;
for (let i = 1; i < seriesData.length; i++) {
const current = seriesData[i][1];
const previous = seriesData[i - 1][1];
totalSlope += current - previous;
count++;
}
return count > 0 ? totalSlope / count : 0;
};
// 실시간 데이터 생성 함수
const generateRealTimeData = () => {
filteredSeriesList.value.forEach(series => {
let previousValue: number;
if (
realTimeData.value[series.name] &&
(realTimeData.value[series.name] as [number, number][]).length > 0
) {
const lastRealTimePoint = (
realTimeData.value[series.name] as [number, number][]
)[(realTimeData.value[series.name] as [number, number][]).length - 1];
previousValue = lastRealTimePoint[1];
} else {
const lastDataPoint = series.data[series.data.length - 1] as [
number,
number,
];
previousValue = lastDataPoint[1];
}
const averageSlope = calculateAverageSlope(
series.data as [number, number][]
);
const predictedValue = previousValue + averageSlope * 0.5;
const random = Math.random();
let newValue: number;
if (random < 0.7) {
newValue = predictedValue;
} else if (random < 0.9) {
newValue = previousValue;
} else {
const variation = (Math.random() - 0.5) * 0.04;
newValue = Math.max(0, previousValue * (1 + variation));
}
const newPoint: [number, number] = [
realTimeStartTime + currentRealTimeIndex.value * 0.333,
newValue,
];
if (realTimeData.value[series.name]) {
(realTimeData.value[series.name] as [number, number][]).push(newPoint);
} else {
realTimeData.value[series.name] = [newPoint];
}
});
};
// 실시간 모드 시작 (사용하지 않음)
const _startRealTimeMode = () => {
if (isRealTimeMode.value) return;
isRealTimeMode.value = true;
currentRealTimeIndex.value = 0;
// 실시간 데이터 초기화
realTimeData.value = {};
// 1초마다 새로운 데이터 생성
realTimeTimer.value = setInterval(() => {
currentRealTimeIndex.value++;
if (currentRealTimeIndex.value >= realTimeDuration) {
// 72초 완료 후 실시간 모드 종료 (24초 × 3배 세밀도)
stopRealTimeMode();
return;
}
generateRealTimeData();
}, 1000); // 1초마다 실행
};
watch(intervalValue, () => {
renderChart();
});
// 실시간 모드 중지
const stopRealTimeMode = () => {
if (!isRealTimeMode.value) return;
isRealTimeMode.value = false;
if (realTimeTimer.value) {
clearInterval(realTimeTimer.value);
realTimeTimer.value = null;
}
currentRealTimeIndex.value = 0;
realTimeData.value = {};
};
</script>
<style scoped>
.experiment-graph-page {
width: 95vw;
height: 76vh;
background: #fff;
display: flex;
flex-direction: column;
overflow: hidden;
}
.info-bar {
width: 100%;
background: #fff;
border-bottom: 1.5px solid #e0e0e0;
padding: 18px 32px 10px 32px;
font-size: 1.08rem;
font-family: "Pretendard", "Noto Sans KR", sans-serif;
font-weight: 500;
letter-spacing: 0.01em;
box-sizing: border-box;
}
.info-row {
display: flex;
align-items: center;
gap: 16px;
position: relative;
}
.info-label {
color: #222;
font-weight: 600;
margin-right: 2px;
}
.info-value {
color: #1976d2;
font-weight: 500;
margin-right: 8px;
}
.info-divider {
color: #aaa;
margin: 0 10px;
}
.realdata-checkbox-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 32px 8px 32px;
font-size: 1.08rem;
border-bottom: 1px solid #e0e0e0;
margin-bottom: 0px;
background: #fff;
flex-shrink: 0;
overflow-x: auto;
white-space: nowrap;
}
.realdata-label {
font-weight: bold;
margin-right: 12px;
color: #444;
}
.realdata-checkbox-item {
margin-right: 10px;
font-size: 1.01rem;
display: inline-flex;
align-items: center;
gap: 2px;
user-select: none;
}
.realdata-checkbox-item input[type="checkbox"] {
accent-color: #7ecbff;
width: 16px;
height: 16px;
margin-right: 2px;
}
.main-graph-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.echarts-container {
flex: 1;
position: relative;
background: #fff;
}
.echarts-graph {
width: 100%;
height: 100%;
}
.zoom-controls {
position: absolute;
top: 60px; /* 기존 10px에서 60px로 변경 */
right: 10px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 1000;
}
.zoom-btn {
width: 32px;
height: 32px;
border: 1px solid #ddd;
background: #fff;
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
color: #666;
transition: all 0.2s ease;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.zoom-btn:hover {
background: #f5f5f5;
border-color: #bbb;
color: #333;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.zoom-btn.reset {
font-size: 14px;
}
.zoom-btn:active {
transform: translateY(1px);
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.yaxis-scroll-bar-overlay {
position: absolute;
left: 60px;
bottom: 70px;
width: 120px;
height: 24px;
display: flex;
align-items: flex-start;
justify-content: center;
transform: translateY(50%);
background: none;
border-radius: 12px 12px 0 0;
box-shadow: none;
z-index: 30;
padding: 0 8px;
pointer-events: auto;
}
.yaxis-scroll-bar-overlay input[type="range"] {
width: 100%;
height: 8px;
accent-color: #bbb;
background: rgba(180, 180, 180, 0.1);
border-radius: 8px;
opacity: 0.4;
transition: box-shadow 0.2s;
}
.yaxis-scroll-bar-overlay input[type="range"]::-webkit-slider-thumb {
background: #eee;
border: 1.5px solid #ccc;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
opacity: 0.5;
}
.yaxis-scroll-bar-overlay input[type="range"]:hover {
box-shadow: 0 0 0 2px #bbb2;
}
</style>