Skip to main content

sidereon_core/observation_qc/
report_html.rs

1use std::fmt::Write as _;
2
3use crate::format::fmtnum::fixed_decimals;
4
5use super::report_text::{format_epoch_time, format_vec3_m, severity_label, system_rows};
6use super::{IntervalSource, ObservationQcHeader, ObservationQcReport};
7
8pub fn render_html(report: &ObservationQcReport) -> String {
9    let mut out = String::new();
10    out.push_str(
11        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>RINEX Observation QC</title>",
12    );
13    out.push_str("<style>");
14    out.push_str(
15        "body{font-family:system-ui,-apple-system,Segoe UI,sans-serif;margin:24px;color:#1f2933;background:#f7f9fb}\
16         h1{font-size:22px;margin:0 0 18px}h2{font-size:16px;margin:22px 0 10px}\
17         table{border-collapse:collapse;width:100%;background:#fff}th,td{border:1px solid #d8dee6;padding:6px 8px;text-align:right}\
18         th{background:#e9eef4;font-weight:600}td:first-child,td:nth-child(2),th:first-child,th:nth-child(2),.text{text-align:left}\
19         dl{display:grid;grid-template-columns:max-content 1fr;gap:6px 14px;background:#fff;border:1px solid #d8dee6;padding:12px}\
20         dt{font-weight:600;color:#52616f}dd{margin:0}.section{max-width:1180px}",
21    );
22    out.push_str("</style></head><body><main class=\"section\">");
23    out.push_str("<h1>RINEX Observation QC +qc Summary</h1>");
24    write_header(&mut out, report);
25    write_system_table(&mut out, report);
26    write_findings(&mut out, report);
27    out.push_str("</main></body></html>");
28    out
29}
30
31fn write_header(out: &mut String, report: &ObservationQcReport) {
32    out.push_str("<h2>Header</h2><dl>");
33    let header = &report.header;
34    push_detail(out, "Marker name", header.marker_name.as_deref());
35    push_detail(out, "Marker number", header.marker_number.as_deref());
36    push_detail(out, "Marker type", header.marker_type.as_deref());
37    push_detail(out, "Receiver", format_receiver(header).as_deref());
38    push_detail(out, "Antenna", format_antenna(header).as_deref());
39    push_detail(
40        out,
41        "Position XYZ m",
42        header.approx_position_m.map(format_vec3_m).as_deref(),
43    );
44    push_detail(
45        out,
46        "Antenna HEN m",
47        header.antenna_delta_hen_m.map(format_vec3_m).as_deref(),
48    );
49    push_detail(
50        out,
51        "Time first",
52        header
53            .time_of_first_obs
54            .as_ref()
55            .map(format_epoch_time)
56            .as_deref(),
57    );
58    push_detail(
59        out,
60        "Time last",
61        header
62            .time_of_last_obs
63            .as_ref()
64            .map(format_epoch_time)
65            .as_deref(),
66    );
67    push_detail(out, "Interval s", format_interval(report).as_deref());
68    push_detail(
69        out,
70        "Duration s",
71        header
72            .duration_s
73            .map(|value| fixed_decimals(value, 1))
74            .as_deref(),
75    );
76    out.push_str("</dl>");
77}
78
79fn write_system_table(out: &mut String, report: &ObservationQcReport) {
80    out.push_str("<h2>Per-Constellation</h2><table><thead><tr>");
81    for heading in [
82        "Sys",
83        "Name",
84        "Sats",
85        "Epochs",
86        "Obs",
87        "Expect",
88        "Comp",
89        "SNR mean/min by band",
90        "MP1 RMS",
91        "MP2 RMS",
92        "Slips",
93        "Gaps",
94        "Gap s",
95    ] {
96        let _ = write!(out, "<th>{}</th>", escape_html(heading));
97    }
98    out.push_str("</tr></thead><tbody>");
99    let rows = system_rows(report);
100    if rows.is_empty() {
101        out.push_str("<tr><td class=\"text\" colspan=\"13\">None</td></tr>");
102    } else {
103        for row in rows {
104            out.push_str("<tr>");
105            cell(out, &row.system_letter.to_string(), true);
106            cell(out, row.system_name, true);
107            cell(out, &row.satellites_seen.to_string(), false);
108            cell(out, &row.epochs.to_string(), false);
109            cell(out, &row.observations.to_string(), false);
110            cell(out, &row.expected.to_string(), false);
111            cell(out, &row.completeness, false);
112            cell(out, &row.snr, true);
113            cell(out, &row.mp1, false);
114            cell(out, &row.mp2, false);
115            cell(out, &row.slips.to_string(), false);
116            cell(out, &row.gaps.to_string(), false);
117            cell(out, &row.gap_s, false);
118            out.push_str("</tr>");
119        }
120    }
121    out.push_str("</tbody></table>");
122}
123
124fn write_findings(out: &mut String, report: &ObservationQcReport) {
125    out.push_str("<h2>Findings</h2><table><thead><tr>");
126    for heading in ["Code", "Severity", "Spec ref"] {
127        let _ = write!(out, "<th>{}</th>", escape_html(heading));
128    }
129    out.push_str("</tr></thead><tbody>");
130    if report.lint_findings.is_empty() {
131        out.push_str("<tr><td class=\"text\" colspan=\"3\">None</td></tr>");
132    } else {
133        for finding in &report.lint_findings {
134            out.push_str("<tr>");
135            cell(out, &finding.code, true);
136            cell(out, severity_label(finding.severity), true);
137            cell(out, &finding.spec_ref, true);
138            out.push_str("</tr>");
139        }
140    }
141    out.push_str("</tbody></table>");
142}
143
144fn push_detail(out: &mut String, label: &str, value: Option<&str>) {
145    if let Some(value) = value.filter(|value| !value.is_empty()) {
146        let _ = write!(
147            out,
148            "<dt>{}</dt><dd>{}</dd>",
149            escape_html(label),
150            escape_html(value)
151        );
152    }
153}
154
155fn cell(out: &mut String, value: &str, text: bool) {
156    if text {
157        let _ = write!(out, "<td class=\"text\">{}</td>", escape_html(value));
158    } else {
159        let _ = write!(out, "<td>{}</td>", escape_html(value));
160    }
161}
162
163fn format_receiver(header: &ObservationQcHeader) -> Option<String> {
164    header.receiver.as_ref().and_then(|receiver| {
165        join_non_empty([
166            receiver.number.as_str(),
167            receiver.receiver_type.as_str(),
168            receiver.version.as_str(),
169        ])
170    })
171}
172
173fn format_antenna(header: &ObservationQcHeader) -> Option<String> {
174    header.antenna.as_ref().and_then(|antenna| {
175        join_non_empty([antenna.number.as_str(), antenna.antenna_type.as_str()])
176    })
177}
178
179fn join_non_empty<const N: usize>(parts: [&str; N]) -> Option<String> {
180    let joined = parts
181        .into_iter()
182        .filter(|part| !part.is_empty())
183        .collect::<Vec<_>>()
184        .join(" / ");
185    (!joined.is_empty()).then_some(joined)
186}
187
188fn format_interval(report: &ObservationQcReport) -> Option<String> {
189    report.interval_s.map(|interval_s| {
190        format!(
191            "{} ({})",
192            fixed_decimals(interval_s, 3),
193            interval_source_label(report.interval_source)
194        )
195    })
196}
197
198fn interval_source_label(source: IntervalSource) -> &'static str {
199    match source {
200        IntervalSource::Override => "override",
201        IntervalSource::Header => "header",
202        IntervalSource::Inferred => "inferred",
203        IntervalSource::Unresolved => "unresolved",
204    }
205}
206
207fn escape_html(value: &str) -> String {
208    let mut escaped = String::with_capacity(value.len());
209    for ch in value.chars() {
210        match ch {
211            '&' => escaped.push_str("&amp;"),
212            '<' => escaped.push_str("&lt;"),
213            '>' => escaped.push_str("&gt;"),
214            '"' => escaped.push_str("&quot;"),
215            '\'' => escaped.push_str("&#39;"),
216            _ => escaped.push(ch),
217        }
218    }
219    escaped
220}