Files
bio_frontend/pages/test/culture-graph-multi.vue
leejisun9 2ec34ff321 mearge
2025-09-12 11:10:43 +09:00

414 lines
12 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> 30개의 그래프를 화면에 동시에 있습니다.</li>
<li>렌더링 최적화를 위해 최소한의 기능만 제공됩니다.</li>
</ul>
</div>
<div class="box">
<h2>2. 출력되는 데이터 개수</h2>
<ul>
<li>
그래프 30 × 시리즈 12 × 10 간격(100시간, 601포인트) =
<span class="highlight">216,360</span>
</li>
<li>화면 렌더링 시간은 600ms ~ 800ms 정도 소요됩니다.</li>
</ul>
</div>
<div class="box">
<h2>3. 그래프 복사</h2>
<ul>
<li>
<span class="highlight">그래프 복사</span> 버튼을 클릭하면 그래프가
클립보드에 복사됩니다.
</li>
</ul>
</div>
</PageDescription>
<div class="multi-graph-list">
<div v-for="series in seriesList" :key="series.name" class="single-graph">
<div class="graph-title">
{{ series.name }}<span v-if="series.unit"> {{ series.unit }}</span>
<button class="copy-btn" @click="copyChartImage(series.name)">
그래프 복사
</button>
</div>
<div
:ref="el => setGraphRef(series.name, el as Element | null)"
class="echarts-graph"
></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick } from "vue";
import * as echarts from "echarts";
// 파스텔 컬러 15개 반복
const pastelColors = [
"#A3D8F4",
"#F7B7A3",
"#B5EAD7",
"#FFDAC1",
"#C7CEEA",
"#FFF1BA",
"#FFB7B2",
"#B4A7D6",
"#AED9E0",
"#FFC3A0",
"#E2F0CB",
"#FFB347",
"#C1C8E4",
"#FFFACD",
"#FFD1DC",
];
// 그래프 개수 변수로 분리
const NUM_GRAPHS = 30;
// 30개 y축 정보 (culture-graph.vue와 동일하게 수정)
const yAxisList = [
{ name: "ORP", unit: "", color: pastelColors[0], min: 0, max: 1000 },
{
name: "Air flow",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 30,
},
{ name: "DO", unit: "", color: pastelColors[2], min: 0, max: 200 },
{ name: "Feed TK1", unit: "(L)", color: pastelColors[3], min: 0, max: 10 },
{ name: "Feed TK2", unit: "(L)", color: pastelColors[4], min: 0, max: 10 },
{ name: "pH", unit: "", color: pastelColors[5], min: 6.0, max: 8.0 },
{ name: "Pressure", unit: "(bar)", color: pastelColors[6], min: 0, max: 2 },
{ name: "RPM", unit: "", color: pastelColors[7], min: 0, max: 3000 },
{ name: "CO2", unit: "(%)", color: pastelColors[8], min: 0, max: 10 },
{ name: "JAR Vol", unit: "(L)", color: pastelColors[9], min: 0, max: 20 },
{ name: "WEIGHT", unit: "(kg)", color: pastelColors[10], min: 0, max: 100 },
{ name: "O2", unit: "(%)", color: pastelColors[11], min: 0, max: 100 },
{ name: "NV", unit: "", color: pastelColors[12], min: 0, max: 10 },
{ name: "NIR", unit: "", color: pastelColors[13], min: 0, max: 10 },
{
name: "Temperature",
unit: "(℃)",
color: pastelColors[14],
min: 0,
max: 50,
},
{ name: "Humidity", unit: "(%)", color: pastelColors[0], min: 0, max: 100 },
{
name: "Flow Rate",
unit: "(L/min)",
color: pastelColors[1],
min: 0,
max: 60,
},
{
name: "Conductivity",
unit: "(μS/cm)",
color: pastelColors[2],
min: 0,
max: 600,
},
{ name: "Turbidity", unit: "(NTU)", color: pastelColors[3], min: 0, max: 12 },
{
name: "Dissolved Solids",
unit: "(mg/L)",
color: pastelColors[4],
min: 0,
max: 250,
},
{
name: "Alkalinity",
unit: "(mg/L)",
color: pastelColors[5],
min: 0,
max: 120,
},
{
name: "Hardness",
unit: "(mg/L)",
color: pastelColors[6],
min: 0,
max: 100,
},
{ name: "Chlorine", unit: "(mg/L)", color: pastelColors[7], min: 0, max: 6 },
{ name: "Nitrate", unit: "(mg/L)", color: pastelColors[8], min: 0, max: 25 },
{
name: "Phosphate",
unit: "(mg/L)",
color: pastelColors[9],
min: 0,
max: 12,
},
{ name: "Sulfate", unit: "(mg/L)", color: pastelColors[10], min: 0, max: 60 },
{ name: "Ammonia", unit: "(mg/L)", color: pastelColors[11], min: 0, max: 18 },
{ name: "Nitrite", unit: "(mg/L)", color: pastelColors[12], min: 0, max: 6 },
{ name: "BOD", unit: "(mg/L)", color: pastelColors[13], min: 0, max: 35 },
{ name: "COD", unit: "(mg/L)", color: pastelColors[14], min: 0, max: 120 },
];
// NUM_GRAPHS까지 자동 생성
if (yAxisList.length < NUM_GRAPHS) {
for (let i = yAxisList.length; i < NUM_GRAPHS; i++) {
yAxisList.push({
name: `Graph ${i + 1}`,
unit: "",
color: pastelColors[i % pastelColors.length],
min: 0,
max: 100 + (i % 10) * 100, // 100, 200, ... 1000 반복
});
}
}
// x축 간격(초) - 5초 또는 1분(60초) 등으로 변경 가능
// 예시: const X_INTERVAL = 5; // 5초 간격
// const X_INTERVAL = 60; // 1분 간격
const X_INTERVAL = 600; // 필요시 60으로 변경
// 100시간치, X_INTERVAL 간격
const xLabels = Array.from(
{ length: (100 * 60 * 60) / X_INTERVAL + 1 },
(_, i) => i * X_INTERVAL
);
// 부드러운 곡선형 + 구간별 변화 가데이터 생성 함수 (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]);
}
return values;
}
// 시리즈 데이터 30개 생성 → 각 그래프마다 12개 라인(시리즈)로 확장
const NUM_LINES_PER_GRAPH = 12;
const seriesList = yAxisList.map((y, idx) => {
// 12개 라인(시리즈) 생성
const lines = Array.from({ length: NUM_LINES_PER_GRAPH }, (_, lineIdx) => ({
name: String(lineIdx + 1), // "1", "2", ... "12"
color: pastelColors[lineIdx % pastelColors.length],
data: smoothData(
y.min,
y.max,
xLabels,
Math.PI / (idx + 1 + lineIdx), // phase를 다르게
1,
0,
idx + lineIdx * 2 // 패턴 다양화
),
}));
return {
name: y.name,
unit: y.unit,
color: y.color,
min: y.min,
max: y.max,
lines, // 12개 시리즈 배열
};
});
// 차트 렌더링
const graphRefs = ref<Record<string, HTMLDivElement | null>>({});
const chartInstances = ref<Record<string, echarts.ECharts | null>>({});
function setGraphRef(name: string, el: Element | null) {
if (el && el instanceof HTMLDivElement) {
graphRefs.value[name] = el;
}
}
function renderAllCharts() {
nextTick(() => {
seriesList.forEach(series => {
const el = graphRefs.value[series.name];
if (!el) return;
if (chartInstances.value[series.name]) {
chartInstances.value[series.name]?.dispose();
}
const chart = echarts.init(el);
chart.setOption({
grid: { left: 50, right: 20, top: 30, bottom: 40, containLabel: true },
xAxis: {
type: "value",
axisLabel: {
fontSize: 11,
color: "#666",
formatter: (v: number) =>
`${Math.floor(v / 3600)}:${String(Math.floor((v % 3600) / 60)).padStart(2, "0")}`,
},
},
yAxis: {
type: "value",
min: series.min,
max: series.max,
name: series.unit ? `${series.name} ${series.unit}` : series.name,
nameTextStyle: {
color: series.color,
fontWeight: "bold",
fontSize: 12,
},
axisLabel: { color: series.color, fontWeight: "bold", fontSize: 12 },
axisLine: { lineStyle: { color: series.color, width: 2 } },
},
series: series.lines.map(line => ({
name: line.name,
data: line.data,
type: "line",
lineStyle: { color: line.color, width: 1 },
symbol: "none",
})),
animation: false,
legend: {
show: true,
bottom: 8, // 하단에 위치
left: "center", // 중앙 정렬
orient: "horizontal",
itemWidth: 18,
itemHeight: 10,
icon: "circle",
textStyle: { fontSize: 12, padding: [0, 4, 0, 0] },
},
tooltip: { show: true, trigger: "axis" },
});
chartInstances.value[series.name] = chart;
});
});
}
// 이미지 복사 함수 추가
async function copyImageToClipboard(dataUrl: string) {
const res = await fetch(dataUrl);
const blob = await res.blob();
await navigator.clipboard.write([
new window.ClipboardItem({ [blob.type]: blob }),
]);
}
async function copyChartImage(name: string) {
const chart = chartInstances.value[name];
if (!chart) return;
const dataUrl = chart.getDataURL({
type: "png",
pixelRatio: 2,
backgroundColor: "#fff",
});
try {
await copyImageToClipboard(dataUrl);
alert("그래프가 클립보드에 복사되었습니다!");
} catch {
alert("클립보드 복사에 실패했습니다. 브라우저 권한을 확인하세요.");
}
}
onMounted(() => {
renderAllCharts();
});
</script>
<style scoped>
.multi-graph-list {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20px 1.5%;
padding: 16px 0;
align-content: flex-start;
flex-direction: row;
background: #fff;
}
.single-graph {
min-height: 260px;
width: 100%;
flex: 0 0 100%;
max-width: 100%;
background: #fff;
border: 1px solid #e0e0e0;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
padding: 10px 16px 10px 16px;
position: relative;
display: flex;
flex-direction: column;
margin-bottom: 20px;
align-items: flex-start;
}
.graph-title {
font-weight: bold;
font-size: 1.05rem;
color: #1976d2;
margin-bottom: 6px;
width: 100%;
}
.echarts-graph {
min-height: 200px;
height: 200px !important;
width: 100%;
}
.copy-btn {
margin-left: 10px;
font-size: 0.9rem;
padding: 2px 8px;
border: 1px solid #1976d2;
background: #e3f2fd;
color: #1976d2;
border-radius: 4px;
cursor: pointer;
transition: background 0.2s;
float: right;
}
.copy-btn:hover {
background: #bbdefb;
}
</style>