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

1#!/usr/bin/env python3 

2""" 

3シャッターメトリクス表示ページ 

4 

5シャッター操作の統計情報とグラフを表示するWebページを提供します。 

6""" 

7 

8from __future__ import annotations 

9 

10import datetime 

11import io 

12import json 

13import logging 

14 

15import flask 

16import my_lib.webapp.config 

17from PIL import Image, ImageDraw 

18 

19import rasp_shutter.metrics.collector 

20 

21blueprint = flask.Blueprint("metrics", __name__, url_prefix=my_lib.webapp.config.URL_PREFIX) 

22 

23 

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 ) 

39 

40 # メトリクス収集器を取得 

41 collector = rasp_shutter.metrics.collector.get_collector(db_path) 

42 

43 # 全期間のデータを取得 

44 operation_metrics = collector.get_all_operation_metrics() 

45 failure_metrics = collector.get_all_failure_metrics() 

46 

47 # 統計データを生成 

48 stats = generate_statistics(operation_metrics, failure_metrics) 

49 

50 # データ期間を計算 

51 data_period = calculate_data_period(operation_metrics) 

52 

53 # HTMLを生成 

54 html_content = generate_metrics_html(stats, operation_metrics, data_period) 

55 

56 return flask.Response(html_content, mimetype="text/html") 

57 

58 except Exception as e: 

59 logging.exception("メトリクス表示の生成エラー") 

60 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500) 

61 

62 

63@blueprint.route("/favicon.ico", methods=["GET"]) 

64def favicon(): 

65 """動的生成されたシャッターメトリクス用favicon.icoを返す""" 

66 try: 

67 # シャッターメトリクスアイコンを生成 

68 img = generate_shutter_metrics_icon() 

69 

70 # ICO形式で出力 

71 output = io.BytesIO() 

72 img.save(output, format="ICO", sizes=[(32, 32)]) 

73 output.seek(0) 

74 

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) 

86 

87 

88def generate_shutter_metrics_icon(): 

89 """シャッターメトリクス用のアイコンを動的生成(アンチエイリアス対応)""" 

90 # アンチエイリアスのため4倍サイズで描画してから縮小 

91 scale = 4 

92 size = 32 

93 large_size = size * scale 

94 

95 # 大きなサイズで描画 

96 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0)) 

97 draw = ImageDraw.Draw(img) 

98 

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 ) 

107 

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 ] 

116 

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) 

120 

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 ) 

128 

129 # 32x32に縮小してアンチエイリアス効果を得る 

130 return img.resize((size, size), Image.Resampling.LANCZOS) 

131 

132 

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": "データなし"} 

137 

138 # 日付のみを抽出(str型のみをフィルタリング) 

139 dates: list[str] = [ 

140 str(date) for op in operation_metrics if (date := op.get("date")) and isinstance(date, str) 

141 ] 

142 

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": "データなし"} 

145 

146 # 最古と最新の日付を取得 

147 start_date: str = min(dates) 

148 end_date: str = max(dates) 

149 

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 

154 

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}〜)" 

161 

162 return { 

163 "total_days": total_days, 

164 "start_date": start_date, 

165 "end_date": end_date, 

166 "display_text": display_text, 

167 } 

168 

169 

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 

179 

180 

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 } 

191 

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]) 

199 

200 return sensor_data 

201 

202 

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 } 

232 

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") 

239 

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 

244 

245 # 時刻データを収集(最後の操作時刻のみ) 

246 open_times = [] 

247 close_times = [] 

248 

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) 

260 

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") 

267 

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 

286 

287 # 日数を計算 

288 unique_dates = {op.get("date") for op in operation_metrics if op.get("date")} 

289 

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 } 

302 

303 

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 } 

314 

315 chart_data_json = json.dumps(chart_data) 

316 

317 # URL_PREFIXを取得してfaviconパスを構築 

318 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico" 

319 

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> 

359 

360 <!-- 基本統計 --> 

361 {generate_basic_stats_section(stats)} 

362 

363 <!-- 時刻分析 --> 

364 {generate_time_analysis_section()} 

365 

366 <!-- 時系列データ分析 --> 

367 {generate_time_series_section()} 

368 

369 <!-- センサーデータ分析 --> 

370 {generate_sensor_analysis_section()} 

371 </div> 

372 

373 <script> 

374 const chartData = {chart_data_json}; 

375 

376 // チャート生成 

377 generateTimeCharts(); 

378 generateTimeSeriesCharts(); 

379 generateAutoSensorCharts(); 

380 generateManualSensorCharts(); 

381 

382 // パーマリンク機能を初期化 

383 initializePermalinks(); 

384 

385 {generate_chart_javascript()} 

386 </script> 

387</html> 

388 """ 

389 

390 

391def _extract_daily_last_operations(operation_metrics: list[dict]) -> dict: 

392 """日付ごとの最後の操作時刻とセンサーデータを取得""" 

393 daily_last_operations: dict[str, dict] = {} 

394 

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") 

399 

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 } 

410 

411 return daily_last_operations 

412 

413 

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 

421 

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 

433 

434 return time_val, lux_val, solar_rad_val, altitude_val 

435 

436 

437def prepare_time_series_data(operation_metrics: list[dict]) -> dict: 

438 """時系列データを準備""" 

439 daily_last_operations = _extract_daily_last_operations(operation_metrics) 

440 

441 # 日付リストを生成 

442 date_set: set[str] = {op["date"] for op in operation_metrics if op.get("date")} 

443 unique_dates = sorted(date_set) 

444 

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 = [] 

454 

455 for date in unique_dates: 

456 dates.append(date) 

457 

458 # その日の最後の開操作時刻とセンサーデータ 

459 open_time, open_lux_val, open_solar_rad_val, open_altitude_val = _extract_daily_data( 

460 date, "open", daily_last_operations 

461 ) 

462 

463 # その日の最後の閉操作時刻とセンサーデータ 

464 close_time, close_lux_val, close_solar_rad_val, close_altitude_val = _extract_daily_data( 

465 date, "close", daily_last_operations 

466 ) 

467 

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) 

476 

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 } 

488 

489 

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> 

501 

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

537 

538 

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> 

550 

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

585 

586 

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> 

598 

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> 

615 

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> 

631 

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> 

647 

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

666 

667 

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> 

679 

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> 

713 

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> 

747 

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> 

782 

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> 

791 

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> 

800 

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> 

836 

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> 

870 

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

907 

908 

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; 

915 

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 } 

927 

928 function copyPermalink(sectionId) { 

929 const url = window.location.origin + window.location.pathname + '#' + sectionId; 

930 

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 } 

943 

944 // URLにハッシュを設定(履歴には残さない) 

945 window.history.replaceState(null, null, '#' + sectionId); 

946 } 

947 

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(); 

957 

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 } 

966 

967 document.body.removeChild(textArea); 

968 } 

969 

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 `; 

988 

989 document.body.appendChild(notification); 

990 

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); 

1007 

1008 chartData.open_times.forEach(time => { 

1009 const hour = Math.floor(time); 

1010 if (hour >= 0 && hour < 24) openHist[hour]++; 

1011 }); 

1012 

1013 // 頻度を%に変換 

1014 const total = chartData.open_times.length; 

1015 const openHistPercent = openHist.map(count => total > 0 ? (count / total) * 100 : 0); 

1016 

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 } 

1056 

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); 

1062 

1063 chartData.close_times.forEach(time => { 

1064 const hour = Math.floor(time); 

1065 if (hour >= 0 && hour < 24) closeHist[hour]++; 

1066 }); 

1067 

1068 // 頻度を%に変換 

1069 const total = chartData.close_times.length; 

1070 const closeHistPercent = closeHist.map(count => total > 0 ? (count / total) * 100 : 0); 

1071 

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 } 

1112 

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 } 

1173 

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 } 

1230 

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 } 

1282 

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 } 

1334 

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 } 

1351 

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 } 

1364 

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; 

1370 

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; 

1375 

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; 

1380 

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 } 

1423 

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 } 

1466 

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 } 

1509 

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 } 

1552 

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 } 

1595 

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 } 

1639 

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 ); 

1651 

1652 const noDataDiv = document.getElementById('manual-no-data'); 

1653 const chartsDiv = document.getElementById('manual-charts'); 

1654 

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 } 

1665 

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 } 

1681 

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); 

1693 

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 } 

1733 

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); 

1745 

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 } 

1785 

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); 

1797 

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 } 

1837 

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); 

1849 

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 } 

1889 

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); 

1901 

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 } 

1941 

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); 

1953 

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