Coverage for src / rasp_shutter / metrics / webapi / page.py: 81%
197 statements
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-13 00:10 +0900
« prev ^ index » next coverage.py v7.13.1, created at 2026-02-13 00:10 +0900
1#!/usr/bin/env python3
2"""
3シャッターメトリクス表示ページ
5シャッター操作の統計情報とグラフを表示するWebページを提供します。
6"""
8from __future__ import annotations
10import datetime
11import io
12import json
13import logging
15import flask
16import my_lib.webapp.config
17from PIL import Image, ImageDraw
19import rasp_shutter.metrics.collector
21blueprint = flask.Blueprint("metrics", __name__, url_prefix=my_lib.webapp.config.URL_PREFIX)
24@blueprint.route("/api/metrics", methods=["GET"])
25def metrics_view():
26 """メトリクスダッシュボードページを表示"""
27 try:
28 # 設定からメトリクスデータパスを取得
29 config = flask.current_app.config["CONFIG"]
30 db_path = config.metrics.data
31 if not db_path.exists(): 31 ↛ 32line 31 didn't jump to line 32 because the condition on line 31 was never true
32 return flask.Response(
33 f"<html><body><h1>メトリクスデータベースが見つかりません</h1>"
34 f"<p>データベースファイル: {db_path}</p>"
35 f"<p>システムが十分に動作してからメトリクスが生成されます。</p></body></html>",
36 mimetype="text/html",
37 status=503,
38 )
40 # メトリクス収集器を取得
41 collector = rasp_shutter.metrics.collector.get_collector(db_path)
43 # 全期間のデータを取得
44 operation_metrics = collector.get_all_operation_metrics()
45 failure_metrics = collector.get_all_failure_metrics()
47 # 統計データを生成
48 stats = generate_statistics(operation_metrics, failure_metrics)
50 # データ期間を計算
51 data_period = calculate_data_period(operation_metrics)
53 # HTMLを生成
54 html_content = generate_metrics_html(stats, operation_metrics, data_period)
56 return flask.Response(html_content, mimetype="text/html")
58 except Exception as e:
59 logging.exception("メトリクス表示の生成エラー")
60 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500)
63@blueprint.route("/favicon.ico", methods=["GET"])
64def favicon():
65 """動的生成されたシャッターメトリクス用favicon.icoを返す"""
66 try:
67 # シャッターメトリクスアイコンを生成
68 img = generate_shutter_metrics_icon()
70 # ICO形式で出力
71 output = io.BytesIO()
72 img.save(output, format="ICO", sizes=[(32, 32)])
73 output.seek(0)
75 return flask.Response(
76 output.getvalue(),
77 mimetype="image/x-icon",
78 headers={
79 "Cache-Control": "public, max-age=3600", # 1時間キャッシュ
80 "Content-Type": "image/x-icon",
81 },
82 )
83 except Exception:
84 logging.exception("favicon生成エラー")
85 return flask.Response("", status=500)
88def generate_shutter_metrics_icon():
89 """シャッターメトリクス用のアイコンを動的生成(アンチエイリアス対応)"""
90 # アンチエイリアスのため4倍サイズで描画してから縮小
91 scale = 4
92 size = 32
93 large_size = size * scale
95 # 大きなサイズで描画
96 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
97 draw = ImageDraw.Draw(img)
99 # 背景円(メトリクスらしい青色)
100 margin = 2 * scale
101 draw.ellipse(
102 [margin, margin, large_size - margin, large_size - margin],
103 fill=(52, 152, 219, 255),
104 outline=(41, 128, 185, 255),
105 width=2 * scale,
106 )
108 # グラフっぽい線を描画(座標を4倍に拡大)
109 points = [
110 (8 * scale, 20 * scale),
111 (12 * scale, 16 * scale),
112 (16 * scale, 12 * scale),
113 (20 * scale, 14 * scale),
114 (24 * scale, 10 * scale),
115 ]
117 # 折れ線グラフ
118 for i in range(len(points) - 1):
119 draw.line([points[i], points[i + 1]], fill=(255, 255, 255, 255), width=2 * scale)
121 # データポイント
122 point_size = 1 * scale
123 for point in points:
124 draw.ellipse(
125 [point[0] - point_size, point[1] - point_size, point[0] + point_size, point[1] + point_size],
126 fill=(255, 255, 255, 255),
127 )
129 # 32x32に縮小してアンチエイリアス効果を得る
130 return img.resize((size, size), Image.Resampling.LANCZOS)
133def calculate_data_period(operation_metrics: list[dict]) -> dict:
134 """データ期間を計算"""
135 if not operation_metrics:
136 return {"total_days": 0, "start_date": None, "end_date": None, "display_text": "データなし"}
138 # 日付のみを抽出(str型のみをフィルタリング)
139 dates: list[str] = [
140 str(date) for op in operation_metrics if (date := op.get("date")) and isinstance(date, str)
141 ]
143 if not dates: 143 ↛ 144line 143 didn't jump to line 144 because the condition on line 143 was never true
144 return {"total_days": 0, "start_date": None, "end_date": None, "display_text": "データなし"}
146 # 最古と最新の日付を取得
147 start_date: str = min(dates)
148 end_date: str = max(dates)
150 # 日数を計算
151 start_dt = datetime.datetime.fromisoformat(start_date)
152 end_dt = datetime.datetime.fromisoformat(end_date)
153 total_days = (end_dt - start_dt).days + 1
155 # 表示テキストを生成
156 if total_days == 1:
157 display_text = f"過去1日間({start_date.replace('-', '年', 1).replace('-', '月', 1)}日)"
158 else:
159 start_display = start_date.replace("-", "年", 1).replace("-", "月", 1) + "日"
160 display_text = f"過去{total_days}日間({start_display}〜)"
162 return {
163 "total_days": total_days,
164 "start_date": start_date,
165 "end_date": end_date,
166 "display_text": display_text,
167 }
170def _extract_time_data(day_data: dict, key: str) -> float | None:
171 """時刻データを抽出して時間形式に変換"""
172 if not day_data.get(key): 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 return None
174 try:
175 dt = datetime.datetime.fromisoformat(day_data[key].replace("Z", "+00:00"))
176 return dt.hour + dt.minute / 60.0
177 except (ValueError, TypeError):
178 return None
181def _collect_sensor_data_by_type(operation_metrics: list[dict], operation_type: str) -> dict:
182 """操作タイプ別にセンサーデータを収集"""
183 sensor_data: dict[str, list[float]] = {
184 "open_lux": [],
185 "close_lux": [],
186 "open_solar_rad": [],
187 "close_solar_rad": [],
188 "open_altitude": [],
189 "close_altitude": [],
190 }
192 for op_data in operation_metrics:
193 if op_data.get("operation_type") == operation_type:
194 action = op_data.get("action")
195 if action in ["open", "close"]: 195 ↛ 192line 195 didn't jump to line 192 because the condition on line 195 was always true
196 for sensor_type in ["lux", "solar_rad", "altitude"]:
197 if op_data.get(sensor_type) is not None:
198 sensor_data[f"{action}_{sensor_type}"].append(op_data[sensor_type])
200 return sensor_data
203def generate_statistics(operation_metrics: list[dict], failure_metrics: list[dict]) -> dict:
204 """メトリクスデータから統計情報を生成"""
205 if not operation_metrics:
206 return {
207 "total_days": 0,
208 "open_times": [],
209 "close_times": [],
210 "auto_sensor_data": {
211 "open_lux": [],
212 "close_lux": [],
213 "open_solar_rad": [],
214 "close_solar_rad": [],
215 "open_altitude": [],
216 "close_altitude": [],
217 },
218 "manual_sensor_data": {
219 "open_lux": [],
220 "close_lux": [],
221 "open_solar_rad": [],
222 "close_solar_rad": [],
223 "open_altitude": [],
224 "close_altitude": [],
225 },
226 "manual_open_total": 0,
227 "manual_close_total": 0,
228 "auto_open_total": 0,
229 "auto_close_total": 0,
230 "failure_total": len(failure_metrics),
231 }
233 # 日付ごとの最後の操作時刻を取得(時刻分析用)
234 daily_last_operations = {}
235 for op_data in operation_metrics:
236 date = op_data.get("date")
237 action = op_data.get("action")
238 timestamp = op_data.get("timestamp")
240 if date and action and timestamp: 240 ↛ 235line 240 didn't jump to line 235 because the condition on line 240 was always true
241 key = f"{date}_{action}"
242 # より新しい時刻で上書き(最後の操作時刻を保持)
243 daily_last_operations[key] = timestamp
245 # 時刻データを収集(最後の操作時刻のみ)
246 open_times = []
247 close_times = []
249 for key, timestamp in daily_last_operations.items():
250 if (
251 key.endswith("_open")
252 and (t := _extract_time_data({"timestamp": timestamp}, "timestamp")) is not None
253 ):
254 open_times.append(t)
255 elif ( 255 ↛ 249line 255 didn't jump to line 249 because the condition on line 255 was always true
256 key.endswith("_close")
257 and (t := _extract_time_data({"timestamp": timestamp}, "timestamp")) is not None
258 ):
259 close_times.append(t)
261 # センサーデータを操作タイプ別に収集(autoとscheduleを統合)
262 auto_sensor_data = _collect_sensor_data_by_type(operation_metrics, "auto")
263 schedule_sensor_data = _collect_sensor_data_by_type(operation_metrics, "schedule")
264 for key in auto_sensor_data:
265 auto_sensor_data[key].extend(schedule_sensor_data[key])
266 manual_sensor_data = _collect_sensor_data_by_type(operation_metrics, "manual")
268 # カウント系データを集計(1回のループで全統計を計算)
269 manual_open_total = 0
270 manual_close_total = 0
271 auto_open_total = 0
272 auto_close_total = 0
273 for op in operation_metrics:
274 op_type = op.get("operation_type")
275 action = op.get("action")
276 if op_type == "manual":
277 if action == "open":
278 manual_open_total += 1
279 elif action == "close": 279 ↛ 273line 279 didn't jump to line 273 because the condition on line 279 was always true
280 manual_close_total += 1
281 elif op_type in ["auto", "schedule"]: 281 ↛ 273line 281 didn't jump to line 273 because the condition on line 281 was always true
282 if action == "open":
283 auto_open_total += 1
284 elif action == "close": 284 ↛ 273line 284 didn't jump to line 273 because the condition on line 284 was always true
285 auto_close_total += 1
287 # 日数を計算
288 unique_dates = {op.get("date") for op in operation_metrics if op.get("date")}
290 return {
291 "total_days": len(unique_dates),
292 "open_times": open_times,
293 "close_times": close_times,
294 "auto_sensor_data": auto_sensor_data,
295 "manual_sensor_data": manual_sensor_data,
296 "manual_open_total": manual_open_total,
297 "manual_close_total": manual_close_total,
298 "auto_open_total": auto_open_total,
299 "auto_close_total": auto_close_total,
300 "failure_total": len(failure_metrics),
301 }
304def generate_metrics_html(stats: dict, operation_metrics: list[dict], data_period: dict) -> str:
305 """Tailwind CSSを使用したメトリクスHTMLを生成"""
306 # JavaScript用データを準備
307 chart_data = {
308 "open_times": stats["open_times"],
309 "close_times": stats["close_times"],
310 "auto_sensor_data": stats["auto_sensor_data"],
311 "manual_sensor_data": stats["manual_sensor_data"],
312 "time_series": prepare_time_series_data(operation_metrics),
313 }
315 chart_data_json = json.dumps(chart_data)
317 # URL_PREFIXを取得してfaviconパスを構築
318 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico"
320 return f"""
321<!DOCTYPE html>
322<html lang="ja">
323<head>
324 <meta charset="utf-8">
325 <meta name="viewport" content="width=device-width, initial-scale=1">
326 <title>シャッター メトリクス ダッシュボード</title>
327 <link rel="icon" type="image/x-icon" href="{favicon_path}">
328 <script src="https://cdn.tailwindcss.com"></script>
329 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
330 <script src="https://cdn.jsdelivr.net/npm/@sgratzl/chartjs-chart-boxplot"></script>
331 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
332 <style>
333 .chart-container {{ position: relative; height: 350px; margin: 0.5rem 0; }}
334 @media (max-width: 768px) {{
335 .chart-container {{ height: 300px; margin: 0.25rem 0; }}
336 }}
337 .permalink-header {{ position: relative; display: inline-block; }}
338 .permalink-icon {{
339 opacity: 0;
340 transition: opacity 0.2s;
341 cursor: pointer;
342 margin-left: 0.5rem;
343 color: #3b82f6;
344 font-size: 0.875rem;
345 }}
346 .permalink-icon:hover {{ color: #1d4ed8; }}
347 .permalink-header:hover .permalink-icon {{ opacity: 1; }}
348 </style>
349</head>
350<body class="bg-gray-50 font-sans">
351 <div class="container mx-auto px-2 sm:px-4 py-4">
352 <div class="text-center mb-8">
353 <h1 class="text-2xl sm:text-3xl font-bold text-gray-800 mb-2">
354 <i class="fas fa-chart-line mr-2 text-blue-600"></i>
355 シャッター メトリクス ダッシュボード
356 </h1>
357 <p class="text-gray-600">{data_period["display_text"]}のシャッター操作統計</p>
358 </div>
360 <!-- 基本統計 -->
361 {generate_basic_stats_section(stats)}
363 <!-- 時刻分析 -->
364 {generate_time_analysis_section()}
366 <!-- 時系列データ分析 -->
367 {generate_time_series_section()}
369 <!-- センサーデータ分析 -->
370 {generate_sensor_analysis_section()}
371 </div>
373 <script>
374 const chartData = {chart_data_json};
376 // チャート生成
377 generateTimeCharts();
378 generateTimeSeriesCharts();
379 generateAutoSensorCharts();
380 generateManualSensorCharts();
382 // パーマリンク機能を初期化
383 initializePermalinks();
385 {generate_chart_javascript()}
386 </script>
387</html>
388 """
391def _extract_daily_last_operations(operation_metrics: list[dict]) -> dict:
392 """日付ごとの最後の操作時刻とセンサーデータを取得"""
393 daily_last_operations: dict[str, dict] = {}
395 for op_data in operation_metrics:
396 date = op_data.get("date")
397 action = op_data.get("action")
398 timestamp = op_data.get("timestamp")
400 if date and action and timestamp: 400 ↛ 395line 400 didn't jump to line 395 because the condition on line 400 was always true
401 key = f"{date}_{action}"
402 # より新しい時刻で上書き
403 if key not in daily_last_operations or timestamp > daily_last_operations[key]["timestamp"]: 403 ↛ 395line 403 didn't jump to line 395 because the condition on line 403 was always true
404 daily_last_operations[key] = {
405 "timestamp": timestamp,
406 "lux": op_data.get("lux"),
407 "solar_rad": op_data.get("solar_rad"),
408 "altitude": op_data.get("altitude"),
409 }
411 return daily_last_operations
414def _extract_daily_data(date: str, action: str, daily_last_operations: dict) -> tuple[float | None, ...]:
415 """指定した日付と操作の時刻とセンサーデータを抽出"""
416 key = f"{date}_{action}"
417 time_val = None
418 lux_val = None
419 solar_rad_val = None
420 altitude_val = None
422 if key in daily_last_operations: 422 ↛ 434line 422 didn't jump to line 434 because the condition on line 422 was always true
423 try:
424 dt = datetime.datetime.fromisoformat(
425 daily_last_operations[key]["timestamp"].replace("Z", "+00:00")
426 )
427 time_val = dt.hour + dt.minute / 60.0
428 lux_val = daily_last_operations[key]["lux"]
429 solar_rad_val = daily_last_operations[key]["solar_rad"]
430 altitude_val = daily_last_operations[key]["altitude"]
431 except (ValueError, TypeError):
432 pass
434 return time_val, lux_val, solar_rad_val, altitude_val
437def prepare_time_series_data(operation_metrics: list[dict]) -> dict:
438 """時系列データを準備"""
439 daily_last_operations = _extract_daily_last_operations(operation_metrics)
441 # 日付リストを生成
442 date_set: set[str] = {op["date"] for op in operation_metrics if op.get("date")}
443 unique_dates = sorted(date_set)
445 dates = []
446 open_times = []
447 close_times = []
448 open_lux = []
449 close_lux = []
450 open_solar_rad = []
451 close_solar_rad = []
452 open_altitude = []
453 close_altitude = []
455 for date in unique_dates:
456 dates.append(date)
458 # その日の最後の開操作時刻とセンサーデータ
459 open_time, open_lux_val, open_solar_rad_val, open_altitude_val = _extract_daily_data(
460 date, "open", daily_last_operations
461 )
463 # その日の最後の閉操作時刻とセンサーデータ
464 close_time, close_lux_val, close_solar_rad_val, close_altitude_val = _extract_daily_data(
465 date, "close", daily_last_operations
466 )
468 open_times.append(open_time)
469 close_times.append(close_time)
470 open_lux.append(open_lux_val)
471 close_lux.append(close_lux_val)
472 open_solar_rad.append(open_solar_rad_val)
473 close_solar_rad.append(close_solar_rad_val)
474 open_altitude.append(open_altitude_val)
475 close_altitude.append(close_altitude_val)
477 return {
478 "dates": dates,
479 "open_times": open_times,
480 "close_times": close_times,
481 "open_lux": open_lux,
482 "close_lux": close_lux,
483 "open_solar_rad": open_solar_rad,
484 "close_solar_rad": close_solar_rad,
485 "open_altitude": open_altitude,
486 "close_altitude": close_altitude,
487 }
490def generate_basic_stats_section(stats: dict) -> str:
491 """基本統計セクションのHTML生成"""
492 return f"""
493 <div class="mb-8">
494 <h2 class="text-xl font-bold text-gray-800 mb-4 permalink-header" id="basic-stats">
495 <i class="fas fa-chart-bar mr-2 text-blue-600"></i>
496 基本統計
497 <span class="permalink-icon " onclick="copyPermalink('basic-stats')">
498 <i class="fas fa-link text-sm"></i>
499 </span>
500 </h2>
502 <div class="bg-white rounded-lg shadow">
503 <div class="border-b px-4 py-3">
504 <p class="font-semibold text-gray-700">操作回数</p>
505 </div>
506 <div class="p-4">
507 <div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-4">
508 <div class="text-center">
509 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">👆 手動開操作 ☀️</p>
510 <p class="text-2xl font-bold text-green-500">{stats["manual_open_total"]:,}</p>
511 </div>
512 <div class="text-center">
513 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">👆 手動閉操作 🌙</p>
514 <p class="text-2xl font-bold text-blue-500">{stats["manual_close_total"]:,}</p>
515 </div>
516 <div class="text-center">
517 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">🤖 自動開操作 ☀️</p>
518 <p class="text-2xl font-bold text-green-500">{stats["auto_open_total"]:,}</p>
519 </div>
520 <div class="text-center">
521 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">🤖 自動閉操作 🌙</p>
522 <p class="text-2xl font-bold text-blue-500">{stats["auto_close_total"]:,}</p>
523 </div>
524 <div class="text-center">
525 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">制御失敗</p>
526 <p class="text-2xl font-bold text-red-500">{stats["failure_total"]:,}</p>
527 </div>
528 <div class="text-center">
529 <p class="text-xs uppercase tracking-wide text-gray-500 mb-1">データ収集日数</p>
530 <p class="text-2xl font-bold text-indigo-500">{stats["total_days"]:,}</p>
531 </div>
532 </div>
533 </div>
534 </div>
535 </div>
536 """
539def generate_time_analysis_section() -> str:
540 """時刻分析セクションのHTML生成"""
541 return """
542 <div class="mb-8">
543 <h2 class="text-xl font-bold text-gray-800 mb-4 permalink-header" id="time-analysis">
544 <i class="fas fa-clock mr-2 text-blue-600"></i>
545 時刻分析
546 <span class="permalink-icon " onclick="copyPermalink('time-analysis')">
547 <i class="fas fa-link text-sm"></i>
548 </span>
549 </h2>
551 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
552 <div class="bg-white rounded-lg shadow">
553 <div class="border-b px-4 py-3 permalink-header" id="open-time-histogram">
554 <p class="font-semibold text-gray-700">
555 ☀️ 開操作時刻の頻度分布
556 <span class="permalink-icon " onclick="copyPermalink('open-time-histogram')">
557 <i class="fas fa-link text-sm"></i>
558 </span>
559 </p>
560 </div>
561 <div class="p-4">
562 <div class="chart-container">
563 <canvas id="openTimeHistogramChart"></canvas>
564 </div>
565 </div>
566 </div>
567 <div class="bg-white rounded-lg shadow">
568 <div class="border-b px-4 py-3 permalink-header" id="close-time-histogram">
569 <p class="font-semibold text-gray-700">
570 🌙 閉操作時刻の頻度分布
571 <span class="permalink-icon " onclick="copyPermalink('close-time-histogram')">
572 <i class="fas fa-link text-sm"></i>
573 </span>
574 </p>
575 </div>
576 <div class="p-4">
577 <div class="chart-container">
578 <canvas id="closeTimeHistogramChart"></canvas>
579 </div>
580 </div>
581 </div>
582 </div>
583 </div>
584 """
587def generate_time_series_section() -> str:
588 """時系列データ分析セクションのHTML生成"""
589 return """
590 <div class="mb-8">
591 <h2 class="text-xl font-bold text-gray-800 mb-4 permalink-header" id="time-series">
592 <i class="fas fa-chart-line mr-2 text-blue-600"></i>
593 時系列データ分析
594 <span class="permalink-icon " onclick="copyPermalink('time-series')">
595 <i class="fas fa-link text-sm"></i>
596 </span>
597 </h2>
599 <div class="space-y-4">
600 <div class="bg-white rounded-lg shadow">
601 <div class="border-b px-4 py-3 permalink-header" id="time-series-chart">
602 <p class="font-semibold text-gray-700">
603 🕐 操作時刻の時系列遷移
604 <span class="permalink-icon " onclick="copyPermalink('time-series-chart')">
605 <i class="fas fa-link text-sm"></i>
606 </span>
607 </p>
608 </div>
609 <div class="p-4">
610 <div class="chart-container">
611 <canvas id="timeSeriesChart"></canvas>
612 </div>
613 </div>
614 </div>
616 <div class="bg-white rounded-lg shadow">
617 <div class="border-b px-4 py-3 permalink-header" id="lux-time-series">
618 <p class="font-semibold text-gray-700">
619 💡 照度データの時系列遷移
620 <span class="permalink-icon " onclick="copyPermalink('lux-time-series')">
621 <i class="fas fa-link text-sm"></i>
622 </span>
623 </p>
624 </div>
625 <div class="p-4">
626 <div class="chart-container">
627 <canvas id="luxTimeSeriesChart"></canvas>
628 </div>
629 </div>
630 </div>
632 <div class="bg-white rounded-lg shadow">
633 <div class="border-b px-4 py-3 permalink-header" id="solar-rad-time-series">
634 <p class="font-semibold text-gray-700">
635 ☀️ 日射データの時系列遷移
636 <span class="permalink-icon " onclick="copyPermalink('solar-rad-time-series')">
637 <i class="fas fa-link text-sm"></i>
638 </span>
639 </p>
640 </div>
641 <div class="p-4">
642 <div class="chart-container">
643 <canvas id="solarRadTimeSeriesChart"></canvas>
644 </div>
645 </div>
646 </div>
648 <div class="bg-white rounded-lg shadow">
649 <div class="border-b px-4 py-3 permalink-header" id="altitude-time-series">
650 <p class="font-semibold text-gray-700">
651 📐 太陽高度の時系列遷移
652 <span class="permalink-icon " onclick="copyPermalink('altitude-time-series')">
653 <i class="fas fa-link text-sm"></i>
654 </span>
655 </p>
656 </div>
657 <div class="p-4">
658 <div class="chart-container">
659 <canvas id="altitudeTimeSeriesChart"></canvas>
660 </div>
661 </div>
662 </div>
663 </div>
664 </div>
665 """
668def generate_sensor_analysis_section() -> str:
669 """センサーデータ分析セクションのHTML生成"""
670 return """
671 <div class="mb-8">
672 <h2 class="text-xl font-bold text-gray-800 mb-4 permalink-header" id="auto-sensor-analysis">
673 <i class="fas fa-robot mr-2 text-blue-600"></i>
674 センサーデータ分析(自動操作)
675 <span class="permalink-icon " onclick="copyPermalink('auto-sensor-analysis')">
676 <i class="fas fa-link text-sm"></i>
677 </span>
678 </h2>
680 <!-- 照度データ -->
681 <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
682 <div class="bg-white rounded-lg shadow">
683 <div class="border-b px-4 py-3 permalink-header" id="auto-open-lux">
684 <p class="font-semibold text-gray-700">
685 🤖 自動開操作時の照度データ ☀️
686 <span class="permalink-icon " onclick="copyPermalink('auto-open-lux')">
687 <i class="fas fa-link text-sm"></i>
688 </span>
689 </p>
690 </div>
691 <div class="p-4">
692 <div class="chart-container">
693 <canvas id="autoOpenLuxChart"></canvas>
694 </div>
695 </div>
696 </div>
697 <div class="bg-white rounded-lg shadow">
698 <div class="border-b px-4 py-3 permalink-header" id="auto-close-lux">
699 <p class="font-semibold text-gray-700">
700 🤖 自動閉操作時の照度データ 🌙
701 <span class="permalink-icon " onclick="copyPermalink('auto-close-lux')">
702 <i class="fas fa-link text-sm"></i>
703 </span>
704 </p>
705 </div>
706 <div class="p-4">
707 <div class="chart-container">
708 <canvas id="autoCloseLuxChart"></canvas>
709 </div>
710 </div>
711 </div>
712 </div>
714 <!-- 日射データ -->
715 <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
716 <div class="bg-white rounded-lg shadow">
717 <div class="border-b px-4 py-3 permalink-header" id="auto-open-solar-rad">
718 <p class="font-semibold text-gray-700">
719 🤖 自動開操作時の日射データ ☀️
720 <span class="permalink-icon " onclick="copyPermalink('auto-open-solar-rad')">
721 <i class="fas fa-link text-sm"></i>
722 </span>
723 </p>
724 </div>
725 <div class="p-4">
726 <div class="chart-container">
727 <canvas id="autoOpenSolarRadChart"></canvas>
728 </div>
729 </div>
730 </div>
731 <div class="bg-white rounded-lg shadow">
732 <div class="border-b px-4 py-3 permalink-header" id="auto-close-solar-rad">
733 <p class="font-semibold text-gray-700">
734 🤖 自動閉操作時の日射データ 🌙
735 <span class="permalink-icon " onclick="copyPermalink('auto-close-solar-rad')">
736 <i class="fas fa-link text-sm"></i>
737 </span>
738 </p>
739 </div>
740 <div class="p-4">
741 <div class="chart-container">
742 <canvas id="autoCloseSolarRadChart"></canvas>
743 </div>
744 </div>
745 </div>
746 </div>
748 <!-- 太陽高度データ -->
749 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
750 <div class="bg-white rounded-lg shadow">
751 <div class="border-b px-4 py-3 permalink-header" id="auto-open-altitude">
752 <p class="font-semibold text-gray-700">
753 🤖 自動開操作時の太陽高度データ ☀️
754 <span class="permalink-icon " onclick="copyPermalink('auto-open-altitude')">
755 <i class="fas fa-link text-sm"></i>
756 </span>
757 </p>
758 </div>
759 <div class="p-4">
760 <div class="chart-container">
761 <canvas id="autoOpenAltitudeChart"></canvas>
762 </div>
763 </div>
764 </div>
765 <div class="bg-white rounded-lg shadow">
766 <div class="border-b px-4 py-3 permalink-header" id="auto-close-altitude">
767 <p class="font-semibold text-gray-700">
768 🤖 自動閉操作時の太陽高度データ 🌙
769 <span class="permalink-icon " onclick="copyPermalink('auto-close-altitude')">
770 <i class="fas fa-link text-sm"></i>
771 </span>
772 </p>
773 </div>
774 <div class="p-4">
775 <div class="chart-container">
776 <canvas id="autoCloseAltitudeChart"></canvas>
777 </div>
778 </div>
779 </div>
780 </div>
781 </div>
783 <div class="mb-8">
784 <h2 class="text-xl font-bold text-gray-800 mb-4 permalink-header" id="manual-sensor-analysis">
785 <i class="fas fa-hand-paper mr-2 text-blue-600"></i>
786 センサーデータ分析(手動操作)
787 <span class="permalink-icon " onclick="copyPermalink('manual-sensor-analysis')">
788 <i class="fas fa-link text-sm"></i>
789 </span>
790 </h2>
792 <!-- データなし表示 -->
793 <div id="manual-no-data" class="hidden bg-gray-50 rounded-lg p-8 text-center">
794 <i class="fas fa-inbox text-4xl text-gray-400 mb-4"></i>
795 <p class="text-gray-600">手動操作のデータがまだありません。</p>
796 <p class="text-sm text-gray-500 mt-2">
797 手動でシャッターを操作すると、ここにセンサーデータが表示されます。
798 </p>
799 </div>
801 <!-- グラフ表示エリア -->
802 <div id="manual-charts">
803 <!-- 照度データ -->
804 <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
805 <div class="bg-white rounded-lg shadow">
806 <div class="border-b px-4 py-3 permalink-header" id="manual-open-lux">
807 <p class="font-semibold text-gray-700">
808 👆 手動開操作時の照度データ ☀️
809 <span class="permalink-icon " onclick="copyPermalink('manual-open-lux')">
810 <i class="fas fa-link text-sm"></i>
811 </span>
812 </p>
813 </div>
814 <div class="p-4">
815 <div class="chart-container">
816 <canvas id="manualOpenLuxChart"></canvas>
817 </div>
818 </div>
819 </div>
820 <div class="bg-white rounded-lg shadow">
821 <div class="border-b px-4 py-3 permalink-header" id="manual-close-lux">
822 <p class="font-semibold text-gray-700">
823 👆 手動閉操作時の照度データ 🌙
824 <span class="permalink-icon " onclick="copyPermalink('manual-close-lux')">
825 <i class="fas fa-link text-sm"></i>
826 </span>
827 </p>
828 </div>
829 <div class="p-4">
830 <div class="chart-container">
831 <canvas id="manualCloseLuxChart"></canvas>
832 </div>
833 </div>
834 </div>
835 </div>
837 <!-- 日射データ -->
838 <div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
839 <div class="bg-white rounded-lg shadow">
840 <div class="border-b px-4 py-3 permalink-header" id="manual-open-solar-rad">
841 <p class="font-semibold text-gray-700">
842 👆 手動開操作時の日射データ ☀️
843 <span class="permalink-icon " onclick="copyPermalink('manual-open-solar-rad')">
844 <i class="fas fa-link text-sm"></i>
845 </span>
846 </p>
847 </div>
848 <div class="p-4">
849 <div class="chart-container">
850 <canvas id="manualOpenSolarRadChart"></canvas>
851 </div>
852 </div>
853 </div>
854 <div class="bg-white rounded-lg shadow">
855 <div class="border-b px-4 py-3 permalink-header" id="manual-close-solar-rad">
856 <p class="font-semibold text-gray-700">
857 👆 手動閉操作時の日射データ 🌙
858 <span class="permalink-icon " onclick="copyPermalink('manual-close-solar-rad')">
859 <i class="fas fa-link text-sm"></i>
860 </span>
861 </p>
862 </div>
863 <div class="p-4">
864 <div class="chart-container">
865 <canvas id="manualCloseSolarRadChart"></canvas>
866 </div>
867 </div>
868 </div>
869 </div>
871 <!-- 太陽高度データ -->
872 <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
873 <div class="bg-white rounded-lg shadow">
874 <div class="border-b px-4 py-3 permalink-header" id="manual-open-altitude">
875 <p class="font-semibold text-gray-700">
876 👆 手動開操作時の太陽高度データ ☀️
877 <span class="permalink-icon " onclick="copyPermalink('manual-open-altitude')">
878 <i class="fas fa-link text-sm"></i>
879 </span>
880 </p>
881 </div>
882 <div class="p-4">
883 <div class="chart-container">
884 <canvas id="manualOpenAltitudeChart"></canvas>
885 </div>
886 </div>
887 </div>
888 <div class="bg-white rounded-lg shadow">
889 <div class="border-b px-4 py-3 permalink-header" id="manual-close-altitude">
890 <p class="font-semibold text-gray-700">
891 👆 手動閉操作時の太陽高度データ 🌙
892 <span class="permalink-icon " onclick="copyPermalink('manual-close-altitude')">
893 <i class="fas fa-link text-sm"></i>
894 </span>
895 </p>
896 </div>
897 <div class="p-4">
898 <div class="chart-container">
899 <canvas id="manualCloseAltitudeChart"></canvas>
900 </div>
901 </div>
902 </div>
903 </div>
904 </div><!-- manual-charts -->
905 </div>
906 """
909def generate_chart_javascript() -> str:
910 """チャート生成用JavaScriptを生成"""
911 return """
912 // 凡例を正方形に設定
913 Chart.defaults.plugins.legend.labels.boxWidth = 12;
914 Chart.defaults.plugins.legend.labels.boxHeight = 12;
916 function initializePermalinks() {
917 // ページ読み込み時にハッシュがある場合はスクロール
918 if (window.location.hash) {
919 const element = document.querySelector(window.location.hash);
920 if (element) {
921 setTimeout(() => {
922 element.scrollIntoView({ behavior: 'smooth', block: 'start' });
923 }, 500); // チャート描画完了を待つ
924 }
925 }
926 }
928 function copyPermalink(sectionId) {
929 const url = window.location.origin + window.location.pathname + '#' + sectionId;
931 // Clipboard APIを使用してURLをコピー
932 if (navigator.clipboard && window.isSecureContext) {
933 navigator.clipboard.writeText(url).then(() => {
934 showCopyNotification();
935 }).catch(err => {
936 console.error('Failed to copy: ', err);
937 fallbackCopyToClipboard(url);
938 });
939 } else {
940 // フォールバック
941 fallbackCopyToClipboard(url);
942 }
944 // URLにハッシュを設定(履歴には残さない)
945 window.history.replaceState(null, null, '#' + sectionId);
946 }
948 function fallbackCopyToClipboard(text) {
949 const textArea = document.createElement("textarea");
950 textArea.value = text;
951 textArea.style.position = "fixed";
952 textArea.style.left = "-999999px";
953 textArea.style.top = "-999999px";
954 document.body.appendChild(textArea);
955 textArea.focus();
956 textArea.select();
958 try {
959 document.execCommand('copy');
960 showCopyNotification();
961 } catch (err) {
962 console.error('Fallback: Failed to copy', err);
963 // 最後の手段として、プロンプトでURLを表示
964 prompt('URLをコピーしてください:', text);
965 }
967 document.body.removeChild(textArea);
968 }
970 function showCopyNotification() {
971 // 通知要素を作成
972 const notification = document.createElement('div');
973 notification.textContent = 'パーマリンクをコピーしました!';
974 notification.style.cssText = `
975 position: fixed;
976 top: 20px;
977 right: 20px;
978 background: #23d160;
979 color: white;
980 padding: 12px 20px;
981 border-radius: 4px;
982 z-index: 1000;
983 font-size: 14px;
984 font-weight: 500;
985 box-shadow: 0 2px 8px rgba(0,0,0,0.15);
986 transition: opacity 0.3s ease-in-out;
987 `;
989 document.body.appendChild(notification);
991 // 3秒後にフェードアウト
992 setTimeout(() => {
993 notification.style.opacity = '0';
994 setTimeout(() => {
995 if (notification.parentNode) {
996 document.body.removeChild(notification);
997 }
998 }, 300);
999 }, 3000);
1000 }
1001 function generateTimeCharts() {
1002 // 開操作時刻ヒストグラム
1003 const openTimeHistogramCtx = document.getElementById('openTimeHistogramChart');
1004 if (openTimeHistogramCtx && chartData.open_times.length > 0) {
1005 const bins = Array.from({length: 24}, (_, i) => i);
1006 const openHist = Array(24).fill(0);
1008 chartData.open_times.forEach(time => {
1009 const hour = Math.floor(time);
1010 if (hour >= 0 && hour < 24) openHist[hour]++;
1011 });
1013 // 頻度を%に変換
1014 const total = chartData.open_times.length;
1015 const openHistPercent = openHist.map(count => total > 0 ? (count / total) * 100 : 0);
1017 new Chart(openTimeHistogramCtx, {
1018 type: 'bar',
1019 data: {
1020 labels: bins.map(h => h + ':00'),
1021 datasets: [{
1022 label: '☀️ 開操作頻度',
1023 data: openHistPercent,
1024 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1025 borderColor: 'rgba(255, 206, 84, 1)',
1026 borderWidth: 1
1027 }]
1028 },
1029 options: {
1030 responsive: true,
1031 maintainAspectRatio: false,
1032 scales: {
1033 y: {
1034 beginAtZero: true,
1035 max: 100,
1036 title: {
1037 display: true,
1038 text: '頻度(%)'
1039 },
1040 ticks: {
1041 callback: function(value) {
1042 return value + '%';
1043 }
1044 }
1045 },
1046 x: {
1047 title: {
1048 display: true,
1049 text: '時刻'
1050 }
1051 }
1052 }
1053 }
1054 });
1055 }
1057 // 閉操作時刻ヒストグラム
1058 const closeTimeHistogramCtx = document.getElementById('closeTimeHistogramChart');
1059 if (closeTimeHistogramCtx && chartData.close_times.length > 0) {
1060 const bins = Array.from({length: 24}, (_, i) => i);
1061 const closeHist = Array(24).fill(0);
1063 chartData.close_times.forEach(time => {
1064 const hour = Math.floor(time);
1065 if (hour >= 0 && hour < 24) closeHist[hour]++;
1066 });
1068 // 頻度を%に変換
1069 const total = chartData.close_times.length;
1070 const closeHistPercent = closeHist.map(count => total > 0 ? (count / total) * 100 : 0);
1072 new Chart(closeTimeHistogramCtx, {
1073 type: 'bar',
1074 data: {
1075 labels: bins.map(h => h + ':00'),
1076 datasets: [{
1077 label: '🌙 閉操作頻度',
1078 data: closeHistPercent,
1079 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1080 borderColor: 'rgba(153, 102, 255, 1)',
1081 borderWidth: 1
1082 }]
1083 },
1084 options: {
1085 responsive: true,
1086 maintainAspectRatio: false,
1087 scales: {
1088 y: {
1089 beginAtZero: true,
1090 max: 100,
1091 title: {
1092 display: true,
1093 text: '頻度(%)'
1094 },
1095 ticks: {
1096 callback: function(value) {
1097 return value + '%';
1098 }
1099 }
1100 },
1101 x: {
1102 title: {
1103 display: true,
1104 text: '時刻'
1105 }
1106 }
1107 }
1108 }
1109 });
1110 }
1111 }
1113 function generateTimeSeriesCharts() {
1114 // 操作時刻の時系列グラフ
1115 const timeSeriesCtx = document.getElementById('timeSeriesChart');
1116 if (timeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1117 new Chart(timeSeriesCtx, {
1118 type: 'line',
1119 data: {
1120 labels: chartData.time_series.dates,
1121 datasets: [
1122 {
1123 label: '☀️ 開操作時刻',
1124 data: chartData.time_series.open_times,
1125 borderColor: 'rgba(255, 206, 84, 1)',
1126 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1127 tension: 0.1,
1128 spanGaps: true
1129 },
1130 {
1131 label: '🌙 閉操作時刻',
1132 data: chartData.time_series.close_times,
1133 borderColor: 'rgba(153, 102, 255, 1)',
1134 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1135 tension: 0.1,
1136 spanGaps: true
1137 }
1138 ]
1139 },
1140 options: {
1141 responsive: true,
1142 maintainAspectRatio: false,
1143 interaction: {
1144 mode: 'index',
1145 intersect: false
1146 },
1147 scales: {
1148 y: {
1149 beginAtZero: true,
1150 max: 24,
1151 title: {
1152 display: true,
1153 text: '時刻'
1154 },
1155 ticks: {
1156 callback: function(value) {
1157 const hour = Math.floor(value);
1158 const minute = Math.round((value - hour) * 60);
1159 return hour + ':' + (minute < 10 ? '0' : '') + minute;
1160 }
1161 }
1162 },
1163 x: {
1164 title: {
1165 display: true,
1166 text: '日付'
1167 }
1168 }
1169 }
1170 }
1171 });
1172 }
1174 // 照度の時系列グラフ
1175 const luxTimeSeriesCtx = document.getElementById('luxTimeSeriesChart');
1176 if (luxTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1177 new Chart(luxTimeSeriesCtx, {
1178 type: 'line',
1179 data: {
1180 labels: chartData.time_series.dates,
1181 datasets: [
1182 {
1183 label: '☀️ 開操作時照度',
1184 data: chartData.time_series.open_lux,
1185 borderColor: 'rgba(255, 206, 84, 1)',
1186 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1187 tension: 0.1,
1188 spanGaps: true
1189 },
1190 {
1191 label: '🌙 閉操作時照度',
1192 data: chartData.time_series.close_lux,
1193 borderColor: 'rgba(153, 102, 255, 1)',
1194 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1195 tension: 0.1,
1196 spanGaps: true
1197 }
1198 ]
1199 },
1200 options: {
1201 responsive: true,
1202 maintainAspectRatio: false,
1203 interaction: {
1204 mode: 'index',
1205 intersect: false
1206 },
1207 scales: {
1208 y: {
1209 beginAtZero: true,
1210 title: {
1211 display: true,
1212 text: '照度(lux)'
1213 },
1214 ticks: {
1215 callback: function(value) {
1216 return value.toLocaleString();
1217 }
1218 }
1219 },
1220 x: {
1221 title: {
1222 display: true,
1223 text: '日付'
1224 }
1225 }
1226 }
1227 }
1228 });
1229 }
1231 // 日射の時系列グラフ
1232 const solarRadTimeSeriesCtx = document.getElementById('solarRadTimeSeriesChart');
1233 if (solarRadTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1234 new Chart(solarRadTimeSeriesCtx, {
1235 type: 'line',
1236 data: {
1237 labels: chartData.time_series.dates,
1238 datasets: [
1239 {
1240 label: '☀️ 開操作時日射',
1241 data: chartData.time_series.open_solar_rad,
1242 borderColor: 'rgba(255, 206, 84, 1)',
1243 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1244 tension: 0.1,
1245 spanGaps: true
1246 },
1247 {
1248 label: '🌙 閉操作時日射',
1249 data: chartData.time_series.close_solar_rad,
1250 borderColor: 'rgba(153, 102, 255, 1)',
1251 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1252 tension: 0.1,
1253 spanGaps: true
1254 }
1255 ]
1256 },
1257 options: {
1258 responsive: true,
1259 maintainAspectRatio: false,
1260 interaction: {
1261 mode: 'index',
1262 intersect: false
1263 },
1264 scales: {
1265 y: {
1266 beginAtZero: true,
1267 title: {
1268 display: true,
1269 text: '日射(W/m²)'
1270 }
1271 },
1272 x: {
1273 title: {
1274 display: true,
1275 text: '日付'
1276 }
1277 }
1278 }
1279 }
1280 });
1281 }
1283 // 太陽高度の時系列グラフ
1284 const altitudeTimeSeriesCtx = document.getElementById('altitudeTimeSeriesChart');
1285 if (altitudeTimeSeriesCtx && chartData.time_series && chartData.time_series.dates.length > 0) {
1286 new Chart(altitudeTimeSeriesCtx, {
1287 type: 'line',
1288 data: {
1289 labels: chartData.time_series.dates,
1290 datasets: [
1291 {
1292 label: '☀️ 開操作時太陽高度',
1293 data: chartData.time_series.open_altitude,
1294 borderColor: 'rgba(255, 206, 84, 1)',
1295 backgroundColor: 'rgba(255, 206, 84, 0.1)',
1296 tension: 0.1,
1297 spanGaps: true
1298 },
1299 {
1300 label: '🌙 閉操作時太陽高度',
1301 data: chartData.time_series.close_altitude,
1302 borderColor: 'rgba(153, 102, 255, 1)',
1303 backgroundColor: 'rgba(153, 102, 255, 0.1)',
1304 tension: 0.1,
1305 spanGaps: true
1306 }
1307 ]
1308 },
1309 options: {
1310 responsive: true,
1311 maintainAspectRatio: false,
1312 interaction: {
1313 mode: 'index',
1314 intersect: false
1315 },
1316 scales: {
1317 y: {
1318 title: {
1319 display: true,
1320 text: '太陽高度(度)'
1321 }
1322 },
1323 x: {
1324 title: {
1325 display: true,
1326 text: '日付'
1327 }
1328 }
1329 }
1330 }
1331 });
1332 }
1333 }
1335 function generateAutoSensorCharts() {
1336 // ヒストグラム生成のヘルパー関数
1337 function createHistogram(data, bins) {
1338 const hist = Array(bins.length - 1).fill(0);
1339 data.forEach(value => {
1340 for (let i = 0; i < bins.length - 1; i++) {
1341 // 最後のビンは最大値も含める(<= を使用)
1342 const isLastBin = (i === bins.length - 2);
1343 if (value >= bins[i] && (isLastBin ? value <= bins[i + 1] : value < bins[i + 1])) {
1344 hist[i]++;
1345 break;
1346 }
1347 }
1348 });
1349 return hist;
1350 }
1352 // ヒストグラムパーセントを計算するヘルパー関数
1353 function calcHistPercent(data) {
1354 if (!data || data.length === 0) return { bins: [], histPercent: [], maxPercent: 0 };
1355 const minVal = Math.min(...data);
1356 const maxVal = Math.max(...data);
1357 const bins = Array.from({length: 21}, (_, i) => minVal + (maxVal - minVal) * i / 20);
1358 const hist = createHistogram(data, bins);
1359 const total = data.length;
1360 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1361 const maxPercent = Math.max(...histPercent);
1362 return { bins, histPercent, maxPercent };
1363 }
1365 // 各カテゴリの開/閉の最大頻度を事前計算
1366 const openLuxData = calcHistPercent(chartData.auto_sensor_data.open_lux);
1367 const closeLuxData = calcHistPercent(chartData.auto_sensor_data.close_lux);
1368 const luxMax = Math.max(openLuxData.maxPercent, closeLuxData.maxPercent, 10);
1369 const luxMaxY = Math.ceil(luxMax / 10) * 10;
1371 const openSolarRadData = calcHistPercent(chartData.auto_sensor_data.open_solar_rad);
1372 const closeSolarRadData = calcHistPercent(chartData.auto_sensor_data.close_solar_rad);
1373 const solarRadMax = Math.max(openSolarRadData.maxPercent, closeSolarRadData.maxPercent, 10);
1374 const solarRadMaxY = Math.ceil(solarRadMax / 10) * 10;
1376 const openAltitudeData = calcHistPercent(chartData.auto_sensor_data.open_altitude);
1377 const closeAltitudeData = calcHistPercent(chartData.auto_sensor_data.close_altitude);
1378 const altitudeMax = Math.max(openAltitudeData.maxPercent, closeAltitudeData.maxPercent, 10);
1379 const altitudeMaxY = Math.ceil(altitudeMax / 10) * 10;
1381 // 自動開操作時照度チャート
1382 const autoOpenLuxCtx = document.getElementById('autoOpenLuxChart');
1383 if (autoOpenLuxCtx && openLuxData.bins.length > 0) {
1384 new Chart(autoOpenLuxCtx, {
1385 type: 'bar',
1386 data: {
1387 labels: openLuxData.bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1388 datasets: [{
1389 label: '🤖☀️ 自動開操作時照度頻度',
1390 data: openLuxData.histPercent,
1391 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1392 borderColor: 'rgba(255, 206, 84, 1)',
1393 borderWidth: 1
1394 }]
1395 },
1396 options: {
1397 responsive: true,
1398 maintainAspectRatio: false,
1399 scales: {
1400 y: {
1401 beginAtZero: true,
1402 max: luxMaxY,
1403 title: {
1404 display: true,
1405 text: '頻度(%)'
1406 },
1407 ticks: {
1408 callback: function(value) {
1409 return value + '%';
1410 }
1411 }
1412 },
1413 x: {
1414 title: {
1415 display: true,
1416 text: '照度(lux)'
1417 }
1418 }
1419 }
1420 }
1421 });
1422 }
1424 // 自動閉操作時照度チャート
1425 const autoCloseLuxCtx = document.getElementById('autoCloseLuxChart');
1426 if (autoCloseLuxCtx && closeLuxData.bins.length > 0) {
1427 new Chart(autoCloseLuxCtx, {
1428 type: 'bar',
1429 data: {
1430 labels: closeLuxData.bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1431 datasets: [{
1432 label: '🤖🌙 自動閉操作時照度頻度',
1433 data: closeLuxData.histPercent,
1434 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1435 borderColor: 'rgba(153, 102, 255, 1)',
1436 borderWidth: 1
1437 }]
1438 },
1439 options: {
1440 responsive: true,
1441 maintainAspectRatio: false,
1442 scales: {
1443 y: {
1444 beginAtZero: true,
1445 max: luxMaxY,
1446 title: {
1447 display: true,
1448 text: '頻度(%)'
1449 },
1450 ticks: {
1451 callback: function(value) {
1452 return value + '%';
1453 }
1454 }
1455 },
1456 x: {
1457 title: {
1458 display: true,
1459 text: '照度(lux)'
1460 }
1461 }
1462 }
1463 }
1464 });
1465 }
1467 // 自動開操作時日射チャート
1468 const autoOpenSolarRadCtx = document.getElementById('autoOpenSolarRadChart');
1469 if (autoOpenSolarRadCtx && openSolarRadData.bins.length > 0) {
1470 new Chart(autoOpenSolarRadCtx, {
1471 type: 'bar',
1472 data: {
1473 labels: openSolarRadData.bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1474 datasets: [{
1475 label: '🤖☀️ 自動開操作時日射頻度',
1476 data: openSolarRadData.histPercent,
1477 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1478 borderColor: 'rgba(255, 206, 84, 1)',
1479 borderWidth: 1
1480 }]
1481 },
1482 options: {
1483 responsive: true,
1484 maintainAspectRatio: false,
1485 scales: {
1486 y: {
1487 beginAtZero: true,
1488 max: solarRadMaxY,
1489 title: {
1490 display: true,
1491 text: '頻度(%)'
1492 },
1493 ticks: {
1494 callback: function(value) {
1495 return value + '%';
1496 }
1497 }
1498 },
1499 x: {
1500 title: {
1501 display: true,
1502 text: '日射(W/m²)'
1503 }
1504 }
1505 }
1506 }
1507 });
1508 }
1510 // 自動閉操作時日射チャート
1511 const autoCloseSolarRadCtx = document.getElementById('autoCloseSolarRadChart');
1512 if (autoCloseSolarRadCtx && closeSolarRadData.bins.length > 0) {
1513 new Chart(autoCloseSolarRadCtx, {
1514 type: 'bar',
1515 data: {
1516 labels: closeSolarRadData.bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1517 datasets: [{
1518 label: '🤖🌙 自動閉操作時日射頻度',
1519 data: closeSolarRadData.histPercent,
1520 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1521 borderColor: 'rgba(153, 102, 255, 1)',
1522 borderWidth: 1
1523 }]
1524 },
1525 options: {
1526 responsive: true,
1527 maintainAspectRatio: false,
1528 scales: {
1529 y: {
1530 beginAtZero: true,
1531 max: solarRadMaxY,
1532 title: {
1533 display: true,
1534 text: '頻度(%)'
1535 },
1536 ticks: {
1537 callback: function(value) {
1538 return value + '%';
1539 }
1540 }
1541 },
1542 x: {
1543 title: {
1544 display: true,
1545 text: '日射(W/m²)'
1546 }
1547 }
1548 }
1549 }
1550 });
1551 }
1553 // 自動開操作時太陽高度チャート
1554 const autoOpenAltitudeCtx = document.getElementById('autoOpenAltitudeChart');
1555 if (autoOpenAltitudeCtx && openAltitudeData.bins.length > 0) {
1556 new Chart(autoOpenAltitudeCtx, {
1557 type: 'bar',
1558 data: {
1559 labels: openAltitudeData.bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1560 datasets: [{
1561 label: '🤖☀️ 自動開操作時太陽高度頻度',
1562 data: openAltitudeData.histPercent,
1563 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1564 borderColor: 'rgba(255, 206, 84, 1)',
1565 borderWidth: 1
1566 }]
1567 },
1568 options: {
1569 responsive: true,
1570 maintainAspectRatio: false,
1571 scales: {
1572 y: {
1573 beginAtZero: true,
1574 max: altitudeMaxY,
1575 title: {
1576 display: true,
1577 text: '頻度(%)'
1578 },
1579 ticks: {
1580 callback: function(value) {
1581 return value + '%';
1582 }
1583 }
1584 },
1585 x: {
1586 title: {
1587 display: true,
1588 text: '太陽高度(度)'
1589 }
1590 }
1591 }
1592 }
1593 });
1594 }
1596 // 自動閉操作時太陽高度チャート
1597 const autoCloseAltitudeCtx = document.getElementById('autoCloseAltitudeChart');
1598 if (autoCloseAltitudeCtx && closeAltitudeData.bins.length > 0) {
1599 new Chart(autoCloseAltitudeCtx, {
1600 type: 'bar',
1601 data: {
1602 labels: closeAltitudeData.bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1603 datasets: [{
1604 label: '🤖🌙 自動閉操作時太陽高度頻度',
1605 data: closeAltitudeData.histPercent,
1606 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1607 borderColor: 'rgba(153, 102, 255, 1)',
1608 borderWidth: 1
1609 }]
1610 },
1611 options: {
1612 responsive: true,
1613 maintainAspectRatio: false,
1614 scales: {
1615 y: {
1616 beginAtZero: true,
1617 max: altitudeMaxY,
1618 title: {
1619 display: true,
1620 text: '頻度(%)'
1621 },
1622 ticks: {
1623 callback: function(value) {
1624 return value + '%';
1625 }
1626 }
1627 },
1628 x: {
1629 title: {
1630 display: true,
1631 text: '太陽高度(度)'
1632 }
1633 }
1634 }
1635 }
1636 });
1637 }
1638 }
1640 function generateManualSensorCharts() {
1641 // 手動操作データの有無をチェック
1642 const manualData = chartData.manual_sensor_data;
1643 const hasManualData = manualData && (
1644 (manualData.open_lux && manualData.open_lux.length > 0) ||
1645 (manualData.close_lux && manualData.close_lux.length > 0) ||
1646 (manualData.open_solar_rad && manualData.open_solar_rad.length > 0) ||
1647 (manualData.close_solar_rad && manualData.close_solar_rad.length > 0) ||
1648 (manualData.open_altitude && manualData.open_altitude.length > 0) ||
1649 (manualData.close_altitude && manualData.close_altitude.length > 0)
1650 );
1652 const noDataDiv = document.getElementById('manual-no-data');
1653 const chartsDiv = document.getElementById('manual-charts');
1655 if (!hasManualData) {
1656 // データがない場合はメッセージを表示し、グラフを非表示
1657 if (noDataDiv) noDataDiv.classList.remove('hidden');
1658 if (chartsDiv) chartsDiv.classList.add('hidden');
1659 return;
1660 } else {
1661 // データがある場合はグラフを表示し、メッセージを非表示
1662 if (noDataDiv) noDataDiv.classList.add('hidden');
1663 if (chartsDiv) chartsDiv.classList.remove('hidden');
1664 }
1666 // ヒストグラム生成のヘルパー関数
1667 function createHistogram(data, bins) {
1668 const hist = Array(bins.length - 1).fill(0);
1669 data.forEach(value => {
1670 for (let i = 0; i < bins.length - 1; i++) {
1671 // 最後のビンは最大値も含める(<= を使用)
1672 const isLastBin = (i === bins.length - 2);
1673 if (value >= bins[i] && (isLastBin ? value <= bins[i + 1] : value < bins[i + 1])) {
1674 hist[i]++;
1675 break;
1676 }
1677 }
1678 });
1679 return hist;
1680 }
1682 // 手動開操作時照度チャート
1683 const manualOpenLuxCtx = document.getElementById('manualOpenLuxChart');
1684 if (manualOpenLuxCtx && chartData.manual_sensor_data &&
1685 chartData.manual_sensor_data.open_lux &&
1686 chartData.manual_sensor_data.open_lux.length > 0) {
1687 const minLux = Math.min(...chartData.manual_sensor_data.open_lux);
1688 const maxLux = Math.max(...chartData.manual_sensor_data.open_lux);
1689 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1690 const hist = createHistogram(chartData.manual_sensor_data.open_lux, bins);
1691 const total = chartData.manual_sensor_data.open_lux.length;
1692 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1694 new Chart(manualOpenLuxCtx, {
1695 type: 'bar',
1696 data: {
1697 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1698 datasets: [{
1699 label: '👆☀️ 手動開操作時照度頻度',
1700 data: histPercent,
1701 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1702 borderColor: 'rgba(255, 206, 84, 1)',
1703 borderWidth: 1
1704 }]
1705 },
1706 options: {
1707 responsive: true,
1708 maintainAspectRatio: false,
1709 scales: {
1710 y: {
1711 beginAtZero: true,
1712 max: 100,
1713 title: {
1714 display: true,
1715 text: '頻度(%)'
1716 },
1717 ticks: {
1718 callback: function(value) {
1719 return value + '%';
1720 }
1721 }
1722 },
1723 x: {
1724 title: {
1725 display: true,
1726 text: '照度(lux)'
1727 }
1728 }
1729 }
1730 }
1731 });
1732 }
1734 // 手動閉操作時照度チャート
1735 const manualCloseLuxCtx = document.getElementById('manualCloseLuxChart');
1736 if (manualCloseLuxCtx && chartData.manual_sensor_data &&
1737 chartData.manual_sensor_data.close_lux &&
1738 chartData.manual_sensor_data.close_lux.length > 0) {
1739 const minLux = Math.min(...chartData.manual_sensor_data.close_lux);
1740 const maxLux = Math.max(...chartData.manual_sensor_data.close_lux);
1741 const bins = Array.from({length: 21}, (_, i) => minLux + (maxLux - minLux) * i / 20);
1742 const hist = createHistogram(chartData.manual_sensor_data.close_lux, bins);
1743 const total = chartData.manual_sensor_data.close_lux.length;
1744 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1746 new Chart(manualCloseLuxCtx, {
1747 type: 'bar',
1748 data: {
1749 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1750 datasets: [{
1751 label: '👆🌙 手動閉操作時照度頻度',
1752 data: histPercent,
1753 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1754 borderColor: 'rgba(153, 102, 255, 1)',
1755 borderWidth: 1
1756 }]
1757 },
1758 options: {
1759 responsive: true,
1760 maintainAspectRatio: false,
1761 scales: {
1762 y: {
1763 beginAtZero: true,
1764 max: 100,
1765 title: {
1766 display: true,
1767 text: '頻度(%)'
1768 },
1769 ticks: {
1770 callback: function(value) {
1771 return value + '%';
1772 }
1773 }
1774 },
1775 x: {
1776 title: {
1777 display: true,
1778 text: '照度(lux)'
1779 }
1780 }
1781 }
1782 }
1783 });
1784 }
1786 // 手動開操作時日射チャート
1787 const manualOpenSolarRadCtx = document.getElementById('manualOpenSolarRadChart');
1788 if (manualOpenSolarRadCtx && chartData.manual_sensor_data &&
1789 chartData.manual_sensor_data.open_solar_rad &&
1790 chartData.manual_sensor_data.open_solar_rad.length > 0) {
1791 const minRad = Math.min(...chartData.manual_sensor_data.open_solar_rad);
1792 const maxRad = Math.max(...chartData.manual_sensor_data.open_solar_rad);
1793 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1794 const hist = createHistogram(chartData.manual_sensor_data.open_solar_rad, bins);
1795 const total = chartData.manual_sensor_data.open_solar_rad.length;
1796 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1798 new Chart(manualOpenSolarRadCtx, {
1799 type: 'bar',
1800 data: {
1801 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1802 datasets: [{
1803 label: '👆☀️ 手動開操作時日射頻度',
1804 data: histPercent,
1805 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1806 borderColor: 'rgba(255, 206, 84, 1)',
1807 borderWidth: 1
1808 }]
1809 },
1810 options: {
1811 responsive: true,
1812 maintainAspectRatio: false,
1813 scales: {
1814 y: {
1815 beginAtZero: true,
1816 max: 100,
1817 title: {
1818 display: true,
1819 text: '頻度(%)'
1820 },
1821 ticks: {
1822 callback: function(value) {
1823 return value + '%';
1824 }
1825 }
1826 },
1827 x: {
1828 title: {
1829 display: true,
1830 text: '日射(W/m²)'
1831 }
1832 }
1833 }
1834 }
1835 });
1836 }
1838 // 手動閉操作時日射チャート
1839 const manualCloseSolarRadCtx = document.getElementById('manualCloseSolarRadChart');
1840 if (manualCloseSolarRadCtx && chartData.manual_sensor_data &&
1841 chartData.manual_sensor_data.close_solar_rad &&
1842 chartData.manual_sensor_data.close_solar_rad.length > 0) {
1843 const minRad = Math.min(...chartData.manual_sensor_data.close_solar_rad);
1844 const maxRad = Math.max(...chartData.manual_sensor_data.close_solar_rad);
1845 const bins = Array.from({length: 21}, (_, i) => minRad + (maxRad - minRad) * i / 20);
1846 const hist = createHistogram(chartData.manual_sensor_data.close_solar_rad, bins);
1847 const total = chartData.manual_sensor_data.close_solar_rad.length;
1848 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1850 new Chart(manualCloseSolarRadCtx, {
1851 type: 'bar',
1852 data: {
1853 labels: bins.slice(0, -1).map(b => Math.round(b).toLocaleString()),
1854 datasets: [{
1855 label: '👆🌙 手動閉操作時日射頻度',
1856 data: histPercent,
1857 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1858 borderColor: 'rgba(153, 102, 255, 1)',
1859 borderWidth: 1
1860 }]
1861 },
1862 options: {
1863 responsive: true,
1864 maintainAspectRatio: false,
1865 scales: {
1866 y: {
1867 beginAtZero: true,
1868 max: 100,
1869 title: {
1870 display: true,
1871 text: '頻度(%)'
1872 },
1873 ticks: {
1874 callback: function(value) {
1875 return value + '%';
1876 }
1877 }
1878 },
1879 x: {
1880 title: {
1881 display: true,
1882 text: '日射(W/m²)'
1883 }
1884 }
1885 }
1886 }
1887 });
1888 }
1890 // 手動開操作時太陽高度チャート
1891 const manualOpenAltitudeCtx = document.getElementById('manualOpenAltitudeChart');
1892 if (manualOpenAltitudeCtx && chartData.manual_sensor_data &&
1893 chartData.manual_sensor_data.open_altitude &&
1894 chartData.manual_sensor_data.open_altitude.length > 0) {
1895 const minAlt = Math.min(...chartData.manual_sensor_data.open_altitude);
1896 const maxAlt = Math.max(...chartData.manual_sensor_data.open_altitude);
1897 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
1898 const hist = createHistogram(chartData.manual_sensor_data.open_altitude, bins);
1899 const total = chartData.manual_sensor_data.open_altitude.length;
1900 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1902 new Chart(manualOpenAltitudeCtx, {
1903 type: 'bar',
1904 data: {
1905 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1906 datasets: [{
1907 label: '👆☀️ 手動開操作時太陽高度頻度',
1908 data: histPercent,
1909 backgroundColor: 'rgba(255, 206, 84, 0.7)',
1910 borderColor: 'rgba(255, 206, 84, 1)',
1911 borderWidth: 1
1912 }]
1913 },
1914 options: {
1915 responsive: true,
1916 maintainAspectRatio: false,
1917 scales: {
1918 y: {
1919 beginAtZero: true,
1920 max: 100,
1921 title: {
1922 display: true,
1923 text: '頻度(%)'
1924 },
1925 ticks: {
1926 callback: function(value) {
1927 return value + '%';
1928 }
1929 }
1930 },
1931 x: {
1932 title: {
1933 display: true,
1934 text: '太陽高度(度)'
1935 }
1936 }
1937 }
1938 }
1939 });
1940 }
1942 // 手動閉操作時太陽高度チャート
1943 const manualCloseAltitudeCtx = document.getElementById('manualCloseAltitudeChart');
1944 if (manualCloseAltitudeCtx && chartData.manual_sensor_data &&
1945 chartData.manual_sensor_data.close_altitude &&
1946 chartData.manual_sensor_data.close_altitude.length > 0) {
1947 const minAlt = Math.min(...chartData.manual_sensor_data.close_altitude);
1948 const maxAlt = Math.max(...chartData.manual_sensor_data.close_altitude);
1949 const bins = Array.from({length: 21}, (_, i) => minAlt + (maxAlt - minAlt) * i / 20);
1950 const hist = createHistogram(chartData.manual_sensor_data.close_altitude, bins);
1951 const total = chartData.manual_sensor_data.close_altitude.length;
1952 const histPercent = hist.map(count => total > 0 ? (count / total) * 100 : 0);
1954 new Chart(manualCloseAltitudeCtx, {
1955 type: 'bar',
1956 data: {
1957 labels: bins.slice(0, -1).map(b => Math.round(b * 10) / 10),
1958 datasets: [{
1959 label: '👆🌙 手動閉操作時太陽高度頻度',
1960 data: histPercent,
1961 backgroundColor: 'rgba(153, 102, 255, 0.7)',
1962 borderColor: 'rgba(153, 102, 255, 1)',
1963 borderWidth: 1
1964 }]
1965 },
1966 options: {
1967 responsive: true,
1968 maintainAspectRatio: false,
1969 scales: {
1970 y: {
1971 beginAtZero: true,
1972 max: 100,
1973 title: {
1974 display: true,
1975 text: '頻度(%)'
1976 },
1977 ticks: {
1978 callback: function(value) {
1979 return value + '%';
1980 }
1981 }
1982 },
1983 x: {
1984 title: {
1985 display: true,
1986 text: '太陽高度(度)'
1987 }
1988 }
1989 }
1990 }
1991 });
1992 }
1993 }
1994 """