Files
bio_frontend/components/BatchGraph.vue
2025-08-08 13:11:33 +09:00

652 lines
15 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 class="graph-container">
<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>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, computed, onUnmounted } from "vue";
import * as echarts from "echarts";
const props = defineProps<{ batchIndex: number }>();
// --- 기존 culture-graph.vue에서 필요한 상수/데이터 복사 ---
const pastelColors = [
"#A3D8F4",
"#F7B7A3",
"#B5EAD7",
"#FFDAC1",
"#C7CEEA",
"#FFF1BA",
"#FFB7B2",
"#B4A7D6",
"#AED9E0",
"#FFC3A0",
"#E2F0CB",
"#FFB347",
"#C1C8E4",
"#FFFACD",
"#FFD1DC",
];
const yAxisList = [
{
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",
},
];
const intervalValue = ref(60); // 1분
const totalSeconds = 100 * 60 * 60;
const pointsPerLine = computed(
() => Math.floor(totalSeconds / intervalValue.value) + 1
);
const xLabels = computed(() =>
Array.from({ length: pointsPerLine.value }, (_, i) => i * intervalValue.value)
);
// --- batchIndex에 따라 곡선 패턴이 다르게 나오도록 smoothData 수정 ---
function smoothData(
min: number,
max: number,
xLabels: number[],
phase = 0,
_amp = 1,
offset = 0,
seriesIndex: number
) {
let _prevValue = 0.5;
const values = [];
// batchIndex를 곡선 패턴에 반영 (phase, ampVar, randomFactor 등)
const batchPhase = phase + (props.batchIndex * Math.PI) / 5;
const ampVar = 0.2 + ((seriesIndex + props.batchIndex) % 5) * 0.1;
const randomFactor = 0.01 + 0.01 * ((seriesIndex + props.batchIndex) % 4);
const trendType = (seriesIndex + props.batchIndex) % 4;
const rangeMin = min + (max - min) * 0.1;
const rangeMax = max - (max - min) * 0.1;
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(batchPhase + t * Math.PI * ampVar * 8);
} else if (trendType === 1) {
base =
0.8 - 0.6 * t + 0.13 * Math.cos(batchPhase + t * Math.PI * ampVar * 8);
} else if (trendType === 2) {
base = 0.5 + 0.22 * Math.sin(batchPhase + t * Math.PI * ampVar * 12);
} else {
base =
0.5 + 0.08 * Math.sin(batchPhase + t * Math.PI * ampVar * 6) + 0.2 * t;
}
// 노이즈도 batchIndex에 따라 다르게
const noise =
(Math.sin(i + props.batchIndex * 13) * 0.5 + 0.5) *
randomFactor *
(Math.random() - 0.5);
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]);
}
return values;
}
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 visibleYAxisList = computed(() =>
filteredYAxisList.value.slice(
yAxisScrollIndex.value,
yAxisScrollIndex.value + 2
)
);
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),
},
]);
const batchMarkLines = [
// 배치1
[
{ xAxis: 6 * 3600, name: "1F" },
{ xAxis: 10 * 3600, name: "2F" },
{ xAxis: 12 * 3600, name: "Seed" },
{ xAxis: 22 * 3600, name: "Main" },
],
// 배치2
[
{ xAxis: 12 * 3600, name: "1F" },
{ xAxis: 18 * 3600, name: "2F" },
{ xAxis: 16 * 3600, name: "Seed" },
{ xAxis: 36 * 3600, name: "Main" },
],
// 배치3
[
{ xAxis: 15 * 3600, name: "1F" },
{ xAxis: 30 * 3600, name: "2F" },
{ xAxis: 45 * 3600, name: "Seed" },
{ xAxis: 60 * 3600, name: "Main" },
],
// 배치4
[
{ xAxis: 8 * 3600, name: "1F" },
{ xAxis: 16 * 3600, name: "2F" },
{ xAxis: 24 * 3600, name: "Seed" },
{ xAxis: 50 * 3600, name: "Main" },
],
];
// --- 차트 렌더링 로직 (간단화) ---
const growthChartRef = ref<HTMLDivElement | null>(null);
let chartInstance: echarts.ECharts | null = null;
const renderChart = () => {
if (!growthChartRef.value) return;
if (!chartInstance) {
chartInstance = echarts.init(growthChartRef.value);
}
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 markLine = {
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: batchMarkLines[props.batchIndex] || [],
};
const option = {
grid: { left: 120, right: 60, top: 40, bottom: 120, containLabel: true },
xAxis: [
{
type: "value",
splitNumber: 20,
position: "bottom",
axisLabel: {
fontSize: 12,
color: "#666",
fontWeight: "normal",
interval: 0,
hideOverlap: true,
formatter: function (value: number) {
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: filteredSeriesList.value.map((s, idx) => ({
...s,
yAxisIndex: filteredYAxisList.value.findIndex(y => y.name === s.name),
type: "line",
symbol: "none",
sampling: "lttb",
lineStyle: { width: 0.5 },
...(idx === 0 ? { markLine } : {}),
})),
legend: { show: false },
animation: false,
};
chartInstance.setOption(option, true);
};
onMounted(() => {
renderChart();
});
watch([checkedList, yAxisScrollIndex, () => props.batchIndex], () => {
renderChart();
});
onUnmounted(() => {
if (chartInstance) {
chartInstance.dispose();
}
});
// 줌 컨트롤 함수
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);
};
</script>
<style scoped>
.graph-container {
width: 100%;
height: 1000px;
}
.main-graph-area {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
.echarts-container {
flex: 1;
position: relative;
background: #fff;
width: 100%;
height: 100%;
min-height: 1000px;
}
.echarts-graph {
width: 100%;
height: 100%;
min-height: 1000px;
}
.zoom-controls {
position: absolute;
top: 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>