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

1649 lines
42 KiB
Vue
Raw Normal View History

<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";
2025-09-25 15:33:11 +09:00
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>