[페이지 원복] 기존 테스트 페이지 원복복

This commit is contained in:
2025-09-03 17:18:25 +09:00
parent 5f1d1f5018
commit 0f0317e356
12 changed files with 4783 additions and 3 deletions

View 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>