414 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
			
		
		
	
	
			414 lines
		
	
	
		
			12 KiB
		
	
	
	
		
			Vue
		
	
	
	
	
	
<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>
 |