1039 lines
28 KiB
HTML
1039 lines
28 KiB
HTML
|
|
<!doctype html>
|
|||
|
|
<html lang="ko">
|
|||
|
|
<head>
|
|||
|
|
<meta charset="UTF-8" />
|
|||
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|||
|
|
<title>Culture Graph</title>
|
|||
|
|
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
|
|||
|
|
<style>
|
|||
|
|
* {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
box-sizing: border-box;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
body {
|
|||
|
|
font-family: "Pretendard", "Noto Sans KR", sans-serif;
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.experiment-graph-page {
|
|||
|
|
width: 95vw;
|
|||
|
|
height: 76vh;
|
|||
|
|
background: #fff;
|
|||
|
|
display: flex;
|
|||
|
|
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
margin: 20px auto;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-bar {
|
|||
|
|
width: 100%;
|
|||
|
|
background: #fff;
|
|||
|
|
border-bottom: 1.5px solid #e0e0e0;
|
|||
|
|
padding: 18px 32px 10px 32px;
|
|||
|
|
font-size: 1.08rem;
|
|||
|
|
font-weight: 500;
|
|||
|
|
letter-spacing: 0.01em;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-row {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 16px;
|
|||
|
|
position: relative;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-label {
|
|||
|
|
color: #222;
|
|||
|
|
font-weight: 600;
|
|||
|
|
margin-right: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-value {
|
|||
|
|
color: #1976d2;
|
|||
|
|
font-weight: 500;
|
|||
|
|
margin-right: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-divider {
|
|||
|
|
color: #aaa;
|
|||
|
|
margin: 0 10px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.realdata-checkbox-bar {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
padding: 12px 32px 8px 32px;
|
|||
|
|
font-size: 1.08rem;
|
|||
|
|
border-bottom: 1px solid #e0e0e0;
|
|||
|
|
margin-bottom: 0px;
|
|||
|
|
background: #fff;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
overflow-x: auto;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.realdata-label {
|
|||
|
|
font-weight: bold;
|
|||
|
|
margin-right: 12px;
|
|||
|
|
color: #444;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.realdata-checkbox-item {
|
|||
|
|
margin-right: 10px;
|
|||
|
|
font-size: 1.01rem;
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 2px;
|
|||
|
|
user-select: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.realdata-checkbox-item input[type="checkbox"] {
|
|||
|
|
accent-color: #7ecbff;
|
|||
|
|
width: 16px;
|
|||
|
|
height: 16px;
|
|||
|
|
margin-right: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.main-graph-area {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.echarts-container {
|
|||
|
|
flex: 1;
|
|||
|
|
position: relative;
|
|||
|
|
background: #fff;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.echarts-graph {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu {
|
|||
|
|
position: fixed;
|
|||
|
|
background: white;
|
|||
|
|
border: 1px solid #ccc;
|
|||
|
|
border-radius: 4px;
|
|||
|
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
|||
|
|
padding: 8px 0;
|
|||
|
|
z-index: 10000;
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-item {
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: #333;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.context-menu-item:hover {
|
|||
|
|
background: #f5f5f5;
|
|||
|
|
}
|
|||
|
|
</style>
|
|||
|
|
</head>
|
|||
|
|
<body>
|
|||
|
|
<div class="experiment-graph-page" id="experimentGraphPage">
|
|||
|
|
<div class="info-bar">
|
|||
|
|
<div class="info-row">
|
|||
|
|
<span class="info-label">Experiment :</span>
|
|||
|
|
<span class="info-value">Sample001</span>
|
|||
|
|
<span class="info-divider">|</span>
|
|||
|
|
<span class="info-label">Strain :</span>
|
|||
|
|
<span class="info-value">균주명(Test)</span>
|
|||
|
|
<span class="info-divider">|</span>
|
|||
|
|
<span class="info-label">Time :</span>
|
|||
|
|
<span class="info-value"
|
|||
|
|
>2025.07.01 - 00:00 ~ 2025.07.03 - 00:00</span
|
|||
|
|
>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="realdata-checkbox-bar">
|
|||
|
|
<span class="realdata-label">Real Data</span>
|
|||
|
|
<span
|
|||
|
|
style="font-size: 12px; color: #666; margin-left: 10px"
|
|||
|
|
id="checkboxCount"
|
|||
|
|
>(30/30)</span
|
|||
|
|
>
|
|||
|
|
<div id="checkboxContainer"></div>
|
|||
|
|
</div>
|
|||
|
|
<div class="main-graph-area">
|
|||
|
|
<div class="echarts-container">
|
|||
|
|
<div class="zoom-controls">
|
|||
|
|
<button class="zoom-btn" title="확대" onclick="zoomIn()">
|
|||
|
|
<span>+</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="zoom-btn" title="축소" onclick="zoomOut()">
|
|||
|
|
<span>−</span>
|
|||
|
|
</button>
|
|||
|
|
<button class="zoom-btn reset" title="초기화" onclick="resetZoom()">
|
|||
|
|
<span>⟲</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div id="growthChart" class="echarts-graph"></div>
|
|||
|
|
<div class="yaxis-scroll-bar-overlay">
|
|||
|
|
<input
|
|||
|
|
id="yAxisScrollSlider"
|
|||
|
|
type="range"
|
|||
|
|
min="0"
|
|||
|
|
max="28"
|
|||
|
|
value="0"
|
|||
|
|
oninput="handleYAxisScroll(this.value)"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div class="context-menu" id="contextMenu">
|
|||
|
|
<div
|
|||
|
|
class="context-menu-item"
|
|||
|
|
onclick="handleContextMenuAction('zoomIn')"
|
|||
|
|
>
|
|||
|
|
확대
|
|||
|
|
</div>
|
|||
|
|
<div
|
|||
|
|
class="context-menu-item"
|
|||
|
|
onclick="handleContextMenuAction('zoomOut')"
|
|||
|
|
>
|
|||
|
|
축소
|
|||
|
|
</div>
|
|||
|
|
<div class="context-menu-item" onclick="handleContextMenuAction('reset')">
|
|||
|
|
초기화
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<script>
|
|||
|
|
// 전역 변수
|
|||
|
|
let chartInstance = null;
|
|||
|
|
let checkedList = [];
|
|||
|
|
let yAxisScrollIndex = 0;
|
|||
|
|
let currentZoomLevel = 1;
|
|||
|
|
const zoomStep = 0.2;
|
|||
|
|
|
|||
|
|
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],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Air flow",
|
|||
|
|
unit: "(L/min)",
|
|||
|
|
color: pastelColors[1],
|
|||
|
|
min: 0,
|
|||
|
|
max: 30,
|
|||
|
|
ticks: [30, 25, 20, 15, 10, 5, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "DO",
|
|||
|
|
unit: "",
|
|||
|
|
color: pastelColors[2],
|
|||
|
|
min: 0,
|
|||
|
|
max: 200,
|
|||
|
|
ticks: [200, 150, 100, 50, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Feed TK1",
|
|||
|
|
unit: "(L)",
|
|||
|
|
color: pastelColors[3],
|
|||
|
|
min: 0,
|
|||
|
|
max: 10,
|
|||
|
|
ticks: [10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Feed TK2",
|
|||
|
|
unit: "(L)",
|
|||
|
|
color: pastelColors[4],
|
|||
|
|
min: 0,
|
|||
|
|
max: 10,
|
|||
|
|
ticks: [10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "pH",
|
|||
|
|
unit: "",
|
|||
|
|
color: pastelColors[5],
|
|||
|
|
min: 6.0,
|
|||
|
|
max: 8.0,
|
|||
|
|
ticks: [8.0, 7.5, 7.0, 6.5, 6.0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Pressure",
|
|||
|
|
unit: "(bar)",
|
|||
|
|
color: pastelColors[6],
|
|||
|
|
min: 0,
|
|||
|
|
max: 2,
|
|||
|
|
ticks: [2, 1.5, 1, 0.5, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "RPM",
|
|||
|
|
unit: "",
|
|||
|
|
color: pastelColors[7],
|
|||
|
|
min: 0,
|
|||
|
|
max: 3000,
|
|||
|
|
ticks: [3000, 2400, 1800, 1200, 600, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "CO2",
|
|||
|
|
unit: "(%)",
|
|||
|
|
color: pastelColors[8],
|
|||
|
|
min: 0,
|
|||
|
|
max: 10,
|
|||
|
|
ticks: [10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "JAR Vol",
|
|||
|
|
unit: "(L)",
|
|||
|
|
color: pastelColors[9],
|
|||
|
|
min: 0,
|
|||
|
|
max: 20,
|
|||
|
|
ticks: [20, 16, 12, 8, 4, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "WEIGHT",
|
|||
|
|
unit: "(kg)",
|
|||
|
|
color: pastelColors[10],
|
|||
|
|
min: 0,
|
|||
|
|
max: 100,
|
|||
|
|
ticks: [100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "O2",
|
|||
|
|
unit: "(%)",
|
|||
|
|
color: pastelColors[11],
|
|||
|
|
min: 0,
|
|||
|
|
max: 100,
|
|||
|
|
ticks: [100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "NV",
|
|||
|
|
unit: "",
|
|||
|
|
color: pastelColors[12],
|
|||
|
|
min: 0,
|
|||
|
|
max: 10,
|
|||
|
|
ticks: [10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "NIR",
|
|||
|
|
unit: "",
|
|||
|
|
color: pastelColors[13],
|
|||
|
|
min: 0,
|
|||
|
|
max: 10,
|
|||
|
|
ticks: [10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Temperature",
|
|||
|
|
unit: "(℃)",
|
|||
|
|
color: pastelColors[14],
|
|||
|
|
min: 0,
|
|||
|
|
max: 50,
|
|||
|
|
ticks: [50, 40, 30, 20, 10, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Humidity",
|
|||
|
|
unit: "(%)",
|
|||
|
|
color: pastelColors[0],
|
|||
|
|
min: 0,
|
|||
|
|
max: 100,
|
|||
|
|
ticks: [100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Flow Rate",
|
|||
|
|
unit: "(L/min)",
|
|||
|
|
color: pastelColors[1],
|
|||
|
|
min: 0,
|
|||
|
|
max: 60,
|
|||
|
|
ticks: [60, 50, 40, 30, 20, 10, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Conductivity",
|
|||
|
|
unit: "(μS/cm)",
|
|||
|
|
color: pastelColors[2],
|
|||
|
|
min: 0,
|
|||
|
|
max: 600,
|
|||
|
|
ticks: [600, 500, 400, 300, 200, 100, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Turbidity",
|
|||
|
|
unit: "(NTU)",
|
|||
|
|
color: pastelColors[3],
|
|||
|
|
min: 0,
|
|||
|
|
max: 12,
|
|||
|
|
ticks: [12, 10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Dissolved Solids",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[4],
|
|||
|
|
min: 0,
|
|||
|
|
max: 250,
|
|||
|
|
ticks: [250, 200, 150, 100, 50, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Alkalinity",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[5],
|
|||
|
|
min: 0,
|
|||
|
|
max: 120,
|
|||
|
|
ticks: [120, 100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Hardness",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[6],
|
|||
|
|
min: 0,
|
|||
|
|
max: 100,
|
|||
|
|
ticks: [100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Chlorine",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[7],
|
|||
|
|
min: 0,
|
|||
|
|
max: 6,
|
|||
|
|
ticks: [6, 5, 4, 3, 2, 1, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Nitrate",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[8],
|
|||
|
|
min: 0,
|
|||
|
|
max: 25,
|
|||
|
|
ticks: [25, 20, 15, 10, 5, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Phosphate",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[9],
|
|||
|
|
min: 0,
|
|||
|
|
max: 12,
|
|||
|
|
ticks: [12, 10, 8, 6, 4, 2, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Sulfate",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[10],
|
|||
|
|
min: 0,
|
|||
|
|
max: 60,
|
|||
|
|
ticks: [60, 50, 40, 30, 20, 10, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Ammonia",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[11],
|
|||
|
|
min: 0,
|
|||
|
|
max: 18,
|
|||
|
|
ticks: [18, 15, 12, 9, 6, 3, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "Nitrite",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[12],
|
|||
|
|
min: 0,
|
|||
|
|
max: 6,
|
|||
|
|
ticks: [6, 5, 4, 3, 2, 1, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "BOD",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[13],
|
|||
|
|
min: 0,
|
|||
|
|
max: 35,
|
|||
|
|
ticks: [35, 30, 25, 20, 15, 10, 5, 0],
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
name: "COD",
|
|||
|
|
unit: "(mg/L)",
|
|||
|
|
color: pastelColors[14],
|
|||
|
|
min: 0,
|
|||
|
|
max: 120,
|
|||
|
|
ticks: [120, 100, 80, 60, 40, 20, 0],
|
|||
|
|
},
|
|||
|
|
];
|
|||
|
|
|
|||
|
|
// 데이터 생성 함수
|
|||
|
|
const POINTS_PER_LINE = 66667;
|
|||
|
|
const xLabels = Array.from(
|
|||
|
|
{ length: POINTS_PER_LINE },
|
|||
|
|
(_, i) => +(i * (48 / (POINTS_PER_LINE - 1))).toFixed(6)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
function smoothData(
|
|||
|
|
min,
|
|||
|
|
max,
|
|||
|
|
n,
|
|||
|
|
phase = 0,
|
|||
|
|
amp = 1,
|
|||
|
|
offset = 0,
|
|||
|
|
seriesIndex
|
|||
|
|
) {
|
|||
|
|
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:랜덤
|
|||
|
|
for (let i = 0; i < n; i++) {
|
|||
|
|
const t = i / (n - 1);
|
|||
|
|
let base;
|
|||
|
|
if (trendType === 0) {
|
|||
|
|
// 완만한 증가 + 진동
|
|||
|
|
base =
|
|||
|
|
0.2 + 0.6 * t + 0.15 * Math.sin(phase + t * Math.PI * amp * 2);
|
|||
|
|
} else if (trendType === 1) {
|
|||
|
|
// 완만한 감소 + 진동
|
|||
|
|
base =
|
|||
|
|
0.8 - 0.6 * t + 0.15 * Math.cos(phase + t * Math.PI * amp * 2);
|
|||
|
|
} else if (trendType === 2) {
|
|||
|
|
// 진동(사인파)
|
|||
|
|
base = 0.5 + 0.35 * Math.sin(phase + t * Math.PI * amp * 3);
|
|||
|
|
} else {
|
|||
|
|
// 랜덤 트렌드(완만한 곡선 + 노이즈)
|
|||
|
|
base = 0.5 + 0.2 * Math.sin(phase + t * Math.PI * amp) + 0.2 * t;
|
|||
|
|
}
|
|||
|
|
// 변동성(노이즈) 대폭 감소
|
|||
|
|
const randomFactor = 0.05 + 0.05 * (seriesIndex % 3); // 0.05~0.15
|
|||
|
|
const noise = (Math.random() - 0.5) * randomFactor * 0.18;
|
|||
|
|
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 seriesList = yAxisList.map((yAxis, index) => ({
|
|||
|
|
name: yAxis.name,
|
|||
|
|
color: yAxis.color,
|
|||
|
|
yAxisIndex: index,
|
|||
|
|
data: smoothData(
|
|||
|
|
yAxis.min,
|
|||
|
|
yAxis.max,
|
|||
|
|
POINTS_PER_LINE,
|
|||
|
|
Math.PI / (index + 1),
|
|||
|
|
1,
|
|||
|
|
0,
|
|||
|
|
index
|
|||
|
|
),
|
|||
|
|
}));
|
|||
|
|
|
|||
|
|
// 체크박스 생성
|
|||
|
|
function createCheckboxes() {
|
|||
|
|
const container = document.getElementById("checkboxContainer");
|
|||
|
|
checkedList = yAxisList.map(y => y.name);
|
|||
|
|
|
|||
|
|
yAxisList.forEach(col => {
|
|||
|
|
const label = document.createElement("label");
|
|||
|
|
label.className = "realdata-checkbox-item";
|
|||
|
|
|
|||
|
|
const checkbox = document.createElement("input");
|
|||
|
|
checkbox.type = "checkbox";
|
|||
|
|
checkbox.checked = true;
|
|||
|
|
checkbox.value = col.name;
|
|||
|
|
checkbox.addEventListener("change", e =>
|
|||
|
|
handleCheckboxChange(col.name, e)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const span = document.createElement("span");
|
|||
|
|
span.style.color = col.color;
|
|||
|
|
span.style.fontWeight = "bold";
|
|||
|
|
span.textContent = col.name;
|
|||
|
|
|
|||
|
|
label.appendChild(checkbox);
|
|||
|
|
label.appendChild(span);
|
|||
|
|
container.appendChild(label);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
updateCheckboxCount();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleCheckboxChange(itemName, event) {
|
|||
|
|
const isChecked = event.target.checked;
|
|||
|
|
|
|||
|
|
if (isChecked) {
|
|||
|
|
if (!checkedList.includes(itemName)) {
|
|||
|
|
checkedList.push(itemName);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
if (checkedList.length === 1) {
|
|||
|
|
alert("최소 1개의 데이터를 선택해주세요");
|
|||
|
|
event.target.checked = true;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
checkedList = checkedList.filter(name => name !== itemName);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateCheckboxCount();
|
|||
|
|
renderChart();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function updateCheckboxCount() {
|
|||
|
|
document.getElementById("checkboxCount").textContent =
|
|||
|
|
`(${checkedList.length}/${yAxisList.length})`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleYAxisScroll(value) {
|
|||
|
|
yAxisScrollIndex = parseInt(value);
|
|||
|
|
renderChart();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 줌 기능
|
|||
|
|
function zoomIn() {
|
|||
|
|
currentZoomLevel = Math.min(currentZoomLevel + zoomStep, 3);
|
|||
|
|
applyZoom();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function zoomOut() {
|
|||
|
|
currentZoomLevel = Math.max(currentZoomLevel - zoomStep, 0.5);
|
|||
|
|
applyZoom();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function resetZoom() {
|
|||
|
|
currentZoomLevel = 1;
|
|||
|
|
applyZoom();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function applyZoom() {
|
|||
|
|
if (!chartInstance) return;
|
|||
|
|
const option = chartInstance.getOption();
|
|||
|
|
|
|||
|
|
const xDataZoom = {
|
|||
|
|
type: "inside",
|
|||
|
|
xAxisIndex: 0,
|
|||
|
|
start: 0,
|
|||
|
|
end: 100 / currentZoomLevel,
|
|||
|
|
zoomOnMouseWheel: true,
|
|||
|
|
moveOnMouseMove: true,
|
|||
|
|
moveOnMouseWheel: false,
|
|||
|
|
preventDefaultMouseMove: true,
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
option.dataZoom = [xDataZoom];
|
|||
|
|
chartInstance.setOption(option, false);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 컨텍스트 메뉴
|
|||
|
|
function onContextMenu(e) {
|
|||
|
|
e.preventDefault();
|
|||
|
|
const menu = document.getElementById("contextMenu");
|
|||
|
|
menu.style.display = "block";
|
|||
|
|
menu.style.left = e.clientX + "px";
|
|||
|
|
menu.style.top = e.clientY + "px";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onClickAnywhere() {
|
|||
|
|
document.getElementById("contextMenu").style.display = "none";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function handleContextMenuAction(action) {
|
|||
|
|
switch (action) {
|
|||
|
|
case "zoomIn":
|
|||
|
|
zoomIn();
|
|||
|
|
break;
|
|||
|
|
case "zoomOut":
|
|||
|
|
zoomOut();
|
|||
|
|
break;
|
|||
|
|
case "reset":
|
|||
|
|
resetZoom();
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
document.getElementById("contextMenu").style.display = "none";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 차트 렌더링
|
|||
|
|
function renderChart() {
|
|||
|
|
const chartElement = document.getElementById("growthChart");
|
|||
|
|
if (!chartElement) return;
|
|||
|
|
|
|||
|
|
if (!chartInstance) {
|
|||
|
|
chartInstance = echarts.init(chartElement);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const filteredYAxisList = yAxisList.filter(y =>
|
|||
|
|
checkedList.includes(y.name)
|
|||
|
|
);
|
|||
|
|
const filteredSeriesList = seriesList.filter(s =>
|
|||
|
|
checkedList.includes(s.name)
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const visibleYAxisList = filteredYAxisList.slice(
|
|||
|
|
yAxisScrollIndex,
|
|||
|
|
yAxisScrollIndex + 2
|
|||
|
|
);
|
|||
|
|
|
|||
|
|
const yAxis = filteredYAxisList.map((y, i) => {
|
|||
|
|
const visibleIdx = visibleYAxisList.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 mappedSeriesList = filteredSeriesList
|
|||
|
|
.map((s, idx) => {
|
|||
|
|
const globalIdx = filteredYAxisList.findIndex(
|
|||
|
|
y => y.name === s.name
|
|||
|
|
);
|
|||
|
|
if (globalIdx === -1) return null;
|
|||
|
|
const markLine =
|
|||
|
|
idx === 0
|
|||
|
|
? {
|
|||
|
|
symbol: "none",
|
|||
|
|
label: {
|
|||
|
|
show: true,
|
|||
|
|
position: "insideEndTop",
|
|||
|
|
fontWeight: "bold",
|
|||
|
|
fontSize: 13,
|
|||
|
|
color: "#222",
|
|||
|
|
formatter: function (params) {
|
|||
|
|
return params.data && params.data.name
|
|||
|
|
? params.data.name
|
|||
|
|
: "";
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
lineStyle: { color: "#888", width: 2, type: "solid" },
|
|||
|
|
data: [
|
|||
|
|
{ xAxis: 6, name: "1F" },
|
|||
|
|
{ xAxis: 10, name: "2F" },
|
|||
|
|
{ xAxis: 12, name: "Seed" },
|
|||
|
|
{ xAxis: 22, name: "Main" },
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
: undefined;
|
|||
|
|
return {
|
|||
|
|
...s,
|
|||
|
|
yAxisIndex: globalIdx,
|
|||
|
|
type: "line",
|
|||
|
|
symbol: "none",
|
|||
|
|
sampling: "lttb",
|
|||
|
|
lineStyle: { width: 0.5 },
|
|||
|
|
...(markLine ? { markLine } : {}),
|
|||
|
|
};
|
|||
|
|
})
|
|||
|
|
.filter(Boolean);
|
|||
|
|
|
|||
|
|
const option = {
|
|||
|
|
grid: {
|
|||
|
|
left: 120,
|
|||
|
|
right: 60,
|
|||
|
|
top: 40,
|
|||
|
|
bottom: 120,
|
|||
|
|
containLabel: true,
|
|||
|
|
},
|
|||
|
|
toolbox: {
|
|||
|
|
feature: {
|
|||
|
|
dataZoom: {
|
|||
|
|
yAxisIndex: "none",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
xAxis: [
|
|||
|
|
{
|
|||
|
|
type: "value",
|
|||
|
|
min: 0,
|
|||
|
|
max: 48,
|
|||
|
|
interval: 6,
|
|||
|
|
position: "bottom",
|
|||
|
|
axisLabel: {
|
|||
|
|
fontSize: 12,
|
|||
|
|
color: "#666",
|
|||
|
|
fontWeight: "normal",
|
|||
|
|
},
|
|||
|
|
splitLine: {
|
|||
|
|
show: true,
|
|||
|
|
lineStyle: { color: "#e0e0e0", width: 1 },
|
|||
|
|
},
|
|||
|
|
axisLine: {
|
|||
|
|
show: true,
|
|||
|
|
lineStyle: { color: "#ccc", width: 1 },
|
|||
|
|
},
|
|||
|
|
axisTick: {
|
|||
|
|
show: true,
|
|||
|
|
lineStyle: { color: "#ccc" },
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
yAxis,
|
|||
|
|
series: mappedSeriesList,
|
|||
|
|
legend: { show: false },
|
|||
|
|
animation: false,
|
|||
|
|
dataZoom: [
|
|||
|
|
{
|
|||
|
|
type: "inside",
|
|||
|
|
xAxisIndex: 0,
|
|||
|
|
start: 0,
|
|||
|
|
end: 100,
|
|||
|
|
zoomOnMouseWheel: true,
|
|||
|
|
moveOnMouseMove: true,
|
|||
|
|
moveOnMouseWheel: false,
|
|||
|
|
preventDefaultMouseMove: true,
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
type: "slider",
|
|||
|
|
xAxisIndex: 0,
|
|||
|
|
start: 0,
|
|||
|
|
end: 100,
|
|||
|
|
bottom: 10,
|
|||
|
|
height: 20,
|
|||
|
|
borderColor: "transparent",
|
|||
|
|
backgroundColor: "#f5f5f5",
|
|||
|
|
fillerColor: "rgba(25,118,210,0.2)",
|
|||
|
|
handleStyle: {
|
|||
|
|
color: "#1976d2",
|
|||
|
|
},
|
|||
|
|
textStyle: {
|
|||
|
|
color: "#666",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
],
|
|||
|
|
tooltip: {
|
|||
|
|
trigger: "axis",
|
|||
|
|
position: function (pt) {
|
|||
|
|
return [pt[0], "10%"];
|
|||
|
|
},
|
|||
|
|
axisPointer: {
|
|||
|
|
type: "line",
|
|||
|
|
animation: false,
|
|||
|
|
lineStyle: {
|
|||
|
|
color: "#5470c6",
|
|||
|
|
width: 1,
|
|||
|
|
type: "dashed",
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
backgroundColor: "rgba(255, 255, 255, 0.95)",
|
|||
|
|
borderColor: "#ccc",
|
|||
|
|
borderWidth: 1,
|
|||
|
|
textStyle: {
|
|||
|
|
color: "#333",
|
|||
|
|
fontSize: 13,
|
|||
|
|
fontWeight: "normal",
|
|||
|
|
},
|
|||
|
|
extraCssText:
|
|||
|
|
"box-shadow: 0 2px 8px rgba(0,0,0,0.15); border-radius: 4px;",
|
|||
|
|
formatter: function (params) {
|
|||
|
|
if (!Array.isArray(params)) params = [params];
|
|||
|
|
|
|||
|
|
const baseDate = new Date("2025-07-01T00:00:00");
|
|||
|
|
const hour = Array.isArray(params[0].value)
|
|||
|
|
? params[0].value[0]
|
|||
|
|
: params[0].data[0];
|
|||
|
|
const date = new Date(baseDate.getTime());
|
|||
|
|
date.setHours(baseDate.getHours() + hour);
|
|||
|
|
|
|||
|
|
const yyyy = date.getFullYear();
|
|||
|
|
const mm = (date.getMonth() + 1).toString().padStart(2, "0");
|
|||
|
|
const dd = date.getDate().toString().padStart(2, "0");
|
|||
|
|
const HH = date.getHours().toString().padStart(2, "0");
|
|||
|
|
const dateStr = `${yyyy}.${mm}.${dd} - ${HH}:00`;
|
|||
|
|
|
|||
|
|
let tooltipContent = `<div style="font-weight: bold; color: #5470c6; margin-bottom: 8px; border-bottom: 1px solid #eee; padding-bottom: 4px;">${dateStr}</div>`;
|
|||
|
|
|
|||
|
|
params.forEach(param => {
|
|||
|
|
let unit = "";
|
|||
|
|
if (param.seriesName && yAxisList) {
|
|||
|
|
const y = yAxisList.find(y => y.name === param.seriesName);
|
|||
|
|
if (y && y.unit) unit = " " + y.unit;
|
|||
|
|
}
|
|||
|
|
const value = Array.isArray(param.value)
|
|||
|
|
? param.value[1]
|
|||
|
|
: param.data[1];
|
|||
|
|
|
|||
|
|
tooltipContent += `<div style="display: flex; justify-content: space-between; align-items: center; margin: 4px 0;">
|
|||
|
|
<span style="display: flex; align-items: center;">
|
|||
|
|
<span style="display: inline-block; width: 12px; height: 12px; background-color: ${param.color}; margin-right: 8px; border-radius: 2px;"></span>
|
|||
|
|
<span style="font-weight: 500;">${param.seriesName}${unit}</span>
|
|||
|
|
</span>
|
|||
|
|
<span style="font-weight: bold; color: #333; margin-left: 16px;">${value}</span>
|
|||
|
|
</div>`;
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return tooltipContent;
|
|||
|
|
},
|
|||
|
|
},
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
chartInstance.setOption(option, true);
|
|||
|
|
|
|||
|
|
// 슬라이더 최대값 업데이트
|
|||
|
|
const slider = document.getElementById("yAxisScrollSlider");
|
|||
|
|
if (slider) {
|
|||
|
|
slider.max = Math.max(0, filteredYAxisList.length - 2);
|
|||
|
|
slider.disabled = filteredYAxisList.length <= 2;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 초기화
|
|||
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|||
|
|
createCheckboxes();
|
|||
|
|
renderChart();
|
|||
|
|
|
|||
|
|
// 이벤트 리스너 추가
|
|||
|
|
document
|
|||
|
|
.getElementById("experimentGraphPage")
|
|||
|
|
.addEventListener("contextmenu", onContextMenu);
|
|||
|
|
document.addEventListener("click", onClickAnywhere);
|
|||
|
|
|
|||
|
|
// 윈도우 리사이즈 이벤트
|
|||
|
|
window.addEventListener("resize", function () {
|
|||
|
|
if (chartInstance) {
|
|||
|
|
chartInstance.resize();
|
|||
|
|
}
|
|||
|
|
});
|
|||
|
|
});
|
|||
|
|
</script>
|
|||
|
|
</body>
|
|||
|
|
</html>
|