Spaces:
Running
Running
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>リアルタイムセンサーデータプロット</title> | |
| <style> | |
| body { | |
| font-family: Arial, sans-serif; | |
| text-align: center; | |
| margin: 0; | |
| padding: 0; | |
| } | |
| /* ボタンのスタイル */ | |
| button { | |
| padding: 10px 20px; | |
| font-size: 16px; | |
| margin: 10px; | |
| cursor: pointer; | |
| } | |
| /* モーダルダイアログのスタイル */ | |
| .modal { | |
| display: none; /* 初期状態では非表示 */ | |
| position: fixed; /* 固定位置 */ | |
| z-index: 1; /* 他の要素より前面に表示 */ | |
| left: 0; | |
| top: 0; | |
| width: 100%; /* 幅を全画面に */ | |
| height: 100%; /* 高さを全画面に */ | |
| overflow: auto; /* 必要に応じてスクロール */ | |
| background-color: rgba(0, 0, 0, 0.4); /* 背景を半透明に */ | |
| } | |
| .modal-content { | |
| background-color: #fefefe; | |
| margin: 5% auto; /* 上下にマージン、中央揃え */ | |
| padding: 20px; | |
| border: 1px solid #888; | |
| width: 90%; /* 幅を90%に */ | |
| max-width: 600px; /* 最大幅を600pxに */ | |
| border-radius: 8px; | |
| position: relative; | |
| } | |
| .close { | |
| color: #aaa; | |
| float: right; | |
| font-size: 28px; | |
| font-weight: bold; | |
| position: absolute; | |
| right: 20px; | |
| top: 10px; | |
| cursor: pointer; | |
| } | |
| .close:hover, | |
| .close:focus { | |
| color: black; | |
| text-decoration: none; | |
| } | |
| .regex-container textarea, | |
| .options-container input[type="number"] { | |
| width: 100%; | |
| padding: 8px; | |
| margin: 8px 0; | |
| box-sizing: border-box; | |
| font-size: 16px; | |
| } | |
| .message { | |
| color: red; | |
| font-weight: bold; | |
| margin-top: 10px; | |
| } | |
| .modal-buttons { | |
| text-align: right; | |
| margin-top: 20px; | |
| } | |
| .modal-buttons button { | |
| margin-left: 10px; | |
| } | |
| /* グラフコンテナのスタイル */ | |
| #charts { | |
| display: block; | |
| width: 100%; | |
| box-sizing: border-box; | |
| } | |
| .chart-container { | |
| width: 100%; | |
| height: 30vh; /* ビューポート高さの30% */ | |
| margin: 10px 0; | |
| position: relative; | |
| } | |
| svg { | |
| width: 100%; | |
| height: 100%; | |
| border: 1px solid #ccc; | |
| box-sizing: border-box; | |
| } | |
| /* Rawデータ表示のスタイル */ | |
| #rawDataOutput { | |
| width: 90%; | |
| margin: 20px auto; | |
| text-align: left; | |
| } | |
| #rawDataDisplay { | |
| width: 100%; | |
| max-height: 200px; | |
| overflow: auto; | |
| border: 1px solid #ccc; | |
| padding: 10px; | |
| background-color: #f9f9f9; | |
| white-space: pre-wrap; | |
| } | |
| </style> | |
| <!-- D3.jsの読み込み --> | |
| <script src="https://d3js.org/d3.v7.min.js"></script> | |
| </head> | |
| <body> | |
| <h1>リアルタイムセンサーデータプロット</h1> | |
| <button id="connectButton">シリアルポートに接続</button> | |
| <button id="settingsButton">設定</button> | |
| <div id="charts"> | |
| <div class="chart-container"> | |
| <h3>左センサー</h3> | |
| <svg id="leftChart"></svg> | |
| </div> | |
| <div class="chart-container"> | |
| <h3>中央センサー</h3> | |
| <svg id="centerChart"></svg> | |
| </div> | |
| <div class="chart-container"> | |
| <h3>右センサー</h3> | |
| <svg id="rightChart"></svg> | |
| </div> | |
| </div> | |
| <div id="rawDataOutput"> | |
| <h2>Rawデータ</h2> | |
| <pre id="rawDataDisplay"></pre> | |
| </div> | |
| <!-- モーダルダイアログ --> | |
| <div id="settingsModal" class="modal"> | |
| <div class="modal-content"> | |
| <span class="close">×</span> | |
| <h2>設定</h2> | |
| <div class="regex-container"> | |
| <h3>正規表現設定</h3> | |
| <p>以下の正規表現を使用してシリアルデータを解析します。3つのキャプチャグループ(左、中央、右センサー)を含むように設定してください。</p> | |
| <textarea id="regexInput">Left Sensor:\s*(\d+)\s*\|\s*Center Sensor:\s*(\d+)\s*\|\s*Right Sensor:\s*(\d+)</textarea><br> | |
| <button id="applyRegexButton">適用</button> | |
| <div id="regexMessage" class="message"></div> | |
| </div> | |
| <div class="options-container"> | |
| <h3>表示オプション</h3> | |
| <label> | |
| <input type="checkbox" id="includeZeroCheckbox" checked> | |
| Y軸に0を含める | |
| </label> | |
| <br> | |
| <label> | |
| <input type="checkbox" id="useSplineCheckbox" checked> | |
| スプライン曲線を使用 | |
| </label> | |
| </div> | |
| <div class="options-container"> | |
| <h3>データ保持数</h3> | |
| <input type="number" id="maxPointsInput" value="75" min="10" max="1000"> | |
| <button id="applyMaxPointsButton">適用</button> | |
| <div id="maxPointsMessage" class="message"></div> | |
| </div> | |
| <div class="modal-buttons"> | |
| <button id="closeSettingsButton">閉じる</button> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| // ------------------------------- | |
| // グローバル変数の設定 | |
| // ------------------------------- | |
| let MAX_POINTS = 75; // データ保持数(変更可能に) | |
| const leftData = []; | |
| const centerData = []; | |
| const rightData = []; | |
| const timeData = []; | |
| let currentTime = 0; | |
| let sensorPattern = /Left Sensor:\s*(\d+)\s*\|\s*Center Sensor:\s*(\d+)\s*\|\s*Right Sensor:\s*(\d+)/; | |
| // ------------------------------- | |
| // オプション関連の要素取得 | |
| // ------------------------------- | |
| const includeZeroCheckbox = document.getElementById('includeZeroCheckbox'); | |
| const useSplineCheckbox = document.getElementById('useSplineCheckbox'); | |
| const maxPointsInput = document.getElementById('maxPointsInput'); | |
| const applyMaxPointsButton = document.getElementById('applyMaxPointsButton'); | |
| const maxPointsMessage = document.getElementById('maxPointsMessage'); | |
| // ------------------------------- | |
| // モーダルダイアログの設定 | |
| // ------------------------------- | |
| const settingsModal = document.getElementById('settingsModal'); | |
| const settingsButton = document.getElementById('settingsButton'); | |
| const closeModalSpan = document.querySelector('.modal .close'); | |
| const closeSettingsButton = document.getElementById('closeSettingsButton'); | |
| // モーダルを開く | |
| settingsButton.onclick = function() { | |
| settingsModal.style.display = "block"; | |
| } | |
| // モーダルを閉じる(×ボタン) | |
| closeModalSpan.onclick = function() { | |
| settingsModal.style.display = "none"; | |
| } | |
| // モーダルを閉じる(閉じるボタン) | |
| closeSettingsButton.onclick = function() { | |
| settingsModal.style.display = "none"; | |
| } | |
| // モーダル外をクリックすると閉じる | |
| window.onclick = function(event) { | |
| if (event.target == settingsModal) { | |
| settingsModal.style.display = "none"; | |
| } | |
| } | |
| // ------------------------------- | |
| // オプションのイベントリスナー | |
| // ------------------------------- | |
| includeZeroCheckbox.addEventListener('change', () => { | |
| updateCharts(); | |
| }); | |
| useSplineCheckbox.addEventListener('change', () => { | |
| updateLineCurves(); | |
| updateCharts(); | |
| }); | |
| applyMaxPointsButton.addEventListener('click', () => { | |
| const newMax = parseInt(maxPointsInput.value, 10); | |
| if (isNaN(newMax) || newMax < 10 || newMax > 1000) { | |
| maxPointsMessage.style.color = 'red'; | |
| maxPointsMessage.textContent = '有効な数値を入力してください(10〜1000)。'; | |
| return; | |
| } | |
| // 新しいMAX_POINTSを設定 | |
| MAX_POINTS = newMax; | |
| maxPointsMessage.style.color = 'green'; | |
| maxPointsMessage.textContent = `データ保持数が${newMax}に設定されました。`; | |
| // データ配列を更新 | |
| [leftData, centerData, rightData, timeData].forEach(dataArray => { | |
| while (dataArray.length > MAX_POINTS) { | |
| dataArray.shift(); | |
| } | |
| }); | |
| // スケールを再設定 | |
| for (let key in charts) { | |
| const chart = charts[key]; | |
| chart.xScale.domain([0, MAX_POINTS - 1]); | |
| chart.svg.select(".x-axis") | |
| .transition() | |
| .duration(500) | |
| .call(d3.axisBottom(chart.xScale)); | |
| } | |
| updateCharts(); | |
| }); | |
| // ------------------------------- | |
| // スプライン曲線の更新関数 | |
| // ------------------------------- | |
| function updateLineCurves() { | |
| const curveType = useSplineCheckbox.checked ? d3.curveBasis : d3.curveLinear; | |
| for (let key in charts) { | |
| const chart = charts[key]; | |
| chart.line.curve(curveType); | |
| // パスを再設定 | |
| chart.path | |
| .datum(chart.data) | |
| .attr("d", chart.line); | |
| } | |
| } | |
| // ------------------------------- | |
| // D3.jsによるグラフの初期設定 | |
| // ------------------------------- | |
| const charts = { | |
| left: { | |
| svg: d3.select("#leftChart"), | |
| data: leftData, | |
| color: "red", | |
| path: null, | |
| xScale: null, | |
| yScale: null, | |
| line: null | |
| }, | |
| center: { | |
| svg: d3.select("#centerChart"), | |
| data: centerData, | |
| color: "green", | |
| path: null, | |
| xScale: null, | |
| yScale: null, | |
| line: null | |
| }, | |
| right: { | |
| svg: d3.select("#rightChart"), | |
| data: rightData, | |
| color: "blue", | |
| path: null, | |
| xScale: null, | |
| yScale: null, | |
| line: null | |
| } | |
| }; | |
| function initCharts() { | |
| for (let key in charts) { | |
| const chart = charts[key]; | |
| const svgElement = chart.svg.node(); | |
| const boundingRect = svgElement.getBoundingClientRect(); | |
| const width = boundingRect.width; | |
| const height = boundingRect.height; | |
| // スケールの設定 | |
| chart.xScale = d3.scaleLinear().domain([0, MAX_POINTS - 1]).range([50, width - 50]); | |
| chart.yScale = d3.scaleLinear().domain([0, 1023]).range([height - 30, 20]); // マージンを考慮 | |
| // 軸の追加 | |
| chart.svg.append("g") | |
| .attr("transform", `translate(0,${height - 30})`) | |
| .attr("class", "x-axis") | |
| .call(d3.axisBottom(chart.xScale)); | |
| chart.svg.append("g") | |
| .attr("transform", `translate(50,0)`) | |
| .attr("class", "y-axis") | |
| .call(d3.axisLeft(chart.yScale)); | |
| // パスの生成 | |
| chart.line = d3.line() | |
| .x((d, i) => chart.xScale(i)) | |
| .y(d => chart.yScale(d)) | |
| .curve(useSplineCheckbox.checked ? d3.curveBasis : d3.curveLinear); // 初期曲線タイプ | |
| chart.path = chart.svg.append("path") | |
| .datum(chart.data) | |
| .attr("fill", "none") | |
| .attr("stroke", chart.color) | |
| .attr("stroke-width", 2) | |
| .attr("d", chart.line); | |
| } | |
| // リサイズイベントの設定 | |
| window.addEventListener('resize', () => { | |
| resizeCharts(); | |
| }); | |
| } | |
| initCharts(); | |
| // ------------------------------- | |
| // シリアルポートへの接続とデータ読み取り | |
| // ------------------------------- | |
| const connectButton = document.getElementById('connectButton'); | |
| connectButton.addEventListener('click', async () => { | |
| try { | |
| // シリアルポートのリクエスト | |
| const port = await navigator.serial.requestPort(); | |
| // シリアルポートを開く | |
| await port.open({ baudRate: 9600 }); | |
| // テキストストリームの作成 | |
| const textDecoder = new TextDecoderStream(); | |
| const readableStreamClosed = port.readable.pipeTo(textDecoder.writable); | |
| const reader = textDecoder.readable.getReader(); | |
| // データの読み取り | |
| while (true) { | |
| const { value, done } = await reader.read(); | |
| if (done) { | |
| // 読み取りストリームが終了した場合 | |
| break; | |
| } | |
| if (value) { | |
| // 改行でデータを分割 | |
| const lines = value.split('\n'); | |
| lines.forEach(line => processData(line.trim())); | |
| } | |
| } | |
| } catch (error) { | |
| console.error('シリアルポート接続エラー:', error); | |
| alert('シリアルポートの接続に失敗しました。コンソールを確認してください。'); | |
| } | |
| }); | |
| // ------------------------------- | |
| // 正規表現の適用 | |
| // ------------------------------- | |
| const applyRegexButton = document.getElementById('applyRegexButton'); | |
| const regexInput = document.getElementById('regexInput'); | |
| const regexMessage = document.getElementById('regexMessage'); | |
| applyRegexButton.addEventListener('click', () => { | |
| const userRegex = regexInput.value.trim(); | |
| if (!userRegex) { | |
| regexMessage.textContent = '正規表現を入力してください。'; | |
| regexMessage.style.color = 'red'; | |
| return; | |
| } | |
| try { | |
| // 新しい正規表現をコンパイル | |
| const newPattern = new RegExp(userRegex); | |
| // テストデータで正規表現を確認(3つのキャプチャグループがあるか) | |
| const testMatch = newPattern.exec('Left Sensor: 123 | Center Sensor: 456 | Right Sensor: 789'); | |
| if (!testMatch || testMatch.length < 4) { | |
| throw new Error('正規表現には3つのキャプチャグループが必要です。'); | |
| } | |
| // 正規表現を更新 | |
| sensorPattern = newPattern; | |
| regexMessage.style.color = 'green'; | |
| regexMessage.textContent = '正規表現が正常に適用されました。'; | |
| } catch (e) { | |
| console.error('正規表現エラー:', e); | |
| regexMessage.style.color = 'red'; | |
| regexMessage.textContent = `正規表現エラー: ${e.message}`; | |
| } | |
| }); | |
| // ------------------------------- | |
| // データの処理関数 | |
| // ------------------------------- | |
| let lastLeft = null; | |
| let lastCenter = null; | |
| let lastRight = null; | |
| function processData(line) { | |
| if (!line) return; // 空行は無視 | |
| const match = sensorPattern.exec(line); | |
| if (match) { | |
| if (match.length < 4) { | |
| console.warn('正規表現が期待するキャプチャグループを含んでいません。'); | |
| return; | |
| } | |
| const left = parseInt(match[1], 10); | |
| const center = parseInt(match[2], 10); | |
| const right = parseInt(match[3], 10); | |
| // データが一定かどうかをチェック | |
| if (left === lastLeft && center === lastCenter && right === lastRight) { | |
| console.log(`データが一定です。Rawデータ: Left=${left}, Center=${center}, Right=${right}`); | |
| appendRawData(left, center, right); | |
| // 一定データの場合、グラフの更新をスキップ | |
| return; | |
| } | |
| // データの追加 | |
| leftData.push(left); | |
| centerData.push(center); | |
| rightData.push(right); | |
| timeData.push(currentTime); | |
| currentTime += 1; | |
| // データの保持数を制限 | |
| if (leftData.length > MAX_POINTS) { | |
| leftData.shift(); | |
| centerData.shift(); | |
| rightData.shift(); | |
| timeData.shift(); | |
| } | |
| // グラフの更新 | |
| updateCharts(); | |
| // 最後のデータを更新 | |
| lastLeft = left; | |
| lastCenter = center; | |
| lastRight = right; | |
| } else { | |
| console.warn('不正なデータ形式:', line); | |
| } | |
| } | |
| // ------------------------------- | |
| // Rawデータの表示関数 | |
| // ------------------------------- | |
| function appendRawData(left, center, right) { | |
| const rawDataDisplay = document.getElementById('rawDataDisplay'); | |
| const rawData = `Left=${left}, Center=${center}, Right=${right}\n`; | |
| rawDataDisplay.textContent += rawData; | |
| // スクロールを最新にする | |
| rawDataDisplay.scrollTop = rawDataDisplay.scrollHeight; | |
| } | |
| // ------------------------------- | |
| // グラフの更新関数 | |
| // ------------------------------- | |
| function updateCharts() { | |
| for (let key in charts) { | |
| const chart = charts[key]; | |
| const svgElement = chart.svg.node(); | |
| const boundingRect = svgElement.getBoundingClientRect(); | |
| const width = boundingRect.width; | |
| const height = boundingRect.height; | |
| // スケールの再設定 | |
| chart.xScale.range([50, width - 50]); | |
| chart.yScale.range([height - 30, 20]); // マージンを考慮 | |
| // Yスケールの更新 | |
| let yMin, yMax; | |
| if (includeZeroCheckbox.checked) { | |
| yMin = d3.min(chart.data.concat([0])) || 0; | |
| yMax = d3.max(chart.data.concat([0])) || 1023; | |
| } else { | |
| yMin = d3.min(chart.data) || 0; | |
| yMax = d3.max(chart.data) || 1023; | |
| } | |
| chart.yScale.domain([Math.min(yMin, 0), Math.max(yMax, 0)]); | |
| // Y軸の再描画 | |
| chart.svg.select(".y-axis") | |
| .transition() | |
| .duration(500) | |
| .call(d3.axisLeft(chart.yScale)); | |
| // パスの更新 | |
| chart.path | |
| .datum(chart.data) | |
| .transition() | |
| .duration(500) | |
| .attr("d", chart.line); | |
| } | |
| } | |
| // ------------------------------- | |
| // グラフのリサイズ関数 | |
| // ------------------------------- | |
| function resizeCharts() { | |
| for (let key in charts) { | |
| const chart = charts[key]; | |
| const svgElement = chart.svg.node(); | |
| const boundingRect = svgElement.getBoundingClientRect(); | |
| const width = boundingRect.width; | |
| const height = boundingRect.height; | |
| // スケールの更新 | |
| chart.xScale.range([50, width - 50]); | |
| chart.yScale.range([height - 30, 20]); // マージンを考慮 | |
| // Yスケールの更新 | |
| let yMin, yMax; | |
| if (includeZeroCheckbox.checked) { | |
| yMin = d3.min(chart.data.concat([0])) || 0; | |
| yMax = d3.max(chart.data.concat([0])) || 1023; | |
| } else { | |
| yMin = d3.min(chart.data) || 0; | |
| yMax = d3.max(chart.data) || 1023; | |
| } | |
| chart.yScale.domain([Math.min(yMin, 0), Math.max(yMax, 0)]); | |
| // Y軸の再描画 | |
| chart.svg.select(".y-axis") | |
| .transition() | |
| .duration(500) | |
| .call(d3.axisLeft(chart.yScale)); | |
| // パスの更新 | |
| chart.path | |
| .datum(chart.data) | |
| .transition() | |
| .duration(500) | |
| .attr("d", chart.line); | |
| } | |
| } | |
| </script> | |
| </body> | |
| </html> | |