///|
fn dabs(x : Double) -> Double {
if x < 0.0 {
-x
} else {
x
}
}
///|
fn two_digits(n : Int32) -> String {
if n < 10 {
"0\{n}"
} else {
n.to_string()
}
}
///|
pub fn format_meters(x : Double) -> String {
let negative = x < 0.0
let cents = @math.round(dabs(x) * 100.0).to_int()
let whole = cents / 100
let frac = cents % 100
let sign = if negative { "-" } else { "" }
"\{sign}\{whole}.\{two_digits(frac)}"
}
///|
fn tz_label(tz : Double) -> String {
let sign = if tz < 0.0 { "-" } else { "+" }
let hours = @math.floor(dabs(tz)).to_int()
let minutes = @math.round((dabs(tz) - hours.to_double()) * 60.0).to_int()
if minutes == 0 {
"UTC\{sign}\{hours}"
} else {
"UTC\{sign}\{hours}:\{two_digits(minutes)}"
}
}
///|
fn format_clock(hour : Double) -> String {
let h = hour - 24.0 * @math.floor(hour / 24.0)
let hh = @math.floor(h).to_int()
let mm = @math.floor((h - hh.to_double()) * 60.0).to_int()
"\{two_digits(hh)}:\{two_digits(mm)}"
}
///|
fn lat_label(lat : Double) -> String {
let ns = if lat >= 0.0 { "北纬" } else { "南纬" }
"\{ns} \{format_meters(dabs(lat))}°"
}
///|
fn lon_label(lon : Double) -> String {
let ew = if lon >= 0.0 { "东经" } else { "西经" }
"\{ew} \{format_meters(dabs(lon))}°"
}
///|
/// Render an ASCII tide curve over a window.
pub fn render_curve(
station : Station,
start_jd : Double,
duration_hours : Double,
step_hours : Double,
tz : Double,
) -> String {
let samples = @math.floor(duration_hours / step_hours).to_int() + 1
let heights = Array(capacity=samples)
let times = Array(capacity=samples)
for i = 0; i < samples; i = i + 1 {
let jd = start_jd + i.to_double() * step_hours / 24.0
times.push(jd)
heights.push(tide_height(station, jd))
}
let mut lo = heights[0]
let mut hi = heights[0]
for h in heights {
if h < lo {
lo = h
}
if h > hi {
hi = h
}
}
let span = if hi - lo < 0.0001 { 1.0 } else { hi - lo }
let rows = 15
let grid = Array::makei(rows, fn(_) { Array::makei(samples, fn(_) { '.' }) })
// Mark extremes first so they stay visible even when the curve overlaps.
let extremes = find_extremes(station, start_jd, duration_hours, step_hours)
for e in extremes {
let col = @math.round((e.time_jd - start_jd) / (step_hours / 24.0)).to_int()
if col >= 0 && col < samples {
let row = @math.round((hi - e.height) / span * (rows - 1).to_double()).to_int()
let clamped = if row < 0 {
0
} else if row >= rows {
rows - 1
} else {
row
}
grid[clamped][col] = if e.kind == ExtremeKind::High { 'H' } else { 'L' }
}
}
// Draw the curve.
for i = 0; i < samples; i = i + 1 {
let row = @math.round((hi - heights[i]) / span * (rows - 1).to_double()).to_int()
let clamped = if row < 0 { 0 } else if row >= rows { rows - 1 } else { row }
if grid[clamped][i] == '.' {
grid[clamped][i] = '#'
}
}
let lines = Array(capacity=rows + 4)
lines.push(
"潮高范围:\{format_meters(hi)} m(顶)到 \{format_meters(lo)} m(底),每格 \{format_meters(step_hours)} 小时",
)
for r = 0; r < rows; r = r + 1 {
lines.push(String::from_iter(grid[r].iter()))
}
// Time axis: one label every six hours.
let tick_hours = 6.0
let labels = Array(
capacity=@math.floor(duration_hours / tick_hours).to_int() + 2,
)
let mut k = 0
for t = 0.0; t <= duration_hours; t = t + tick_hours {
labels.push(format_local_time(start_jd + t / 24.0, tz))
k = k + 1
}
lines.push(labels.join(" "))
lines.join("\n")
}
///|
/// Render a full Markdown prediction report.
pub fn render_report(p : Prediction) -> String {
let lines = Array(capacity=80)
lines.push("# MoonTide 潮汐预测")
lines.push("")
lines.push("- 站点:\{p.station.name}(\{p.station.id})")
lines.push(
"- 位置:\{lat_label(p.station.lat)},\{lon_label(p.station.lon)}",
)
lines.push(
"- 时间窗:\{format_local_datetime(p.start_jd, p.tz)} 起 \{format_meters(p.duration_hours)} 小时(\{tz_label(p.tz)})",
)
lines.push("- 天文期:\{p.spring_neap}")
lines.push("- 潮差:\{format_meters(p.tidal_range)} m")
lines.push(
"- 日出/日落:\{format_clock(p.sunrise_hour)} / \{format_clock(p.sunset_hour)}(近似)",
)
lines.push("")
lines.push("## 高低潮")
lines.push("")
if p.extremes.is_empty() {
lines.push("本时间窗内未识别到高低潮。")
} else {
lines.push("| 时刻 | 类型 | 潮高 |")
lines.push("| --- | --- | --- |")
for e in p.extremes {
let kind = if e.kind == ExtremeKind::High { "高潮" } else { "低潮" }
lines.push(
"| \{format_local_datetime(e.time_jd, p.tz)} | \{kind} | \{format_meters(e.height)} m |",
)
}
}
lines.push("")
lines.push("## 活动建议")
lines.push("")
for activity in p.activities {
lines.push("### \{activity.title}")
lines.push("")
lines.push(activity.detail)
if !activity.windows.is_empty() {
lines.push("")
lines.push("推荐时段:\{activity.windows.join("、")}")
}
lines.push("")
}
lines.push("## 潮汐曲线")
lines.push("")
lines.push("```text")
lines.push(render_curve(p.station, p.start_jd, p.duration_hours, 1.0, p.tz))
lines.push("```")
lines.push("")
lines.push(
"> 内置站点常数仅用于演示,正式使用请以当地《潮汐表》或海洋站发布的调和常数为准。",
)
lines.join("\n")
}
///|
/// Render a prediction as compact JSON-compatible text.
pub fn render_json(p : Prediction) -> String {
let extremes = Array(capacity=p.extremes.length())
for e in p.extremes {
let kind = if e.kind == ExtremeKind::High { "high" } else { "low" }
extremes.push(
"{\"time\":\"\{format_local_datetime(e.time_jd, p.tz)}\",\"kind\":\"\{kind}\",\"height\":\{format_meters(e.height)}}",
)
}
let activities = Array(capacity=p.activities.length())
for a in p.activities {
let windows = a.windows.map(w => "\"\{w}\"").join(",")
activities.push(
"{\"title\":\"\{a.title}\",\"detail\":\"\{a.detail}\",\"windows\":[\{windows}]}",
)
}
"{\"station\":\"\{p.station.name}\",\"tidal_range\":\{format_meters(p.tidal_range)},\"spring_neap\":\"\{p.spring_neap}\",\"extremes\":[\{extremes.join(",")}],\"activities\":[\{activities.join(",")}]}"
}