mearge
This commit is contained in:
413
pages/test/culture-graph-multi.vue
Normal file
413
pages/test/culture-graph-multi.vue
Normal file
@@ -0,0 +1,413 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user