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
« prev ^ index » next coverage.py v7.9.1, created at 2025-07-04 12:06 +0900
1#!/usr/bin/env python3
2"""
3水やりメトリクス表示ページ
5水やり操作の統計情報とグラフを表示するWebページを提供します。
6"""
8from __future__ import annotations
10import datetime
11import io
12import json
13import logging
14from collections import defaultdict
16import my_lib.webapp.config
17import rasp_water.metrics.collector
18from PIL import Image, ImageDraw
20import flask
22blueprint = flask.Blueprint("metrics", __name__, url_prefix=my_lib.webapp.config.URL_PREFIX)
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")
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 )
42 from pathlib import Path
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 )
54 # メトリクス収集器を取得
55 collector = rasp_water.metrics.collector.get_collector(metrics_data_path)
57 # 最近30日間のデータを取得
58 watering_metrics = collector.get_recent_watering_metrics(days=30)
59 error_metrics = collector.get_recent_error_metrics(days=30)
61 # 統計データを生成
62 stats = generate_statistics(watering_metrics, error_metrics)
64 # 時系列データを準備
65 time_series_data = prepare_time_series_data(watering_metrics)
67 # HTMLを生成
68 html_content = generate_metrics_html(stats, time_series_data)
70 return flask.Response(html_content, mimetype="text/html")
72 except Exception as e:
73 logging.exception("メトリクス表示の生成エラー")
74 return flask.Response(f"エラー: {e!s}", mimetype="text/plain", status=500)
77@blueprint.route("/favicon.ico", methods=["GET"])
78def favicon():
79 """動的生成された水やりメトリクス用favicon.icoを返す"""
80 try:
81 # 水やりメトリクスアイコンを生成
82 img = generate_watering_metrics_icon()
84 # ICO形式で出力
85 output = io.BytesIO()
86 img.save(output, format="ICO", sizes=[(32, 32)])
87 output.seek(0)
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)
102def generate_watering_metrics_icon():
103 """水やりメトリクス用のアイコンを動的生成(アンチエイリアス対応)"""
104 # アンチエイリアスのため4倍サイズで描画してから縮小
105 scale = 4
106 size = 32
107 large_size = size * scale
109 # 大きなサイズで描画
110 img = Image.new("RGBA", (large_size, large_size), (0, 0, 0, 0))
111 draw = ImageDraw.Draw(img)
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 )
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
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 )
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))
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 ]
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)
161 # 32x32に縮小してアンチエイリアス効果を得る
162 return img.resize((size, size), Image.LANCZOS)
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 }
181 # 日付ごとのデータを集計
182 unique_dates = {op.get("date") for op in watering_metrics if op.get("date")}
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 )
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 )
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
204 # 流量(リットル/秒)を計算
205 avg_flow_rate = total_volume_liters / total_duration_seconds if total_duration_seconds > 0 else 0
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 }
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 })
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)
239 if op.get("volume_liters") is not None:
240 daily_data[date]["volume_liters"] += op.get("volume_liters", 0)
242 if op.get("operation_type") == "manual":
243 daily_data[date]["manual_count"] += 1
244 else:
245 daily_data[date]["auto_count"] += 1
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 })
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 })
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()
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"])
275 # ソートして配列に変換
276 sorted_dates = sorted(daily_data.keys())
277 sorted_weeks = sorted(weekly_data.keys())
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]
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]
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", ""))
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 }
324def generate_metrics_html(stats: dict, time_series_data: dict) -> str:
325 """Bulma CSSを使用したメトリクスHTMLを生成"""
326 chart_data_json = json.dumps(time_series_data)
328 # URL_PREFIXを取得してfaviconパスを構築
329 favicon_path = f"{my_lib.webapp.config.URL_PREFIX}/favicon.ico"
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>
391 <!-- 基本統計 -->
392 {generate_basic_stats_section(stats)}
394 <!-- 日別時系列分析 -->
395 {generate_daily_time_series_section()}
397 <!-- 週別時系列分析 -->
398 {generate_weekly_time_series_section()}
400 <!-- 流量分析 -->
401 {generate_flow_analysis_section()}
402 </div>
403 </section>
404 </div>
406 <script>
407 const chartData = {chart_data_json};
409 // チャート生成
410 generateDailyCharts();
411 generateWeeklyCharts();
412 generateFlowChart();
414 // パーマリンク機能を初期化
415 initializePermalinks();
417 {generate_chart_javascript()}
418 </script>
419</html>
420 """
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>
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 """
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>
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>
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>
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 """
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>
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>
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>
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>
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 """
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>
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 """
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 }
723 function copyPermalink(sectionId) {
724 const url = window.location.origin + window.location.pathname + '#' + sectionId;
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 }
739 // URLにハッシュを設定(履歴には残さない)
740 window.history.replaceState(null, null, '#' + sectionId);
741 }
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();
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 }
762 document.body.removeChild(textArea);
763 }
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 `;
784 document.body.appendChild(notification);
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 }
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 """