mirror of
https://github.com/jorijn/meshcore-stats.git
synced 2026-03-28 17:42:55 +01:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c199ace4a2 | ||
|
|
f7923b9434 | ||
|
|
c978844271 | ||
|
|
64cc352b80 | ||
|
|
e37aef6c5e |
1
.github/workflows/docker-publish.yml
vendored
1
.github/workflows/docker-publish.yml
vendored
@@ -33,6 +33,7 @@ permissions:
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
concurrency:
|
||||
group: docker-${{ github.ref }}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "0.2.7"
|
||||
".": "0.2.9"
|
||||
}
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -4,6 +4,25 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
This changelog is automatically generated by [release-please](https://github.com/googleapis/release-please) based on [Conventional Commits](https://www.conventionalcommits.org/).
|
||||
|
||||
## [0.2.9](https://github.com/jorijn/meshcore-stats/compare/v0.2.8...v0.2.9) (2026-01-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* tooltip positioning and locale-aware time formatting ([f7923b9](https://github.com/jorijn/meshcore-stats/commit/f7923b94346c3d492e7291ecca208ab704176308))
|
||||
|
||||
|
||||
### Continuous Integration
|
||||
|
||||
* add artifact-metadata permission for attestation storage records ([c978844](https://github.com/jorijn/meshcore-stats/commit/c978844271eafd35f4778d748d7c832309d1614f))
|
||||
|
||||
## [0.2.8](https://github.com/jorijn/meshcore-stats/compare/v0.2.7...v0.2.8) (2026-01-06)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* normalize reporting outputs and chart tooltips ([e37aef6](https://github.com/jorijn/meshcore-stats/commit/e37aef6c5e55d2077baf4ee35abdff0562983d69))
|
||||
|
||||
## [0.2.7](https://github.com/jorijn/meshcore-stats/compare/v0.2.6...v0.2.7) (2026-01-06)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ services:
|
||||
# MeshCore Stats - Data collection and rendering
|
||||
# ==========================================================================
|
||||
meshcore-stats:
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.2.7 # x-release-please-version
|
||||
image: ghcr.io/jorijn/meshcore-stats:0.2.9 # x-release-please-version
|
||||
container_name: meshcore-stats
|
||||
restart: unless-stopped
|
||||
|
||||
|
||||
@@ -74,16 +74,6 @@ async def collect_companion() -> int:
|
||||
else:
|
||||
log.error(f"device_query failed: {err}")
|
||||
|
||||
# get_bat
|
||||
ok, evt_type, payload, err = await run_command(
|
||||
mc, cmd.get_bat(), "get_bat"
|
||||
)
|
||||
if ok:
|
||||
commands_succeeded += 1
|
||||
log.debug(f"get_bat: {payload}")
|
||||
else:
|
||||
log.error(f"get_bat failed: {err}")
|
||||
|
||||
# get_time
|
||||
ok, evt_type, payload, err = await run_command(
|
||||
mc, cmd.get_time(), "get_time"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""MeshCore network monitoring library."""
|
||||
|
||||
__version__ = "0.2.7" # x-release-please-version
|
||||
__version__ = "0.2.9" # x-release-please-version
|
||||
|
||||
@@ -167,6 +167,7 @@ def load_timeseries_from_db(
|
||||
end_time: datetime,
|
||||
lookback: timedelta,
|
||||
period: str,
|
||||
all_metrics: Optional[dict[str, list[tuple[int, float]]]] = None,
|
||||
) -> TimeSeries:
|
||||
"""Load time series data from SQLite database.
|
||||
|
||||
@@ -179,6 +180,7 @@ def load_timeseries_from_db(
|
||||
end_time: End of the time range (typically now)
|
||||
lookback: How far back to look
|
||||
period: Period name for binning config ("day", "week", etc.)
|
||||
all_metrics: Optional pre-fetched metrics dict for this period
|
||||
|
||||
Returns:
|
||||
TimeSeries with extracted data points
|
||||
@@ -188,7 +190,8 @@ def load_timeseries_from_db(
|
||||
end_ts = int(end_time.timestamp())
|
||||
|
||||
# Fetch all metrics for this role/period (returns pivoted dict)
|
||||
all_metrics = get_metrics_for_period(role, start_ts, end_ts)
|
||||
if all_metrics is None:
|
||||
all_metrics = get_metrics_for_period(role, start_ts, end_ts)
|
||||
|
||||
# Get data for this specific metric
|
||||
metric_data = all_metrics.get(metric, [])
|
||||
@@ -379,10 +382,22 @@ def render_chart_svg(
|
||||
|
||||
# Plot area fill
|
||||
area_color = _hex_to_rgba(theme.area)
|
||||
ax.fill_between(timestamps, values, alpha=area_color[3], color=f"#{theme.line}")
|
||||
area = ax.fill_between(
|
||||
timestamps,
|
||||
values,
|
||||
alpha=area_color[3],
|
||||
color=f"#{theme.line}",
|
||||
)
|
||||
area.set_gid("chart-area")
|
||||
|
||||
# Plot line
|
||||
ax.plot(timestamps, values, color=f"#{theme.line}", linewidth=2)
|
||||
(line,) = ax.plot(
|
||||
timestamps,
|
||||
values,
|
||||
color=f"#{theme.line}",
|
||||
linewidth=2,
|
||||
)
|
||||
line.set_gid("chart-line")
|
||||
|
||||
# Set Y-axis limits and track actual values used
|
||||
if y_min is not None and y_max is not None:
|
||||
@@ -458,7 +473,7 @@ def _inject_data_attributes(
|
||||
|
||||
Adds:
|
||||
- data-metric, data-period, data-theme, data-x-start, data-x-end, data-y-min, data-y-max to root <svg>
|
||||
- data-points JSON array to the chart path element
|
||||
- data-points JSON array to the root <svg> and chart line path
|
||||
|
||||
Args:
|
||||
svg: Raw SVG string
|
||||
@@ -495,22 +510,33 @@ def _inject_data_attributes(
|
||||
r'<svg\b',
|
||||
f'<svg data-metric="{ts.metric}" data-period="{ts.period}" data-theme="{theme_name}" '
|
||||
f'data-x-start="{x_start_ts}" data-x-end="{x_end_ts}" '
|
||||
f'data-y-min="{y_min_val}" data-y-max="{y_max_val}"',
|
||||
f'data-y-min="{y_min_val}" data-y-max="{y_max_val}" '
|
||||
f'data-points="{data_points_attr}"',
|
||||
svg,
|
||||
count=1
|
||||
)
|
||||
|
||||
# Add data-points to the main path element (the line, not the fill)
|
||||
# Look for the second path element (first is usually the fill area)
|
||||
path_count = 0
|
||||
def add_data_to_path(match):
|
||||
nonlocal path_count
|
||||
path_count += 1
|
||||
if path_count == 2: # The line path
|
||||
return f'<path data-points="{data_points_attr}"'
|
||||
return match.group(0)
|
||||
# Add data-points to the line path inside the #chart-line group
|
||||
# matplotlib creates <g id="chart-line"><path d="..."></g>
|
||||
svg, count = re.subn(
|
||||
r'(<g[^>]*id="chart-line"[^>]*>\s*<path\b)',
|
||||
rf'\1 data-points="{data_points_attr}"',
|
||||
svg,
|
||||
count=1,
|
||||
)
|
||||
|
||||
svg = re.sub(r'<path\b', add_data_to_path, svg)
|
||||
if count == 0:
|
||||
# Fallback: look for the second path element (first is usually the fill area)
|
||||
path_count = 0
|
||||
|
||||
def add_data_to_path(match):
|
||||
nonlocal path_count
|
||||
path_count += 1
|
||||
if path_count == 2: # The line path
|
||||
return f'<path data-points="{data_points_attr}"'
|
||||
return match.group(0)
|
||||
|
||||
svg = re.sub(r'<path\b', add_data_to_path, svg)
|
||||
|
||||
return svg
|
||||
|
||||
@@ -558,9 +584,16 @@ def render_all_charts(
|
||||
for metric in metrics:
|
||||
all_stats[metric] = {}
|
||||
|
||||
for period in periods:
|
||||
period_cfg = PERIOD_CONFIG[period]
|
||||
for period in periods:
|
||||
period_cfg = PERIOD_CONFIG[period]
|
||||
x_end = now
|
||||
x_start = now - period_cfg["lookback"]
|
||||
|
||||
start_ts = int(x_start.timestamp())
|
||||
end_ts = int(x_end.timestamp())
|
||||
all_metrics = get_metrics_for_period(role, start_ts, end_ts)
|
||||
|
||||
for metric in metrics:
|
||||
# Load time series from database
|
||||
ts = load_timeseries_from_db(
|
||||
role=role,
|
||||
@@ -568,6 +601,7 @@ def render_all_charts(
|
||||
end_time=now,
|
||||
lookback=period_cfg["lookback"],
|
||||
period=period,
|
||||
all_metrics=all_metrics,
|
||||
)
|
||||
|
||||
# Calculate and store statistics
|
||||
@@ -579,10 +613,6 @@ def render_all_charts(
|
||||
y_min = y_range[0] if y_range else None
|
||||
y_max = y_range[1] if y_range else None
|
||||
|
||||
# Calculate X-axis range for full period padding
|
||||
x_end = now
|
||||
x_start = now - period_cfg["lookback"]
|
||||
|
||||
# Render chart for each theme
|
||||
for theme_name in themes:
|
||||
theme = CHART_THEMES[theme_name]
|
||||
|
||||
@@ -588,8 +588,8 @@ def build_page_context(
|
||||
last_updated = None
|
||||
last_updated_iso = None
|
||||
if ts:
|
||||
dt = datetime.fromtimestamp(ts)
|
||||
last_updated = dt.strftime("%b %d, %Y at %H:%M UTC")
|
||||
dt = datetime.fromtimestamp(ts).astimezone()
|
||||
last_updated = dt.strftime("%b %d, %Y at %H:%M %Z")
|
||||
last_updated_iso = dt.isoformat()
|
||||
|
||||
# Build metrics for sidebar
|
||||
@@ -845,24 +845,24 @@ def build_monthly_table_data(
|
||||
airtime = m.get("airtime", MetricStats())
|
||||
|
||||
# Convert mV to V for display
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": False,
|
||||
"cells": [
|
||||
{"value": f"{daily.date.day:02d}", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_time(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": _fmt_val_time(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean else "-", "class": None},
|
||||
{"value": f"{noise.mean:.0f}" if noise.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{airtime.total:,}" if airtime.total else "-", "class": None},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean is not None else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean is not None else "-", "class": None},
|
||||
{"value": f"{noise.mean:.0f}" if noise.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
{"value": f"{airtime.total:,}" if airtime.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -877,24 +877,24 @@ def build_monthly_table_data(
|
||||
tx = s.get("nb_sent", MetricStats())
|
||||
airtime = s.get("airtime", MetricStats())
|
||||
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": True,
|
||||
"cells": [
|
||||
{"value": "", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_day(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": _fmt_val_day(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean else "-", "class": None},
|
||||
{"value": f"{noise.mean:.0f}" if noise.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{airtime.total:,}" if airtime.total else "-", "class": None},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean is not None else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean is not None else "-", "class": None},
|
||||
{"value": f"{noise.mean:.0f}" if noise.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
{"value": f"{airtime.total:,}" if airtime.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -928,21 +928,21 @@ def build_monthly_table_data(
|
||||
tx = m.get("sent", MetricStats())
|
||||
|
||||
# Convert mV to V for display
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": False,
|
||||
"cells": [
|
||||
{"value": f"{daily.date.day:02d}", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_time(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": _fmt_val_time(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -954,21 +954,21 @@ def build_monthly_table_data(
|
||||
rx = s.get("recv", MetricStats())
|
||||
tx = s.get("sent", MetricStats())
|
||||
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": True,
|
||||
"cells": [
|
||||
{"value": "", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_day(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": _fmt_val_day(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1033,23 +1033,23 @@ def build_yearly_table_data(
|
||||
tx = s.get("nb_sent", MetricStats())
|
||||
|
||||
# Convert mV to V
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": False,
|
||||
"cells": [
|
||||
{"value": str(agg.year), "class": None},
|
||||
{"value": f"{monthly.month:02d}", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_day(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": _fmt_val_day(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean is not None else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1062,23 +1062,23 @@ def build_yearly_table_data(
|
||||
rx = s.get("nb_recv", MetricStats())
|
||||
tx = s.get("nb_sent", MetricStats())
|
||||
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": True,
|
||||
"cells": [
|
||||
{"value": "", "class": None},
|
||||
{"value": "", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_month(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": _fmt_val_month(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{rssi.mean:.0f}" if rssi.mean is not None else "-", "class": None},
|
||||
{"value": f"{snr.mean:.1f}" if snr.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1113,22 +1113,22 @@ def build_yearly_table_data(
|
||||
tx = s.get("sent", MetricStats())
|
||||
|
||||
# Convert mV to V
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": False,
|
||||
"cells": [
|
||||
{"value": str(agg.year), "class": None},
|
||||
{"value": f"{monthly.month:02d}", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_day(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": _fmt_val_day(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1140,22 +1140,22 @@ def build_yearly_table_data(
|
||||
rx = s.get("recv", MetricStats())
|
||||
tx = s.get("sent", MetricStats())
|
||||
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value else None
|
||||
bat_v_mean = bat.mean / 1000.0 if bat.mean is not None else None
|
||||
bat_v_min = bat.min_value / 1000.0 if bat.min_value is not None else None
|
||||
bat_v_max = bat.max_value / 1000.0 if bat.max_value is not None else None
|
||||
|
||||
rows.append({
|
||||
"is_summary": True,
|
||||
"cells": [
|
||||
{"value": "", "class": None},
|
||||
{"value": "", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean else "-", "class": None},
|
||||
{"value": f"{bat_v_mean:.2f}" if bat_v_mean is not None else "-", "class": None},
|
||||
{"value": f"{bat_pct.mean:.0f}" if bat_pct.mean is not None else "-", "class": None},
|
||||
{"value": _fmt_val_month(bat_v_max, bat.max_time), "class": "muted"},
|
||||
{"value": _fmt_val_month(bat_v_min, bat.min_time), "class": "muted"},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total else "-", "class": None},
|
||||
{"value": f"{contacts.mean:.0f}" if contacts.mean is not None else "-", "class": None},
|
||||
{"value": f"{rx.total:,}" if rx.total is not None else "-", "class": "highlight"},
|
||||
{"value": f"{tx.total:,}" if tx.total is not None else "-", "class": None},
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -17,17 +17,12 @@ import calendar
|
||||
import json
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
from .db import get_connection, get_metrics_for_period, VALID_ROLES
|
||||
from .env import get_config
|
||||
from .metrics import (
|
||||
is_counter_metric,
|
||||
get_chart_metrics,
|
||||
transform_value,
|
||||
)
|
||||
from . import log
|
||||
|
||||
|
||||
def _validate_role(role: str) -> str:
|
||||
@@ -59,6 +54,32 @@ def get_metrics_for_role(role: str) -> list[str]:
|
||||
raise ValueError(f"Unknown role: {role}")
|
||||
|
||||
|
||||
REPORT_UNITS_RAW = {
|
||||
"battery_mv": "mV",
|
||||
"bat": "mV",
|
||||
"bat_pct": "%",
|
||||
"uptime": "s",
|
||||
"uptime_secs": "s",
|
||||
"last_rssi": "dBm",
|
||||
"last_snr": "dB",
|
||||
"noise_floor": "dBm",
|
||||
"tx_queue_len": "count",
|
||||
"contacts": "count",
|
||||
"recv": "packets",
|
||||
"sent": "packets",
|
||||
"nb_recv": "packets",
|
||||
"nb_sent": "packets",
|
||||
"airtime": "s",
|
||||
"rx_airtime": "s",
|
||||
"flood_dups": "packets",
|
||||
"direct_dups": "packets",
|
||||
"sent_flood": "packets",
|
||||
"recv_flood": "packets",
|
||||
"sent_direct": "packets",
|
||||
"recv_direct": "packets",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class MetricStats:
|
||||
"""Statistics for a single metric over a period.
|
||||
@@ -1116,10 +1137,14 @@ def format_yearly_txt(
|
||||
return format_yearly_txt_companion(agg, node_name, location)
|
||||
|
||||
|
||||
def _metric_stats_to_dict(stats: MetricStats) -> dict[str, Any]:
|
||||
def _metric_stats_to_dict(stats: MetricStats, metric: str) -> dict[str, Any]:
|
||||
"""Convert MetricStats to JSON-serializable dict."""
|
||||
result: dict[str, Any] = {"count": stats.count}
|
||||
|
||||
unit = REPORT_UNITS_RAW.get(metric)
|
||||
if unit:
|
||||
result["unit"] = unit
|
||||
|
||||
if stats.mean is not None:
|
||||
result["mean"] = round(stats.mean, 4)
|
||||
if stats.min_value is not None:
|
||||
@@ -1144,7 +1169,7 @@ def _daily_to_dict(daily: DailyAggregate) -> dict[str, Any]:
|
||||
"date": daily.date.isoformat(),
|
||||
"snapshot_count": daily.snapshot_count,
|
||||
"metrics": {
|
||||
ds: _metric_stats_to_dict(stats)
|
||||
ds: _metric_stats_to_dict(stats, ds)
|
||||
for ds, stats in daily.metrics.items()
|
||||
if stats.has_data
|
||||
},
|
||||
@@ -1167,7 +1192,7 @@ def monthly_to_json(agg: MonthlyAggregate) -> dict[str, Any]:
|
||||
"role": agg.role,
|
||||
"days_with_data": len(agg.daily),
|
||||
"summary": {
|
||||
ds: _metric_stats_to_dict(stats)
|
||||
ds: _metric_stats_to_dict(stats, ds)
|
||||
for ds, stats in agg.summary.items()
|
||||
if stats.has_data
|
||||
},
|
||||
@@ -1190,7 +1215,7 @@ def yearly_to_json(agg: YearlyAggregate) -> dict[str, Any]:
|
||||
"role": agg.role,
|
||||
"months_with_data": len(agg.monthly),
|
||||
"summary": {
|
||||
ds: _metric_stats_to_dict(stats)
|
||||
ds: _metric_stats_to_dict(stats, ds)
|
||||
for ds, stats in agg.summary.items()
|
||||
if stats.has_data
|
||||
},
|
||||
@@ -1200,7 +1225,7 @@ def yearly_to_json(agg: YearlyAggregate) -> dict[str, Any]:
|
||||
"month": m.month,
|
||||
"days_with_data": len(m.daily),
|
||||
"summary": {
|
||||
ds: _metric_stats_to_dict(stats)
|
||||
ds: _metric_stats_to_dict(stats, ds)
|
||||
for ds, stats in m.summary.items()
|
||||
if stats.has_data
|
||||
},
|
||||
|
||||
@@ -1,142 +1,331 @@
|
||||
/**
|
||||
* Chart tooltip enhancement for MeshCore Stats
|
||||
* Chart Tooltip Enhancement for MeshCore Stats
|
||||
*
|
||||
* Progressive enhancement: charts work fully without JS,
|
||||
* but this adds interactive tooltips on hover.
|
||||
* Progressive enhancement: charts display fully without JavaScript.
|
||||
* This module adds interactive tooltips showing datetime and value on hover,
|
||||
* with an indicator dot that follows the data line.
|
||||
*
|
||||
* Data sources:
|
||||
* - Data points: path.dataset.points or svg.dataset.points (JSON array of {ts, v})
|
||||
* - Time range: svg.dataset.xStart, svg.dataset.xEnd (Unix timestamps)
|
||||
* - Value range: svg.dataset.yMin, svg.dataset.yMax
|
||||
* - Plot bounds: Derived from clipPath rect or line path bounding box
|
||||
*/
|
||||
(function() {
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Create tooltip element
|
||||
const tooltip = document.createElement('div');
|
||||
tooltip.className = 'chart-tooltip';
|
||||
tooltip.innerHTML = '<div class="tooltip-time"></div><div class="tooltip-value"></div>';
|
||||
document.body.appendChild(tooltip);
|
||||
// ============================================================================
|
||||
// Configuration
|
||||
// ============================================================================
|
||||
|
||||
const tooltipTime = tooltip.querySelector('.tooltip-time');
|
||||
const tooltipValue = tooltip.querySelector('.tooltip-value');
|
||||
|
||||
// Track the current indicator element
|
||||
let currentIndicator = null;
|
||||
let currentSvg = null;
|
||||
|
||||
// Metric display labels and units (using firmware field names)
|
||||
const metricLabels = {
|
||||
// Companion metrics
|
||||
'battery_mv': { label: 'Voltage', unit: 'V', decimals: 2 },
|
||||
'uptime_secs': { label: 'Uptime', unit: 'days', decimals: 2 },
|
||||
'contacts': { label: 'Contacts', unit: '', decimals: 0 },
|
||||
'recv': { label: 'Received', unit: '/min', decimals: 1 },
|
||||
'sent': { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
|
||||
// Repeater metrics
|
||||
'bat': { label: 'Voltage', unit: 'V', decimals: 2 },
|
||||
'bat_pct': { label: 'Charge', unit: '%', decimals: 0 },
|
||||
'uptime': { label: 'Uptime', unit: 'days', decimals: 2 },
|
||||
'last_rssi': { label: 'RSSI', unit: 'dBm', decimals: 0 },
|
||||
'last_snr': { label: 'SNR', unit: 'dB', decimals: 1 },
|
||||
'noise_floor': { label: 'Noise', unit: 'dBm', decimals: 0 },
|
||||
'tx_queue_len': { label: 'Queue', unit: '', decimals: 0 },
|
||||
'nb_recv': { label: 'Received', unit: '/min', decimals: 1 },
|
||||
'nb_sent': { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
'airtime': { label: 'TX Air', unit: 's/min', decimals: 2 },
|
||||
'rx_airtime': { label: 'RX Air', unit: 's/min', decimals: 2 },
|
||||
'flood_dups': { label: 'Dropped', unit: '/min', decimals: 1 },
|
||||
'direct_dups': { label: 'Dropped', unit: '/min', decimals: 1 },
|
||||
'sent_flood': { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
'recv_flood': { label: 'Received', unit: '/min', decimals: 1 },
|
||||
'sent_direct': { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
'recv_direct': { label: 'Received', unit: '/min', decimals: 1 },
|
||||
var CONFIG = {
|
||||
tooltipOffset: 15,
|
||||
viewportPadding: 10,
|
||||
indicatorRadius: 5,
|
||||
indicatorStrokeWidth: 2,
|
||||
colors: {
|
||||
light: { fill: '#b45309', stroke: '#ffffff' },
|
||||
dark: { fill: '#f59e0b', stroke: '#0f1114' }
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a timestamp as a readable date/time string
|
||||
* Metric display configuration keyed by firmware field name.
|
||||
* Each entry defines how to format values for that metric.
|
||||
*/
|
||||
function formatTime(ts, period) {
|
||||
const date = new Date(ts * 1000);
|
||||
const options = {
|
||||
var METRIC_CONFIG = {
|
||||
// Companion metrics
|
||||
battery_mv: { label: 'Voltage', unit: 'V', decimals: 2 },
|
||||
uptime_secs: { label: 'Uptime', unit: 'days', decimals: 2 },
|
||||
contacts: { label: 'Contacts', unit: '', decimals: 0 },
|
||||
recv: { label: 'Received', unit: '/min', decimals: 1 },
|
||||
sent: { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
|
||||
// Repeater metrics
|
||||
bat: { label: 'Voltage', unit: 'V', decimals: 2 },
|
||||
bat_pct: { label: 'Charge', unit: '%', decimals: 0 },
|
||||
uptime: { label: 'Uptime', unit: 'days', decimals: 2 },
|
||||
last_rssi: { label: 'RSSI', unit: 'dBm', decimals: 0 },
|
||||
last_snr: { label: 'SNR', unit: 'dB', decimals: 1 },
|
||||
noise_floor: { label: 'Noise', unit: 'dBm', decimals: 0 },
|
||||
tx_queue_len: { label: 'Queue', unit: '', decimals: 0 },
|
||||
nb_recv: { label: 'Received', unit: '/min', decimals: 1 },
|
||||
nb_sent: { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
airtime: { label: 'TX Air', unit: 's/min', decimals: 2 },
|
||||
rx_airtime: { label: 'RX Air', unit: 's/min', decimals: 2 },
|
||||
flood_dups: { label: 'Dropped', unit: '/min', decimals: 1 },
|
||||
direct_dups: { label: 'Dropped', unit: '/min', decimals: 1 },
|
||||
sent_flood: { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
recv_flood: { label: 'Received', unit: '/min', decimals: 1 },
|
||||
sent_direct: { label: 'Sent', unit: '/min', decimals: 1 },
|
||||
recv_direct: { label: 'Received', unit: '/min', decimals: 1 }
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Formatting Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Format a Unix timestamp as a localized date/time string.
|
||||
* Uses browser language preference for locale (determines 12/24 hour format).
|
||||
* Includes year only for year-period charts.
|
||||
*/
|
||||
function formatTimestamp(timestamp, period) {
|
||||
var date = new Date(timestamp * 1000);
|
||||
var options = {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
};
|
||||
|
||||
// For year view, include year
|
||||
if (period === 'year') {
|
||||
options.year = 'numeric';
|
||||
}
|
||||
|
||||
return date.toLocaleString(undefined, options);
|
||||
// Use browser's language preference (navigator.language), not system locale
|
||||
// Empty array [] or undefined would use OS regional settings instead
|
||||
return date.toLocaleString(navigator.language, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a value with appropriate decimals and unit
|
||||
* Format a numeric value with the appropriate decimals and unit for a metric.
|
||||
*/
|
||||
function formatValue(value, metric) {
|
||||
const config = metricLabels[metric] || { label: metric, unit: '', decimals: 2 };
|
||||
const formatted = value.toFixed(config.decimals);
|
||||
return `${formatted}${config.unit ? ' ' + config.unit : ''}`;
|
||||
function formatMetricValue(value, metric) {
|
||||
var config = METRIC_CONFIG[metric] || { label: metric, unit: '', decimals: 2 };
|
||||
var formatted = value.toFixed(config.decimals);
|
||||
return config.unit ? formatted + ' ' + config.unit : formatted;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Data Point Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Find the closest data point to a timestamp, returning index too
|
||||
* Find the data point closest to the target timestamp.
|
||||
* Returns the point object or null if no points available.
|
||||
*/
|
||||
function findClosestPoint(dataPoints, targetTs) {
|
||||
if (!dataPoints || dataPoints.length === 0) return null;
|
||||
function findClosestDataPoint(dataPoints, targetTimestamp) {
|
||||
if (!dataPoints || dataPoints.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let closestIdx = 0;
|
||||
let minDiff = Math.abs(dataPoints[0].ts - targetTs);
|
||||
var closest = dataPoints[0];
|
||||
var minDiff = Math.abs(closest.ts - targetTimestamp);
|
||||
|
||||
for (let i = 1; i < dataPoints.length; i++) {
|
||||
const diff = Math.abs(dataPoints[i].ts - targetTs);
|
||||
for (var i = 1; i < dataPoints.length; i++) {
|
||||
var diff = Math.abs(dataPoints[i].ts - targetTimestamp);
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff;
|
||||
closestIdx = i;
|
||||
closest = dataPoints[i];
|
||||
}
|
||||
}
|
||||
|
||||
return { point: dataPoints[closestIdx], index: closestIdx };
|
||||
return closest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create or get the indicator circle for an SVG
|
||||
* Parse and cache data points on an SVG element.
|
||||
* Handles HTML entity encoding from server-side JSON embedding.
|
||||
*/
|
||||
function getDataPoints(svg, rawJson) {
|
||||
if (svg._dataPoints) {
|
||||
return svg._dataPoints;
|
||||
}
|
||||
|
||||
try {
|
||||
var json = rawJson.replace(/"/g, '"');
|
||||
svg._dataPoints = JSON.parse(json);
|
||||
return svg._dataPoints;
|
||||
} catch (error) {
|
||||
console.warn('Chart tooltip: failed to parse data points', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SVG Coordinate Utilities
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get and cache the plot area bounds for an SVG chart.
|
||||
* Prefers the clip path rect (defines full plot area) over line path bbox
|
||||
* (which only covers the actual data range).
|
||||
*/
|
||||
function getPlotAreaBounds(svg, fallbackPath) {
|
||||
if (svg._plotArea) {
|
||||
return svg._plotArea;
|
||||
}
|
||||
|
||||
var clipRect = svg.querySelector('clipPath rect');
|
||||
if (clipRect) {
|
||||
svg._plotArea = {
|
||||
x: parseFloat(clipRect.getAttribute('x')),
|
||||
y: parseFloat(clipRect.getAttribute('y')),
|
||||
width: parseFloat(clipRect.getAttribute('width')),
|
||||
height: parseFloat(clipRect.getAttribute('height'))
|
||||
};
|
||||
} else if (fallbackPath) {
|
||||
svg._plotArea = fallbackPath.getBBox();
|
||||
}
|
||||
|
||||
return svg._plotArea;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the chart line path element within an SVG.
|
||||
* Tries multiple selectors for compatibility with different SVG structures.
|
||||
*/
|
||||
function findLinePath(svg) {
|
||||
return (
|
||||
svg.querySelector('#chart-line path') ||
|
||||
svg.querySelector('path#chart-line') ||
|
||||
svg.querySelector('[gid="chart-line"] path') ||
|
||||
svg.querySelector('path[gid="chart-line"]') ||
|
||||
svg.querySelector('path[data-points]')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a screen X coordinate to SVG coordinate space.
|
||||
*/
|
||||
function screenToSvgX(svg, clientX) {
|
||||
var svgRect = svg.getBoundingClientRect();
|
||||
var viewBox = svg.viewBox.baseVal;
|
||||
var scale = viewBox.width / svgRect.width;
|
||||
return (clientX - svgRect.left) * scale + viewBox.x;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a timestamp to an X coordinate within the plot area.
|
||||
*/
|
||||
function timestampToX(timestamp, xStart, xEnd, plotArea) {
|
||||
var relativePosition = (timestamp - xStart) / (xEnd - xStart);
|
||||
return plotArea.x + relativePosition * plotArea.width;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a value to a Y coordinate within the plot area.
|
||||
* SVG Y-axis is inverted (0 at top), so higher values map to lower Y.
|
||||
*/
|
||||
function valueToY(value, yMin, yMax, plotArea) {
|
||||
var ySpan = yMax - yMin || 1;
|
||||
var relativePosition = (value - yMin) / ySpan;
|
||||
return plotArea.y + plotArea.height - relativePosition * plotArea.height;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Tooltip Element
|
||||
// ============================================================================
|
||||
|
||||
var tooltip = null;
|
||||
var tooltipTimeEl = null;
|
||||
var tooltipValueEl = null;
|
||||
|
||||
/**
|
||||
* Create the tooltip DOM element (called once on init).
|
||||
*/
|
||||
function createTooltipElement() {
|
||||
tooltip = document.createElement('div');
|
||||
tooltip.className = 'chart-tooltip';
|
||||
tooltip.innerHTML =
|
||||
'<div class="tooltip-time"></div>' + '<div class="tooltip-value"></div>';
|
||||
document.body.appendChild(tooltip);
|
||||
|
||||
tooltipTimeEl = tooltip.querySelector('.tooltip-time');
|
||||
tooltipValueEl = tooltip.querySelector('.tooltip-value');
|
||||
}
|
||||
|
||||
/**
|
||||
* Update tooltip content and position it near the cursor.
|
||||
*/
|
||||
function showTooltip(event, timeText, valueText) {
|
||||
tooltipTimeEl.textContent = timeText;
|
||||
tooltipValueEl.textContent = valueText;
|
||||
|
||||
var left = event.pageX + CONFIG.tooltipOffset;
|
||||
var top = event.pageY + CONFIG.tooltipOffset;
|
||||
|
||||
// Keep tooltip within viewport
|
||||
var rect = tooltip.getBoundingClientRect();
|
||||
if (left + rect.width > window.innerWidth - CONFIG.viewportPadding) {
|
||||
left = event.pageX - rect.width - CONFIG.tooltipOffset;
|
||||
}
|
||||
if (top + rect.height > window.innerHeight - CONFIG.viewportPadding) {
|
||||
top = event.pageY - rect.height - CONFIG.tooltipOffset;
|
||||
}
|
||||
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
tooltip.classList.add('visible');
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the tooltip.
|
||||
*/
|
||||
function hideTooltip() {
|
||||
tooltip.classList.remove('visible');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Indicator Dot
|
||||
// ============================================================================
|
||||
|
||||
var currentIndicator = null;
|
||||
var currentIndicatorSvg = null;
|
||||
|
||||
/**
|
||||
* Get or create the indicator circle for an SVG chart.
|
||||
* Reuses existing indicator if still on the same chart.
|
||||
*/
|
||||
function getIndicator(svg) {
|
||||
if (currentSvg === svg && currentIndicator) {
|
||||
if (currentIndicatorSvg === svg && currentIndicator) {
|
||||
return currentIndicator;
|
||||
}
|
||||
|
||||
// Remove old indicator if switching charts
|
||||
// Remove indicator from previous chart
|
||||
if (currentIndicator && currentIndicator.parentNode) {
|
||||
currentIndicator.parentNode.removeChild(currentIndicator);
|
||||
}
|
||||
|
||||
// Create new indicator as an SVG circle
|
||||
const indicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
||||
indicator.setAttribute('r', '5');
|
||||
// Create new indicator circle
|
||||
var indicator = document.createElementNS(
|
||||
'http://www.w3.org/2000/svg',
|
||||
'circle'
|
||||
);
|
||||
indicator.setAttribute('r', CONFIG.indicatorRadius);
|
||||
indicator.setAttribute('class', 'chart-indicator');
|
||||
indicator.setAttribute('stroke-width', CONFIG.indicatorStrokeWidth);
|
||||
indicator.style.pointerEvents = 'none';
|
||||
|
||||
// Get theme from SVG data attribute for color
|
||||
const theme = svg.dataset.theme;
|
||||
if (theme === 'dark') {
|
||||
indicator.setAttribute('fill', '#f59e0b');
|
||||
indicator.setAttribute('stroke', '#0f1114');
|
||||
} else {
|
||||
indicator.setAttribute('fill', '#b45309');
|
||||
indicator.setAttribute('stroke', '#ffffff');
|
||||
}
|
||||
indicator.setAttribute('stroke-width', '2');
|
||||
// Apply theme-appropriate colors
|
||||
var theme = svg.dataset.theme === 'dark' ? 'dark' : 'light';
|
||||
indicator.setAttribute('fill', CONFIG.colors[theme].fill);
|
||||
indicator.setAttribute('stroke', CONFIG.colors[theme].stroke);
|
||||
|
||||
svg.appendChild(indicator);
|
||||
currentIndicator = indicator;
|
||||
currentSvg = svg;
|
||||
currentIndicatorSvg = svg;
|
||||
|
||||
return indicator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide and clean up the indicator
|
||||
* Position the indicator at a specific data point.
|
||||
*/
|
||||
function positionIndicator(svg, dataPoint, xStart, xEnd, yMin, yMax, plotArea) {
|
||||
var indicator = getIndicator(svg);
|
||||
var x = timestampToX(dataPoint.ts, xStart, xEnd, plotArea);
|
||||
var y = valueToY(dataPoint.v, yMin, yMax, plotArea);
|
||||
|
||||
indicator.setAttribute('cx', x);
|
||||
indicator.setAttribute('cy', y);
|
||||
indicator.style.display = '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the indicator dot.
|
||||
*/
|
||||
function hideIndicator() {
|
||||
if (currentIndicator) {
|
||||
@@ -144,185 +333,137 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Event Handlers
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Position tooltip near the mouse cursor
|
||||
* Convert a touch event to a mouse-like event object.
|
||||
*/
|
||||
function positionTooltip(event) {
|
||||
const offset = 15;
|
||||
let left = event.pageX + offset;
|
||||
let top = event.pageY + offset;
|
||||
|
||||
// Keep tooltip on screen
|
||||
const rect = tooltip.getBoundingClientRect();
|
||||
const viewportWidth = window.innerWidth;
|
||||
const viewportHeight = window.innerHeight;
|
||||
|
||||
if (left + rect.width > viewportWidth - 10) {
|
||||
left = event.pageX - rect.width - offset;
|
||||
}
|
||||
if (top + rect.height > viewportHeight - 10) {
|
||||
top = event.pageY - rect.height - offset;
|
||||
}
|
||||
|
||||
tooltip.style.left = left + 'px';
|
||||
tooltip.style.top = top + 'px';
|
||||
function touchToMouseEvent(touchEvent) {
|
||||
var touch = touchEvent.touches[0];
|
||||
return {
|
||||
currentTarget: touchEvent.currentTarget,
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
pageX: touch.pageX,
|
||||
pageY: touch.pageY
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move over chart SVG
|
||||
* Handle pointer movement over a chart (mouse or touch).
|
||||
* Finds the closest data point and updates tooltip and indicator.
|
||||
*/
|
||||
function handleMouseMove(event) {
|
||||
const svg = event.currentTarget;
|
||||
const metric = svg.dataset.metric;
|
||||
const period = svg.dataset.period;
|
||||
const xStart = parseInt(svg.dataset.xStart, 10);
|
||||
const xEnd = parseInt(svg.dataset.xEnd, 10);
|
||||
const yMin = parseFloat(svg.dataset.yMin);
|
||||
const yMax = parseFloat(svg.dataset.yMax);
|
||||
function handlePointerMove(event) {
|
||||
var svg = event.currentTarget;
|
||||
|
||||
// Find the path with data-points
|
||||
const path = svg.querySelector('path[data-points]');
|
||||
if (!path) return;
|
||||
// Extract chart metadata
|
||||
var metric = svg.dataset.metric;
|
||||
var period = svg.dataset.period;
|
||||
var xStart = parseInt(svg.dataset.xStart, 10);
|
||||
var xEnd = parseInt(svg.dataset.xEnd, 10);
|
||||
var yMin = parseFloat(svg.dataset.yMin);
|
||||
var yMax = parseFloat(svg.dataset.yMax);
|
||||
|
||||
// Parse and cache data points and path coordinates on first access
|
||||
if (!path._dataPoints) {
|
||||
try {
|
||||
const json = path.dataset.points.replace(/"/g, '"');
|
||||
path._dataPoints = JSON.parse(json);
|
||||
} catch (e) {
|
||||
console.warn('Failed to parse chart data:', e);
|
||||
return;
|
||||
}
|
||||
// Find the line path and data points source
|
||||
var linePath = findLinePath(svg);
|
||||
if (!linePath) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Cache the path's bounding box for coordinate mapping
|
||||
if (!path._pathBox) {
|
||||
path._pathBox = path.getBBox();
|
||||
var rawPoints = linePath.dataset.points || svg.dataset.points;
|
||||
if (!rawPoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathBox = path._pathBox;
|
||||
// Parse data points (cached on svg element)
|
||||
var dataPoints = getDataPoints(svg, rawPoints);
|
||||
if (!dataPoints) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get mouse position in SVG coordinate space
|
||||
const svgRect = svg.getBoundingClientRect();
|
||||
const viewBox = svg.viewBox.baseVal;
|
||||
// Get plot area bounds (cached on svg element)
|
||||
var plotArea = getPlotAreaBounds(svg, linePath);
|
||||
if (!plotArea) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Convert screen X coordinate to SVG coordinate
|
||||
const scaleX = viewBox.width / svgRect.width;
|
||||
const svgX = (event.clientX - svgRect.left) * scaleX + viewBox.x;
|
||||
// Convert screen position to timestamp
|
||||
var svgX = screenToSvgX(svg, event.clientX);
|
||||
var relativeX = Math.max(0, Math.min(1, (svgX - plotArea.x) / plotArea.width));
|
||||
var targetTimestamp = xStart + relativeX * (xEnd - xStart);
|
||||
|
||||
// Calculate relative X position within the plot area (pathBox)
|
||||
const relX = (svgX - pathBox.x) / pathBox.width;
|
||||
// Find and display closest data point
|
||||
var closestPoint = findClosestDataPoint(dataPoints, targetTimestamp);
|
||||
if (!closestPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clamp to plot area bounds
|
||||
const clampedRelX = Math.max(0, Math.min(1, relX));
|
||||
showTooltip(
|
||||
event,
|
||||
formatTimestamp(closestPoint.ts, period),
|
||||
formatMetricValue(closestPoint.v, metric)
|
||||
);
|
||||
|
||||
// Map relative X position to timestamp using the chart's X-axis range
|
||||
const targetTs = xStart + clampedRelX * (xEnd - xStart);
|
||||
|
||||
// Find closest data point by timestamp
|
||||
const result = findClosestPoint(path._dataPoints, targetTs);
|
||||
if (!result) return;
|
||||
|
||||
const { point } = result;
|
||||
|
||||
// Update tooltip content
|
||||
tooltipTime.textContent = formatTime(point.ts, period);
|
||||
tooltipValue.textContent = formatValue(point.v, metric);
|
||||
|
||||
// Position and show tooltip
|
||||
positionTooltip(event);
|
||||
tooltip.classList.add('visible');
|
||||
|
||||
// Position the indicator at the data point
|
||||
const indicator = getIndicator(svg);
|
||||
|
||||
// Calculate X position: map timestamp to path coordinate space
|
||||
const pointRelX = (point.ts - xStart) / (xEnd - xStart);
|
||||
const indicatorX = pathBox.x + pointRelX * pathBox.width;
|
||||
|
||||
// Calculate Y position using the actual Y-axis range from the chart
|
||||
const ySpan = yMax - yMin || 1;
|
||||
// Y is inverted in SVG (0 at top)
|
||||
const pointRelY = 1 - (point.v - yMin) / ySpan;
|
||||
const indicatorY = pathBox.y + pointRelY * pathBox.height;
|
||||
|
||||
indicator.setAttribute('cx', indicatorX);
|
||||
indicator.setAttribute('cy', indicatorY);
|
||||
indicator.style.display = '';
|
||||
positionIndicator(svg, closestPoint, xStart, xEnd, yMin, yMax, plotArea);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide tooltip when leaving chart
|
||||
* Handle pointer leaving the chart area.
|
||||
*/
|
||||
function handleMouseLeave() {
|
||||
tooltip.classList.remove('visible');
|
||||
function handlePointerLeave() {
|
||||
hideTooltip();
|
||||
hideIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle touch events for mobile
|
||||
* Handle touch start event.
|
||||
*/
|
||||
function handleTouchStart(event) {
|
||||
// Convert touch to mouse-like event
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
currentTarget: event.currentTarget,
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
pageX: touch.pageX,
|
||||
pageY: touch.pageY
|
||||
};
|
||||
|
||||
handleMouseMove(mouseEvent);
|
||||
}
|
||||
|
||||
function handleTouchMove(event) {
|
||||
const touch = event.touches[0];
|
||||
const mouseEvent = {
|
||||
currentTarget: event.currentTarget,
|
||||
clientX: touch.clientX,
|
||||
clientY: touch.clientY,
|
||||
pageX: touch.pageX,
|
||||
pageY: touch.pageY
|
||||
};
|
||||
|
||||
handleMouseMove(mouseEvent);
|
||||
}
|
||||
|
||||
function handleTouchEnd() {
|
||||
handleMouseLeave();
|
||||
handlePointerMove(touchToMouseEvent(event));
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize tooltips for all chart SVGs
|
||||
* Handle touch move event.
|
||||
*/
|
||||
function initTooltips() {
|
||||
// Find all chart SVGs with data attributes
|
||||
const chartSvgs = document.querySelectorAll('svg[data-metric][data-period]');
|
||||
function handleTouchMove(event) {
|
||||
handlePointerMove(touchToMouseEvent(event));
|
||||
}
|
||||
|
||||
chartSvgs.forEach(function(svg) {
|
||||
// Mouse events for desktop
|
||||
svg.addEventListener('mousemove', handleMouseMove);
|
||||
svg.addEventListener('mouseleave', handleMouseLeave);
|
||||
// ============================================================================
|
||||
// Initialization
|
||||
// ============================================================================
|
||||
|
||||
// Touch events for mobile
|
||||
/**
|
||||
* Attach event listeners to all chart SVG elements.
|
||||
*/
|
||||
function initializeChartTooltips() {
|
||||
createTooltipElement();
|
||||
|
||||
var chartSvgs = document.querySelectorAll('svg[data-metric][data-period]');
|
||||
|
||||
chartSvgs.forEach(function (svg) {
|
||||
// Desktop mouse events
|
||||
svg.addEventListener('mousemove', handlePointerMove);
|
||||
svg.addEventListener('mouseleave', handlePointerLeave);
|
||||
|
||||
// Mobile touch events
|
||||
svg.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||
svg.addEventListener('touchmove', handleTouchMove, { passive: true });
|
||||
svg.addEventListener('touchend', handleTouchEnd);
|
||||
svg.addEventListener('touchcancel', handleTouchEnd);
|
||||
svg.addEventListener('touchend', handlePointerLeave);
|
||||
svg.addEventListener('touchcancel', handlePointerLeave);
|
||||
|
||||
// Set cursor to indicate interactivity
|
||||
// Visual affordance for interactivity
|
||||
svg.style.cursor = 'crosshair';
|
||||
|
||||
// Allow vertical scrolling but prevent horizontal pan on mobile
|
||||
svg.style.touchAction = 'pan-y';
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize when DOM is ready
|
||||
// Run initialization when DOM is ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initTooltips);
|
||||
document.addEventListener('DOMContentLoaded', initializeChartTooltips);
|
||||
} else {
|
||||
initTooltips();
|
||||
initializeChartTooltips();
|
||||
}
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user