Coverage for flask/src/rasp_water/metrics/webapi/page.py: 15%

133 statements  

« prev     ^ index     » next       coverage.py v7.9.1, created at 2025-07-04 12:06 +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 

14from collections import defaultdict 

15 

16import my_lib.webapp.config 

17import rasp_water.metrics.collector 

18from PIL import Image, ImageDraw 

19 

20import flask 

21 

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

23 

24 

25@blueprint.route("/api/metrics", methods=["GET"]) 

26def metrics_view(): 

27 """メトリクスダッシュボードページを表示""" 

28 try: 

29 # 設定からメトリクスデータパスを取得 

30 config = flask.current_app.config["CONFIG"] 

31 metrics_data_path = config.get("metrics", {}).get("data") 

32 

33 # データベースファイルの存在確認 

34 if not metrics_data_path: 

35 return flask.Response( 

36 "<html><body><h1>メトリクス設定が見つかりません</h1>" 

37 "<p>config.yamlでmetricsセクションが設定されていません。</p></body></html>", 

38 mimetype="text/html", 

39 status=503, 

40 ) 

41 

42 from pathlib import Path 

43 

44 db_path = Path(metrics_data_path) 

45 if not db_path.exists(): 

46 return flask.Response( 

47 f"<html><body><h1>メトリクスデータベースが見つかりません</h1>" 

48 f"<p>データベースファイル: {db_path}</p>" 

49 f"<p>システムが十分に動作してからメトリクスが生成されます。</p></body></html>", 

50 mimetype="text/html", 

51 status=503, 

52 ) 

53 

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

55 collector = rasp_water.metrics.collector.get_collector(metrics_data_path) 

56 

57 # 最近30日間のデータを取得 

58 watering_metrics = collector.get_recent_watering_metrics(days=30) 

59 error_metrics = collector.get_recent_error_metrics(days=30) 

60 

61 # 統計データを生成 

62 stats = generate_statistics(watering_metrics, error_metrics) 

63 

64 # 時系列データを準備 

65 time_series_data = prepare_time_series_data(watering_metrics) 

66 

67 # HTMLを生成 

68 html_content = generate_metrics_html(stats, time_series_data) 

69 

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

71 

72 except Exception as e: 

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

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

75 

76 

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

78def favicon(): 

79 """動的生成された水やりメトリクス用favicon.icoを返す""" 

80 try: 

81 # 水やりメトリクスアイコンを生成 

82 img = generate_watering_metrics_icon() 

83 

84 # ICO形式で出力 

85 output = io.BytesIO() 

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

87 output.seek(0) 

88 

89 return flask.Response( 

90 output.getvalue(), 

91 mimetype="image/x-icon", 

92 headers={ 

93 "Cache-Control": "public, max-age=3600", # 1時間キャッシュ 

94 "Content-Type": "image/x-icon", 

95 }, 

96 ) 

97 except Exception: 

98 logging.exception("favicon生成エラー") 

99 return flask.Response("", status=500) 

100 

101 

102def generate_watering_metrics_icon(): 

103 """水やりメトリクス用のアイコンを動的生成(アンチエイリアス対応)""" 

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

105 scale = 4 

106 size = 32 

107 large_size = size * scale 

108 

109 # 大きなサイズで描画 

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

111 draw = ImageDraw.Draw(img) 

112 

113 # 背景円(水を表す青色) 

114 margin = 2 * scale 

115 draw.ellipse( 

116 [margin, margin, large_size - margin, large_size - margin], 

117 fill=(52, 152, 219, 255), # 水色 

118 outline=(41, 128, 185, 255), 

119 width=2 * scale, 

120 ) 

121 

122 # 水滴を描画 

123 drop_center_x = large_size // 2 

124 drop_center_y = large_size // 2 - 4 * scale 

125 drop_width = 8 * scale 

126 drop_height = 10 * scale 

127 

128 # 水滴の形状(上部は円、下部は三角形) 

129 # 上部の円 

130 draw.ellipse( 

131 [ 

132 drop_center_x - drop_width // 2, 

133 drop_center_y, 

134 drop_center_x + drop_width // 2, 

135 drop_center_y + drop_width, 

136 ], 

137 fill=(255, 255, 255, 255), 

138 ) 

139 

140 # 下部の三角形 

141 triangle_points = [ 

142 (drop_center_x - drop_width // 2, drop_center_y + drop_width // 2), 

143 (drop_center_x + drop_width // 2, drop_center_y + drop_width // 2), 

144 (drop_center_x, drop_center_y + drop_height), 

145 ] 

146 draw.polygon(triangle_points, fill=(255, 255, 255, 255)) 

147 

148 # グラフの線を描画(座標を4倍に拡大) 

149 points = [ 

150 (8 * scale, 22 * scale), 

151 (12 * scale, 18 * scale), 

152 (16 * scale, 20 * scale), 

153 (20 * scale, 16 * scale), 

154 (24 * scale, 14 * scale), 

155 ] 

156 

157 # 折れ線グラフ 

158 for i in range(len(points) - 1): 

159 draw.line([points[i], points[i + 1]], fill=(255, 255, 255, 200), width=1 * scale) 

160 

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

162 return img.resize((size, size), Image.LANCZOS) 

163 

164 

165def generate_statistics(watering_metrics: list[dict], error_metrics: list[dict]) -> dict: 

166 """メトリクスデータから統計情報を生成""" 

167 if not watering_metrics: 

168 return { 

169 "total_days": 0, 

170 "total_watering_count": 0, 

171 "manual_watering_count": 0, 

172 "auto_watering_count": 0, 

173 "total_duration_minutes": 0, 

174 "total_volume_liters": 0, 

175 "avg_duration_minutes": 0, 

176 "avg_volume_liters": 0, 

177 "avg_flow_rate": 0, 

178 "error_count": len(error_metrics), 

179 } 

180 

181 # 日付ごとのデータを集計 

182 unique_dates = {op.get("date") for op in watering_metrics if op.get("date")} 

183 

184 # 各種カウントと合計値を計算 

185 total_watering_count = len(watering_metrics) 

186 manual_watering_count = sum( 

187 1 for op in watering_metrics if op.get("operation_type") == "manual" 

188 ) 

189 auto_watering_count = sum( 

190 1 for op in watering_metrics if op.get("operation_type") == "auto" 

191 ) 

192 

193 total_duration_seconds = sum( 

194 op.get("duration_seconds", 0) for op in watering_metrics 

195 ) 

196 total_volume_liters = sum( 

197 op.get("volume_liters", 0) for op in watering_metrics if op.get("volume_liters") is not None 

198 ) 

199 

200 # 平均値を計算 

201 avg_duration_seconds = total_duration_seconds / total_watering_count if total_watering_count > 0 else 0 

202 avg_volume_liters = total_volume_liters / total_watering_count if total_watering_count > 0 else 0 

203 

204 # 流量(リットル/秒)を計算 

205 avg_flow_rate = total_volume_liters / total_duration_seconds if total_duration_seconds > 0 else 0 

206 

207 return { 

208 "total_days": len(unique_dates), 

209 "total_watering_count": total_watering_count, 

210 "manual_watering_count": manual_watering_count, 

211 "auto_watering_count": auto_watering_count, 

212 "total_duration_minutes": total_duration_seconds / 60, 

213 "total_volume_liters": total_volume_liters, 

214 "avg_duration_minutes": avg_duration_seconds / 60, 

215 "avg_volume_liters": avg_volume_liters, 

216 "avg_flow_rate": avg_flow_rate, 

217 "error_count": len(error_metrics), 

218 } 

219 

220 

221def prepare_time_series_data(watering_metrics: list[dict]) -> dict: 

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

223 # 日付ごとのデータを集計 

224 daily_data = defaultdict(lambda: { 

225 "count": 0, 

226 "manual_count": 0, 

227 "auto_count": 0, 

228 "duration_seconds": 0, 

229 "volume_liters": 0, 

230 "watering_list": [] 

231 }) 

232 

233 for op in watering_metrics: 

234 date = op.get("date") 

235 if date: 

236 daily_data[date]["count"] += 1 

237 daily_data[date]["duration_seconds"] += op.get("duration_seconds", 0) 

238 

239 if op.get("volume_liters") is not None: 

240 daily_data[date]["volume_liters"] += op.get("volume_liters", 0) 

241 

242 if op.get("operation_type") == "manual": 

243 daily_data[date]["manual_count"] += 1 

244 else: 

245 daily_data[date]["auto_count"] += 1 

246 

247 # 個別の水やりデータを保存(流量計算用) 

248 daily_data[date]["watering_list"].append({ 

249 "duration_seconds": op.get("duration_seconds", 0), 

250 "volume_liters": op.get("volume_liters", 0) 

251 }) 

252 

253 # 週ごとのデータを集計 

254 weekly_data = defaultdict(lambda: { 

255 "count": 0, 

256 "manual_count": 0, 

257 "auto_count": 0, 

258 "duration_seconds": 0, 

259 "volume_liters": 0, 

260 "watering_list": [] 

261 }) 

262 

263 for date_str, data in daily_data.items(): 

264 date_obj = datetime.datetime.strptime(date_str, "%Y-%m-%d").date() 

265 week_start = date_obj - datetime.timedelta(days=date_obj.weekday()) 

266 week_key = week_start.isoformat() 

267 

268 weekly_data[week_key]["count"] += data["count"] 

269 weekly_data[week_key]["manual_count"] += data["manual_count"] 

270 weekly_data[week_key]["auto_count"] += data["auto_count"] 

271 weekly_data[week_key]["duration_seconds"] += data["duration_seconds"] 

272 weekly_data[week_key]["volume_liters"] += data["volume_liters"] 

273 weekly_data[week_key]["watering_list"].extend(data["watering_list"]) 

274 

275 # ソートして配列に変換 

276 sorted_dates = sorted(daily_data.keys()) 

277 sorted_weeks = sorted(weekly_data.keys()) 

278 

279 # 日別データ 

280 daily_labels = sorted_dates 

281 daily_volumes = [daily_data[date]["volume_liters"] for date in sorted_dates] 

282 daily_counts = [daily_data[date]["count"] for date in sorted_dates] 

283 daily_durations = [daily_data[date]["duration_seconds"] / 60 for date in sorted_dates] # 分に変換 

284 daily_manual_counts = [daily_data[date]["manual_count"] for date in sorted_dates] 

285 

286 # 週別データ 

287 weekly_labels = [f"{week}" for week in sorted_weeks] 

288 weekly_volumes = [weekly_data[week]["volume_liters"] for week in sorted_weeks] 

289 weekly_counts = [weekly_data[week]["count"] for week in sorted_weeks] 

290 weekly_durations = [weekly_data[week]["duration_seconds"] / 60 for week in sorted_weeks] # 分に変換 

291 weekly_manual_counts = [weekly_data[week]["manual_count"] for week in sorted_weeks] 

292 

293 # 流量データ(リットル/秒) 

294 flow_rates = [] 

295 flow_labels = [] 

296 for op in watering_metrics: 

297 if op.get("duration_seconds", 0) > 0 and op.get("volume_liters") is not None: 

298 flow_rate = op.get("volume_liters", 0) / op.get("duration_seconds", 0) 

299 flow_rates.append(flow_rate) 

300 flow_labels.append(op.get("timestamp", "")) 

301 

302 return { 

303 "daily": { 

304 "labels": daily_labels, 

305 "volumes": daily_volumes, 

306 "counts": daily_counts, 

307 "durations": daily_durations, 

308 "manual_counts": daily_manual_counts, 

309 }, 

310 "weekly": { 

311 "labels": weekly_labels, 

312 "volumes": weekly_volumes, 

313 "counts": weekly_counts, 

314 "durations": weekly_durations, 

315 "manual_counts": weekly_manual_counts, 

316 }, 

317 "flow": { 

318 "labels": flow_labels, 

319 "rates": flow_rates, 

320 } 

321 } 

322 

323 

324def generate_metrics_html(stats: dict, time_series_data: dict) -> str: 

325 """Bulma CSSを使用したメトリクスHTMLを生成""" 

326 chart_data_json = json.dumps(time_series_data) 

327 

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

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

330 

331 return f""" 

332<!DOCTYPE html> 

333<html> 

334<head> 

335 <meta charset="utf-8"> 

336 <meta name="viewport" content="width=device-width, initial-scale=1"> 

337 <title>水やり メトリクス ダッシュボード</title> 

338 <link rel="icon" type="image/x-icon" href="{favicon_path}"> 

339 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.9.4/css/bulma.min.css"> 

340 <script src="https://cdn.jsdelivr.net/npm/chart.js"></script> 

341 <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> 

342 <style> 

343 .metrics-card {{ margin-bottom: 1rem; }} 

344 @media (max-width: 768px) {{ 

345 .metrics-card {{ margin-bottom: 0.75rem; }} 

346 }} 

347 .stat-number {{ font-size: 2rem; font-weight: bold; }} 

348 .chart-container {{ position: relative; height: 350px; margin: 0.5rem 0; }} 

349 @media (max-width: 768px) {{ 

350 .chart-container {{ height: 300px; margin: 0.25rem 0; }} 

351 .container.is-fluid {{ padding: 0.25rem !important; }} 

352 .section {{ padding: 0.5rem 0.25rem !important; }} 

353 .card {{ margin-bottom: 1rem !important; }} 

354 .columns {{ margin: 0 !important; }} 

355 .column {{ padding: 0.25rem !important; }} 

356 }} 

357 .japanese-font {{ 

358 font-family: "Hiragino Sans", "Hiragino Kaku Gothic ProN", 

359 "Noto Sans CJK JP", "Yu Gothic", sans-serif; 

360 }} 

361 .permalink-header {{ 

362 position: relative; 

363 display: inline-block; 

364 }} 

365 .permalink-icon {{ 

366 opacity: 0; 

367 transition: opacity 0.2s ease-in-out; 

368 cursor: pointer; 

369 color: #4a90e2; 

370 margin-left: 0.5rem; 

371 font-size: 0.8em; 

372 }} 

373 .permalink-header:hover .permalink-icon {{ 

374 opacity: 1; 

375 }} 

376 .permalink-icon:hover {{ 

377 color: #357abd; 

378 }} 

379 </style> 

380</head> 

381<body class="japanese-font"> 

382 <div class="container is-fluid" style="padding: 0.5rem;"> 

383 <section class="section" style="padding: 1rem 0.5rem;"> 

384 <div class="container" style="max-width: 100%; padding: 0;"> 

385 <h1 class="title is-2 has-text-centered"> 

386 <span class="icon is-large"><i class="fas fa-tint"></i></span> 

387 水やり メトリクス ダッシュボード 

388 </h1> 

389 <p class="subtitle has-text-centered">過去30日間の水やり統計</p> 

390 

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

392 {generate_basic_stats_section(stats)} 

393 

394 <!-- 日別時系列分析 --> 

395 {generate_daily_time_series_section()} 

396 

397 <!-- 週別時系列分析 --> 

398 {generate_weekly_time_series_section()} 

399 

400 <!-- 流量分析 --> 

401 {generate_flow_analysis_section()} 

402 </div> 

403 </section> 

404 </div> 

405 

406 <script> 

407 const chartData = {chart_data_json}; 

408 

409 // チャート生成 

410 generateDailyCharts(); 

411 generateWeeklyCharts(); 

412 generateFlowChart(); 

413 

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

415 initializePermalinks(); 

416 

417 {generate_chart_javascript()} 

418 </script> 

419</html> 

420 """ 

421 

422 

423def generate_basic_stats_section(stats: dict) -> str: 

424 """基本統計セクションのHTML生成""" 

425 return f""" 

426 <div class="section"> 

427 <h2 class="title is-4 permalink-header" id="basic-stats"> 

428 <span class="icon"><i class="fas fa-chart-bar"></i></span> 

429 基本統計(過去30日間) 

430 <span class="permalink-icon" onclick="copyPermalink('basic-stats')"> 

431 <i class="fas fa-link"></i> 

432 </span> 

433 </h2> 

434 

435 <div class="columns"> 

436 <div class="column"> 

437 <div class="card metrics-card"> 

438 <div class="card-header"> 

439 <p class="card-header-title">水やり実績</p> 

440 </div> 

441 <div class="card-content"> 

442 <div class="columns is-multiline"> 

443 <div class="column is-one-third"> 

444 <div class="has-text-centered"> 

445 <p class="heading">総水やり回数</p> 

446 <p class="stat-number has-text-primary">{stats["total_watering_count"]:,}</p> 

447 </div> 

448 </div> 

449 <div class="column is-one-third"> 

450 <div class="has-text-centered"> 

451 <p class="heading">🔧 手動水やり</p> 

452 <p class="stat-number has-text-info">{stats["manual_watering_count"]:,}</p> 

453 </div> 

454 </div> 

455 <div class="column is-one-third"> 

456 <div class="has-text-centered"> 

457 <p class="heading">🤖 自動水やり</p> 

458 <p class="stat-number has-text-success">{stats["auto_watering_count"]:,}</p> 

459 </div> 

460 </div> 

461 <div class="column is-one-third"> 

462 <div class="has-text-centered"> 

463 <p class="heading">総散水量</p> 

464 <p class="stat-number has-text-link">{stats["total_volume_liters"]:.1f} L</p> 

465 </div> 

466 </div> 

467 <div class="column is-one-third"> 

468 <div class="has-text-centered"> 

469 <p class="heading">総散水時間</p> 

470 <p class="stat-number has-text-warning">{stats["total_duration_minutes"]:.1f} 分</p> 

471 </div> 

472 </div> 

473 <div class="column is-one-third"> 

474 <div class="has-text-centered"> 

475 <p class="heading">エラー回数</p> 

476 <p class="stat-number has-text-danger">{stats["error_count"]:,}</p> 

477 </div> 

478 </div> 

479 <div class="column is-one-third"> 

480 <div class="has-text-centered"> 

481 <p class="heading">平均散水量/回</p> 

482 <p class="stat-number">{stats["avg_volume_liters"]:.2f} L</p> 

483 </div> 

484 </div> 

485 <div class="column is-one-third"> 

486 <div class="has-text-centered"> 

487 <p class="heading">平均散水時間/回</p> 

488 <p class="stat-number">{stats["avg_duration_minutes"]:.1f} 分</p> 

489 </div> 

490 </div> 

491 <div class="column is-one-third"> 

492 <div class="has-text-centered"> 

493 <p class="heading">平均流量</p> 

494 <p class="stat-number">{stats["avg_flow_rate"]:.3f} L/秒</p> 

495 </div> 

496 </div> 

497 </div> 

498 </div> 

499 </div> 

500 </div> 

501 </div> 

502 </div> 

503 """ 

504 

505 

506def generate_daily_time_series_section() -> str: 

507 """日別時系列分析セクションのHTML生成""" 

508 return """ 

509 <div class="section"> 

510 <h2 class="title is-4 permalink-header" id="daily-analysis"> 

511 <span class="icon"><i class="fas fa-calendar-day"></i></span> 日別時系列分析 

512 <span class="permalink-icon" onclick="copyPermalink('daily-analysis')"> 

513 <i class="fas fa-link"></i> 

514 </span> 

515 </h2> 

516 

517 <div class="columns"> 

518 <div class="column"> 

519 <div class="card metrics-card"> 

520 <div class="card-header"> 

521 <p class="card-header-title permalink-header" id="daily-volume"> 

522 💧 1日あたりの散水量 

523 <span class="permalink-icon" onclick="copyPermalink('daily-volume')"> 

524 <i class="fas fa-link"></i> 

525 </span> 

526 </p> 

527 </div> 

528 <div class="card-content"> 

529 <div class="chart-container"> 

530 <canvas id="dailyVolumeChart"></canvas> 

531 </div> 

532 </div> 

533 </div> 

534 </div> 

535 </div> 

536 

537 <div class="columns"> 

538 <div class="column"> 

539 <div class="card metrics-card"> 

540 <div class="card-header"> 

541 <p class="card-header-title permalink-header" id="daily-count"> 

542 📊 1日あたりの散水回数 

543 <span class="permalink-icon" onclick="copyPermalink('daily-count')"> 

544 <i class="fas fa-link"></i> 

545 </span> 

546 </p> 

547 </div> 

548 <div class="card-content"> 

549 <div class="chart-container"> 

550 <canvas id="dailyCountChart"></canvas> 

551 </div> 

552 </div> 

553 </div> 

554 </div> 

555 </div> 

556 

557 <div class="columns"> 

558 <div class="column"> 

559 <div class="card metrics-card"> 

560 <div class="card-header"> 

561 <p class="card-header-title permalink-header" id="daily-duration"> 

562 ⏱️ 1日あたりの散水時間 

563 <span class="permalink-icon" onclick="copyPermalink('daily-duration')"> 

564 <i class="fas fa-link"></i> 

565 </span> 

566 </p> 

567 </div> 

568 <div class="card-content"> 

569 <div class="chart-container"> 

570 <canvas id="dailyDurationChart"></canvas> 

571 </div> 

572 </div> 

573 </div> 

574 </div> 

575 </div> 

576 </div> 

577 """ 

578 

579 

580def generate_weekly_time_series_section() -> str: 

581 """週別時系列分析セクションのHTML生成""" 

582 return """ 

583 <div class="section"> 

584 <h2 class="title is-4 permalink-header" id="weekly-analysis"> 

585 <span class="icon"><i class="fas fa-calendar-week"></i></span> 週別時系列分析 

586 <span class="permalink-icon" onclick="copyPermalink('weekly-analysis')"> 

587 <i class="fas fa-link"></i> 

588 </span> 

589 </h2> 

590 

591 <div class="columns"> 

592 <div class="column"> 

593 <div class="card metrics-card"> 

594 <div class="card-header"> 

595 <p class="card-header-title permalink-header" id="weekly-volume"> 

596 💧 1週間あたりの散水量 

597 <span class="permalink-icon" onclick="copyPermalink('weekly-volume')"> 

598 <i class="fas fa-link"></i> 

599 </span> 

600 </p> 

601 </div> 

602 <div class="card-content"> 

603 <div class="chart-container"> 

604 <canvas id="weeklyVolumeChart"></canvas> 

605 </div> 

606 </div> 

607 </div> 

608 </div> 

609 </div> 

610 

611 <div class="columns"> 

612 <div class="column"> 

613 <div class="card metrics-card"> 

614 <div class="card-header"> 

615 <p class="card-header-title permalink-header" id="weekly-count"> 

616 📊 1週間あたりの散水回数 

617 <span class="permalink-icon" onclick="copyPermalink('weekly-count')"> 

618 <i class="fas fa-link"></i> 

619 </span> 

620 </p> 

621 </div> 

622 <div class="card-content"> 

623 <div class="chart-container"> 

624 <canvas id="weeklyCountChart"></canvas> 

625 </div> 

626 </div> 

627 </div> 

628 </div> 

629 </div> 

630 

631 <div class="columns"> 

632 <div class="column"> 

633 <div class="card metrics-card"> 

634 <div class="card-header"> 

635 <p class="card-header-title permalink-header" id="weekly-duration"> 

636 ⏱️ 1週間あたりの散水時間 

637 <span class="permalink-icon" onclick="copyPermalink('weekly-duration')"> 

638 <i class="fas fa-link"></i> 

639 </span> 

640 </p> 

641 </div> 

642 <div class="card-content"> 

643 <div class="chart-container"> 

644 <canvas id="weeklyDurationChart"></canvas> 

645 </div> 

646 </div> 

647 </div> 

648 </div> 

649 </div> 

650 

651 <div class="columns"> 

652 <div class="column"> 

653 <div class="card metrics-card"> 

654 <div class="card-header"> 

655 <p class="card-header-title permalink-header" id="weekly-manual"> 

656 🔧 1週間あたりの手動散水回数 

657 <span class="permalink-icon" onclick="copyPermalink('weekly-manual')"> 

658 <i class="fas fa-link"></i> 

659 </span> 

660 </p> 

661 </div> 

662 <div class="card-content"> 

663 <div class="chart-container"> 

664 <canvas id="weeklyManualChart"></canvas> 

665 </div> 

666 </div> 

667 </div> 

668 </div> 

669 </div> 

670 </div> 

671 """ 

672 

673 

674def generate_flow_analysis_section() -> str: 

675 """流量分析セクションのHTML生成""" 

676 return """ 

677 <div class="section"> 

678 <h2 class="title is-4 permalink-header" id="flow-analysis"> 

679 <span class="icon"><i class="fas fa-water"></i></span> 流量分析 

680 <span class="permalink-icon" onclick="copyPermalink('flow-analysis')"> 

681 <i class="fas fa-link"></i> 

682 </span> 

683 </h2> 

684 

685 <div class="columns"> 

686 <div class="column"> 

687 <div class="card metrics-card"> 

688 <div class="card-header"> 

689 <p class="card-header-title permalink-header" id="flow-rate"> 

690 🚰 1秒あたりの散水量(流量) 

691 <span class="permalink-icon" onclick="copyPermalink('flow-rate')"> 

692 <i class="fas fa-link"></i> 

693 </span> 

694 </p> 

695 </div> 

696 <div class="card-content"> 

697 <div class="chart-container"> 

698 <canvas id="flowRateChart"></canvas> 

699 </div> 

700 </div> 

701 </div> 

702 </div> 

703 </div> 

704 </div> 

705 """ 

706 

707 

708def generate_chart_javascript() -> str: 

709 """チャート生成用JavaScriptを生成""" 

710 return """ 

711 function initializePermalinks() { 

712 // ページ読み込み時にハッシュがある場合はスクロール 

713 if (window.location.hash) { 

714 const element = document.querySelector(window.location.hash); 

715 if (element) { 

716 setTimeout(() => { 

717 element.scrollIntoView({ behavior: 'smooth', block: 'start' }); 

718 }, 500); // チャート描画完了を待つ 

719 } 

720 } 

721 } 

722 

723 function copyPermalink(sectionId) { 

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

725 

726 // Clipboard APIを使用してURLをコピー 

727 if (navigator.clipboard && window.isSecureContext) { 

728 navigator.clipboard.writeText(url).then(() => { 

729 showCopyNotification(); 

730 }).catch(err => { 

731 console.error('Failed to copy: ', err); 

732 fallbackCopyToClipboard(url); 

733 }); 

734 } else { 

735 // フォールバック 

736 fallbackCopyToClipboard(url); 

737 } 

738 

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

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

741 } 

742 

743 function fallbackCopyToClipboard(text) { 

744 const textArea = document.createElement("textarea"); 

745 textArea.value = text; 

746 textArea.style.position = "fixed"; 

747 textArea.style.left = "-999999px"; 

748 textArea.style.top = "-999999px"; 

749 document.body.appendChild(textArea); 

750 textArea.focus(); 

751 textArea.select(); 

752 

753 try { 

754 document.execCommand('copy'); 

755 showCopyNotification(); 

756 } catch (err) { 

757 console.error('Fallback: Failed to copy', err); 

758 // 最後の手段として、プロンプトでURLを表示 

759 prompt('URLをコピーしてください:', text); 

760 } 

761 

762 document.body.removeChild(textArea); 

763 } 

764 

765 function showCopyNotification() { 

766 // 通知要素を作成 

767 const notification = document.createElement('div'); 

768 notification.textContent = 'パーマリンクをコピーしました!'; 

769 notification.style.cssText = ` 

770 position: fixed; 

771 top: 20px; 

772 right: 20px; 

773 background: #23d160; 

774 color: white; 

775 padding: 12px 20px; 

776 border-radius: 4px; 

777 z-index: 1000; 

778 font-size: 14px; 

779 font-weight: 500; 

780 box-shadow: 0 2px 8px rgba(0,0,0,0.15); 

781 transition: opacity 0.3s ease-in-out; 

782 `; 

783 

784 document.body.appendChild(notification); 

785 

786 // 3秒後にフェードアウト 

787 setTimeout(() => { 

788 notification.style.opacity = '0'; 

789 setTimeout(() => { 

790 if (notification.parentNode) { 

791 document.body.removeChild(notification); 

792 } 

793 }, 300); 

794 }, 3000); 

795 } 

796 

797 function generateDailyCharts() { 

798 // 日別散水量チャート 

799 const dailyVolumeCtx = document.getElementById('dailyVolumeChart'); 

800 if (dailyVolumeCtx && chartData.daily && chartData.daily.labels.length > 0) { 

801 new Chart(dailyVolumeCtx, { 

802 type: 'bar', 

803 data: { 

804 labels: chartData.daily.labels, 

805 datasets: [{ 

806 label: '散水量 (L)', 

807 data: chartData.daily.volumes, 

808 backgroundColor: 'rgba(52, 152, 219, 0.7)', 

809 borderColor: 'rgba(52, 152, 219, 1)', 

810 borderWidth: 1 

811 }] 

812 }, 

813 options: { 

814 responsive: true, 

815 maintainAspectRatio: false, 

816 scales: { 

817 y: { 

818 beginAtZero: true, 

819 title: { 

820 display: true, 

821 text: '散水量 (リットル)' 

822 } 

823 }, 

824 x: { 

825 title: { 

826 display: true, 

827 text: '日付' 

828 } 

829 } 

830 } 

831 } 

832 }); 

833 } 

834 

835 // 日別散水回数チャート 

836 const dailyCountCtx = document.getElementById('dailyCountChart'); 

837 if (dailyCountCtx && chartData.daily && chartData.daily.labels.length > 0) { 

838 new Chart(dailyCountCtx, { 

839 type: 'line', 

840 data: { 

841 labels: chartData.daily.labels, 

842 datasets: [{ 

843 label: '散水回数', 

844 data: chartData.daily.counts, 

845 borderColor: 'rgba(46, 204, 113, 1)', 

846 backgroundColor: 'rgba(46, 204, 113, 0.1)', 

847 tension: 0.1 

848 }] 

849 }, 

850 options: { 

851 responsive: true, 

852 maintainAspectRatio: false, 

853 scales: { 

854 y: { 

855 beginAtZero: true, 

856 ticks: { 

857 stepSize: 1 

858 }, 

859 title: { 

860 display: true, 

861 text: '回数' 

862 } 

863 }, 

864 x: { 

865 title: { 

866 display: true, 

867 text: '日付' 

868 } 

869 } 

870 } 

871 } 

872 }); 

873 } 

874 

875 // 日別散水時間チャート 

876 const dailyDurationCtx = document.getElementById('dailyDurationChart'); 

877 if (dailyDurationCtx && chartData.daily && chartData.daily.labels.length > 0) { 

878 new Chart(dailyDurationCtx, { 

879 type: 'bar', 

880 data: { 

881 labels: chartData.daily.labels, 

882 datasets: [{ 

883 label: '散水時間 (分)', 

884 data: chartData.daily.durations, 

885 backgroundColor: 'rgba(241, 196, 15, 0.7)', 

886 borderColor: 'rgba(241, 196, 15, 1)', 

887 borderWidth: 1 

888 }] 

889 }, 

890 options: { 

891 responsive: true, 

892 maintainAspectRatio: false, 

893 scales: { 

894 y: { 

895 beginAtZero: true, 

896 title: { 

897 display: true, 

898 text: '時間 (分)' 

899 } 

900 }, 

901 x: { 

902 title: { 

903 display: true, 

904 text: '日付' 

905 } 

906 } 

907 } 

908 } 

909 }); 

910 } 

911 } 

912 

913 function generateWeeklyCharts() { 

914 // 週別散水量チャート 

915 const weeklyVolumeCtx = document.getElementById('weeklyVolumeChart'); 

916 if (weeklyVolumeCtx && chartData.weekly && chartData.weekly.labels.length > 0) { 

917 new Chart(weeklyVolumeCtx, { 

918 type: 'bar', 

919 data: { 

920 labels: chartData.weekly.labels, 

921 datasets: [{ 

922 label: '散水量 (L)', 

923 data: chartData.weekly.volumes, 

924 backgroundColor: 'rgba(155, 89, 182, 0.7)', 

925 borderColor: 'rgba(155, 89, 182, 1)', 

926 borderWidth: 1 

927 }] 

928 }, 

929 options: { 

930 responsive: true, 

931 maintainAspectRatio: false, 

932 scales: { 

933 y: { 

934 beginAtZero: true, 

935 title: { 

936 display: true, 

937 text: '散水量 (リットル)' 

938 } 

939 }, 

940 x: { 

941 title: { 

942 display: true, 

943 text: '週' 

944 } 

945 } 

946 } 

947 } 

948 }); 

949 } 

950 

951 // 週別散水回数チャート 

952 const weeklyCountCtx = document.getElementById('weeklyCountChart'); 

953 if (weeklyCountCtx && chartData.weekly && chartData.weekly.labels.length > 0) { 

954 new Chart(weeklyCountCtx, { 

955 type: 'line', 

956 data: { 

957 labels: chartData.weekly.labels, 

958 datasets: [{ 

959 label: '散水回数', 

960 data: chartData.weekly.counts, 

961 borderColor: 'rgba(52, 73, 94, 1)', 

962 backgroundColor: 'rgba(52, 73, 94, 0.1)', 

963 tension: 0.1 

964 }] 

965 }, 

966 options: { 

967 responsive: true, 

968 maintainAspectRatio: false, 

969 scales: { 

970 y: { 

971 beginAtZero: true, 

972 title: { 

973 display: true, 

974 text: '回数' 

975 } 

976 }, 

977 x: { 

978 title: { 

979 display: true, 

980 text: '週' 

981 } 

982 } 

983 } 

984 } 

985 }); 

986 } 

987 

988 // 週別散水時間チャート 

989 const weeklyDurationCtx = document.getElementById('weeklyDurationChart'); 

990 if (weeklyDurationCtx && chartData.weekly && chartData.weekly.labels.length > 0) { 

991 new Chart(weeklyDurationCtx, { 

992 type: 'bar', 

993 data: { 

994 labels: chartData.weekly.labels, 

995 datasets: [{ 

996 label: '散水時間 (分)', 

997 data: chartData.weekly.durations, 

998 backgroundColor: 'rgba(230, 126, 34, 0.7)', 

999 borderColor: 'rgba(230, 126, 34, 1)', 

1000 borderWidth: 1 

1001 }] 

1002 }, 

1003 options: { 

1004 responsive: true, 

1005 maintainAspectRatio: false, 

1006 scales: { 

1007 y: { 

1008 beginAtZero: true, 

1009 title: { 

1010 display: true, 

1011 text: '時間 (分)' 

1012 } 

1013 }, 

1014 x: { 

1015 title: { 

1016 display: true, 

1017 text: '週' 

1018 } 

1019 } 

1020 } 

1021 } 

1022 }); 

1023 } 

1024 

1025 // 週別手動散水回数チャート 

1026 const weeklyManualCtx = document.getElementById('weeklyManualChart'); 

1027 if (weeklyManualCtx && chartData.weekly && chartData.weekly.labels.length > 0) { 

1028 new Chart(weeklyManualCtx, { 

1029 type: 'bar', 

1030 data: { 

1031 labels: chartData.weekly.labels, 

1032 datasets: [{ 

1033 label: '手動散水回数', 

1034 data: chartData.weekly.manual_counts, 

1035 backgroundColor: 'rgba(231, 76, 60, 0.7)', 

1036 borderColor: 'rgba(231, 76, 60, 1)', 

1037 borderWidth: 1 

1038 }] 

1039 }, 

1040 options: { 

1041 responsive: true, 

1042 maintainAspectRatio: false, 

1043 scales: { 

1044 y: { 

1045 beginAtZero: true, 

1046 ticks: { 

1047 stepSize: 1 

1048 }, 

1049 title: { 

1050 display: true, 

1051 text: '回数' 

1052 } 

1053 }, 

1054 x: { 

1055 title: { 

1056 display: true, 

1057 text: '週' 

1058 } 

1059 } 

1060 } 

1061 } 

1062 }); 

1063 } 

1064 } 

1065 

1066 function generateFlowChart() { 

1067 // 流量チャート 

1068 const flowRateCtx = document.getElementById('flowRateChart'); 

1069 if (flowRateCtx && chartData.flow && chartData.flow.rates.length > 0) { 

1070 new Chart(flowRateCtx, { 

1071 type: 'scatter', 

1072 data: { 

1073 datasets: [{ 

1074 label: '流量 (L/秒)', 

1075 data: chartData.flow.rates.map((rate, index) => ({ 

1076 x: index, 

1077 y: rate 

1078 })), 

1079 backgroundColor: 'rgba(52, 152, 219, 0.7)', 

1080 borderColor: 'rgba(52, 152, 219, 1)', 

1081 pointRadius: 4, 

1082 pointHoverRadius: 6 

1083 }] 

1084 }, 

1085 options: { 

1086 responsive: true, 

1087 maintainAspectRatio: false, 

1088 scales: { 

1089 y: { 

1090 beginAtZero: true, 

1091 title: { 

1092 display: true, 

1093 text: '流量 (L/秒)' 

1094 } 

1095 }, 

1096 x: { 

1097 title: { 

1098 display: true, 

1099 text: '散水イベント' 

1100 } 

1101 } 

1102 }, 

1103 plugins: { 

1104 tooltip: { 

1105 callbacks: { 

1106 label: function(context) { 

1107 return '流量: ' + context.parsed.y.toFixed(3) + ' L/秒'; 

1108 } 

1109 } 

1110 } 

1111 } 

1112 } 

1113 }); 

1114 } 

1115 } 

1116 """