Files

503 lines
21 KiB
QML

import QtQuick 2.0
import Sailfish.Silica 1.0
import "../js/tides.js" as Tides
Page {
id: page
property var port
property int portIndex: -1
property bool custom: false
property int days: appSettings.days
property string unit: appSettings.unit
property var nearPort: null
property real nearKm: 0
ListModel { id: eventsModel }
Timer {
interval: 600000 // aggiorna ogni 10 minuti
repeat: true
running: true
onTriggered: refresh()
}
onPortChanged: init()
function init() {
nearPort = null
nearKm = 0
console.log("[tide] DetailPage.init custom:", custom, "port:", port ? port[0] : "UNDEFINED")
if (!port) {
console.log("[tide] DetailPage.init: port undefined, niente da caricare")
return
}
if (custom && port.length > 5 && port[5] !== "") {
var np = Tides.nearestPort(port[2], port[3])
if (np.port !== null) {
nearPort = np.port
nearKm = np.km
}
}
console.log("[tide] init nearPort:", nearPort ? nearPort[0] + " (" + nearKm.toFixed(1) + " km)" : "null")
refresh()
}
function openPort(port) {
console.log("[tide] openPort:", port ? port[0] : "undefined")
pageStack.push(Qt.resolvedUrl("DetailPage.qml"),
{ port: port, portIndex: Tides.PORTS.indexOf(port), custom: false })
}
function openCalendar() {
console.log("[tide] openCalendar:", page.port ? page.port[0] : "undefined")
pageStack.push(Qt.resolvedUrl("CalendarPage.qml"),
{ port: page.port })
}
function setPreferred() {
if (!port) return
console.log("[tide] setPreferred:", port[0])
appSettings.preferredPort = port[0]
// se è un comune (custom) salva le coordinate per la cover
if (custom) {
appSettings.preferredLat = port[2]
appSettings.preferredLon = port[3]
} else {
appSettings.preferredLat = -1
appSettings.preferredLon = -1
}
pageStack.replace(Qt.resolvedUrl("MainPage.qml"))
}
function openSettings() {
console.log("[tide] openSettings")
pageStack.push(Qt.resolvedUrl("SettingsPage.qml"))
}
function openWebcams() {
if (!port) return
// Mappa webcam pubblica di Windy centrata sul porto (nessuna API key)
var url = "https://www.windy.com/-Webcams/webcams/"
+ port[2] + "/" + port[3]
console.log("[tide] openWebcams:", url)
Qt.openUrlExternally(url)
}
function refresh() {
if (!port) {
console.log("[tide] refresh: port UNDEFINED")
return
}
console.log("[tide] refresh:", port[0], "days:", days, "unit:", unit)
try {
// eventi
eventsModel.clear()
var ex = Tides.extrema(port, new Date(), days)
var n = Math.min(ex.length, 6)
for (var i = 0; i < n; i++) {
eventsModel.append({
kind: ex[i].kind,
date: Tides.fmtDate(ex[i].t, page.dayNames),
time: Tides.fmtHM(ex[i].t),
height: Tides.fmtH(ex[i].h, unit)
})
}
// astro
var mp = Tides.moonPhase(new Date())
moonLabel.text = mp.icon + " " + moonNames[mp.idx] + " (" + qsTr("age %1 d").arg(mp.age.toFixed(1)) + ")"
var st = Tides.sunTimes(port[2], new Date())
sunLabel.text = Tides.fmtHM(new Date(0, 0, 0, st.rise, 0)) + " / " + Tides.fmtHM(new Date(0, 0, 0, st.set, 0)) + " " + qsTr("(solar time)")
rangeLabel.text = "±" + ((port[4][1][0] + port[4][2][0]) * 2).toFixed(2) + " m"
// info curva
var f = port[5] || ""
if (f.indexOf("near:") === 0) {
var p = f.split(":")
curveInfo.text = qsTr("Constants of the port of %1 (distance %2°)").arg(p[1]).arg(p[2])
} else if (f === "synth") {
curveInfo.text = qsTr("Estimated constants from lat/lon (synthetic model)")
} else {
curveInfo.text = qsTr("Z₀ %1 m · 7 harmonic constituents").arg(port[4][0].toFixed(2))
}
cv.requestPaint()
seaState()
} catch (e) {
console.log("[tide] refresh error:", e)
}
}
function seaFail() {
seaLabel.text = qsTranslate("Sea", "Unavailable (offline)")
seaLabel.color = Theme.secondaryColor
seaSub.text = ""
}
function seaState() {
if (!port) return
console.log("[tide] seaState fetch:", port[0])
var xhr = new XMLHttpRequest()
xhr.open("GET",
"https://marine-api.open-meteo.com/v1/marine?latitude=" + port[2]
+ "&longitude=" + port[3]
+ "&daily=wave_height_max&timezone=auto&forecast_days=3",
true)
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
console.log("[tide] seaState status:", xhr.status)
if (xhr.status === 200) {
try {
var j = JSON.parse(xhr.responseText)
var wh = j.daily.wave_height_max
if (wh && wh.length >= 2) {
// riga 1: oggi
var i0 = Tides.douglas(wh[0])
seaLabel.text = qsTranslate("Sea", "Today") + ": " + seaNames[i0]
+ " · " + Tides.fmtH(wh[0], page.unit)
seaLabel.color = i0 >= 5 ? Theme.errorColor
: (i0 >= 4 ? Theme.secondaryColor : Theme.highlightColor)
// riga 2: domani e terzo giorno
var s = qsTranslate("Sea", "Tomorrow") + ": " + seaNames[Tides.douglas(wh[1])]
+ " (" + Tides.fmtH(wh[1], page.unit) + ")"
if (wh.length > 2) {
var d2 = new Date()
d2.setDate(d2.getDate() + 2)
s += " · " + page.dayNames[d2.getDay()] + ": "
+ seaNames[Tides.douglas(wh[2])]
+ " (" + Tides.fmtH(wh[2], page.unit) + ")"
}
seaSub.text = s
seaSub.color = Theme.secondaryColor
return
}
} catch (e) {
console.log("[tide] seaState parse err:", e)
}
}
seaFail()
}
}
xhr.send()
}
property var moonNames: [
qsTr("New moon"), qsTr("Waxing crescent"), qsTr("First quarter"), qsTr("Waxing gibbous"),
qsTr("Full moon"), qsTr("Waning gibbous"), qsTr("Last quarter"), qsTr("Waning crescent")
]
// abbreviazioni giorni localizzate (context unico "Tide")
property var dayNames: [
qsTranslate("Tide", "Sun"), qsTranslate("Tide", "Mon"),
qsTranslate("Tide", "Tue"), qsTranslate("Tide", "Wed"),
qsTranslate("Tide", "Thu"), qsTranslate("Tide", "Fri"),
qsTranslate("Tide", "Sat")
]
// stato del mare: scala Douglas (0-9) — context "Sea" separato:
// "High" qui = "Grosso", non "Alta" degli eventi
property var seaNames: [
qsTranslate("Sea", "Calm"), qsTranslate("Sea", "Calm rippled"),
qsTranslate("Sea", "Smooth wavelets"), qsTranslate("Sea", "Slight"),
qsTranslate("Sea", "Moderate"), qsTranslate("Sea", "Rough"),
qsTranslate("Sea", "Very rough"), qsTranslate("Sea", "High"),
qsTranslate("Sea", "Very high"), qsTranslate("Sea", "Phenomenal")
]
SilicaFlickable {
id: flick
anchors.fill: parent
contentHeight: col.height
Column {
id: col
width: parent.width
PageHeader {
title: page.port ? page.port[0] : ""
description: page.port
? (page.port[1] !== "" ? page.port[1] + " · " : "")
+ qsTr("lat %1°").arg(page.port[2].toFixed(2))
: ""
}
// Card porto più vicino (solo città cercate) — rosso
Rectangle {
id: nearCard
visible: page.custom && page.nearPort !== null
width: parent.width - 2 * Theme.horizontalPageMargin
anchors.horizontalCenter: parent.horizontalCenter
height: visible ? Theme.itemSizeSmall : 0
radius: Theme.paddingSmall
color: "transparent"
border.width: 2
border.color: Theme.errorColor
Label {
anchors.fill: parent
anchors.margins: Theme.paddingSmall
text: page.nearPort
? qsTr("📍 Nearest port: %1 — %2 · ±%3 m")
.arg(page.nearPort[0])
.arg(page.nearKm < 222
? (page.nearKm / 111).toFixed(1) + "°"
: Math.round(page.nearKm) + " km")
.arg(((page.nearPort[4][1][0] + page.nearPort[4][2][0]) * 2).toFixed(2))
: ""
color: Theme.errorColor
font.pixelSize: Theme.fontSizeSmall
wrapMode: Text.Wrap
verticalAlignment: Text.AlignVCenter
}
MouseArea {
anchors.fill: parent
onClicked: page.openPort(page.nearPort)
}
}
// Intervallo (chips 24/48/72)
Row {
spacing: Theme.paddingMedium
anchors.horizontalCenter: parent.horizontalCenter
Repeater {
model: [1, 2, 3]
delegate: Rectangle {
width: 110
height: Theme.itemSizeSmall
radius: Theme.paddingMedium
color: page.days === modelData ? Theme.highlightColor : Theme.rgba(Theme.highlightBackgroundColor, 0.2)
Label {
anchors.centerIn: parent
text: modelData === 1 ? qsTr("24 h") : (modelData === 2 ? qsTr("48 h") : qsTr("72 h"))
color: page.days === modelData ? Theme.primaryColor : Theme.secondaryColor
}
MouseArea {
anchors.fill: parent
onClicked: {
page.days = modelData
appSettings.days = modelData
refresh()
}
}
}
}
}
Item { width: parent.width; height: Theme.paddingMedium }
// Grafico
Rectangle {
id: cvBox
width: parent.width - 2 * Theme.horizontalPageMargin
anchors.horizontalCenter: parent.horizontalCenter
height: 250
color: "transparent"
Canvas {
id: cv
anchors.fill: parent
onPaint: draw()
onWidthChanged: requestPaint()
}
MouseArea {
id: cvMouse
anchors.fill: parent
onClicked: {
var t = page.port ? Tides.tide(page.port, new Date(nowBase + (mouse.x / width) * (page.days * 86400000))) : 0
var d = new Date(nowBase + (mouse.x / width) * (page.days * 86400000))
tip.text = Tides.fmtDate(d.getTime(), page.dayNames) + " " + Tides.fmtHM(d.getTime()) + " → " + Tides.fmtH(t, page.unit)
tip.visible = true
tipTimer.restart()
}
}
property double nowBase: new Date().getTime()
Label {
id: tip
visible: false
anchors.top: parent.top
anchors.left: parent.left
anchors.margins: Theme.paddingSmall
color: Theme.highlightColor
font.pixelSize: Theme.fontSizeSmall
}
Timer { id: tipTimer; interval: 2500; onTriggered: tip.visible = false }
}
Label {
id: curveInfo
width: parent.width - 2 * Theme.horizontalPageMargin
anchors.horizontalCenter: parent.horizontalCenter
color: Theme.secondaryColor
font.pixelSize: Theme.fontSizeSmall
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
}
Item { width: parent.width; height: Theme.paddingMedium }
SectionHeader { text: qsTr("Next high and low tides") }
Repeater {
model: eventsModel
delegate: Item {
width: parent.width
height: Theme.itemSizeSmall
Label {
id: kindLbl
text: model.kind === "high" ? "▲ " + qsTr("High") : "▼ " + qsTr("Low")
color: model.kind === "high" ? Theme.highlightColor : Theme.errorColor
font.pixelSize: Theme.fontSizeSmall
anchors.left: parent.left
anchors.leftMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
width: 130
truncationMode: TruncationMode.Fade
}
Label {
text: model.date + " " + model.time
color: Theme.primaryColor
anchors.left: kindLbl.right
anchors.leftMargin: Theme.paddingSmall
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin + 70
anchors.verticalCenter: parent.verticalCenter
truncationMode: TruncationMode.Fade
}
Label {
text: model.height
color: Theme.primaryColor
font.pixelSize: Theme.fontSizeSmall
anchors.right: parent.right
anchors.rightMargin: Theme.horizontalPageMargin
anchors.verticalCenter: parent.verticalCenter
}
}
}
// Stato del mare (scala Douglas, previsione onde Open-Meteo,
// oggi + domani + terzo giorno) — sotto la tabella alte/basse
Item { width: parent.width; height: Theme.itemSizeSmall
Label { text: "🌊 " + qsTranslate("Sea", "Sea state"); color: Theme.secondaryColor; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
Label { id: seaLabel; text: "…"; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin + 130; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter; horizontalAlignment: Text.AlignRight; truncationMode: TruncationMode.Fade }
}
Item { width: parent.width; height: Theme.itemSizeSmall
Label { id: seaSub; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter; horizontalAlignment: Text.AlignRight; font.pixelSize: Theme.fontSizeSmall; truncationMode: TruncationMode.Fade }
}
SectionHeader { text: qsTr("Astro") }
Item { width: parent.width; height: Theme.itemSizeSmall
Label { text: "🌙 " + qsTr("Moon"); color: Theme.secondaryColor; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
Label { id: moonLabel; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
}
Item { width: parent.width; height: Theme.itemSizeSmall
Label { text: "☀️ " + qsTr("Sunrise / sunset"); color: Theme.secondaryColor; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
Label { id: sunLabel; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter; font.pixelSize: Theme.fontSizeSmall }
}
Item { width: parent.width; height: Theme.itemSizeSmall
Label { text: qsTr("Typical range"); color: Theme.secondaryColor; anchors.left: parent.left; anchors.leftMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
Label { id: rangeLabel; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter }
}
Item { width: parent.width; height: Theme.paddingLarge }
}
PullDownMenu {
MenuItem {
text: qsTr("Set as preferred port")
visible: page.port && page.port[0] !== appSettings.preferredPort
onClicked: page.setPreferred()
}
MenuItem {
text: qsTr("30-day calendar")
visible: page.port !== undefined
onClicked: page.openCalendar()
}
MenuItem {
text: qsTr("Webcams")
visible: page.port !== undefined
onClicked: page.openWebcams()
}
MenuItem {
text: qsTr("Settings")
onClicked: page.openSettings()
}
}
}
// disegno della curva (stesso algoritmo del mockup)
function draw() {
if (!port || cv.width < 10) return
var ctx = cv.getContext("2d")
ctx.clearRect(0, 0, cv.width, cv.height)
var now = new Date()
var span = days * 86400000
var w = cv.width, h = cv.height
var pad = 12
var steps = Math.round(span / (10 * 60000))
var min = 1e9, max = -1e9, i, t, ht
var pts = []
for (i = 0; i <= steps; i++) {
t = now.getTime() + i * span / steps
ht = Tides.tide(port, new Date(t))
pts.push([t, ht])
if (ht < min) min = ht
if (ht > max) max = ht
}
min -= 0.05; max += 0.05
function X(t) { return pad + (t - now.getTime()) / span * (w - 2 * pad) }
function Y(hv) { return h - pad - (hv - min) / (max - min) * (h - 2 * pad) }
// griglia ogni 6 h
ctx.strokeStyle = Theme.rgba(Theme.primaryColor, 0.12)
ctx.fillStyle = Theme.rgba(Theme.secondaryColor, 0.8)
ctx.font = "10px sans-serif"
ctx.lineWidth = 1
var gsteps = Math.round(span / 3600000 / 6)
for (i = 0; i <= gsteps; i++) {
t = now.getTime() + i * 6 * 3600000
var x = X(t)
ctx.beginPath(); ctx.moveTo(x, pad); ctx.lineTo(x, h - pad); ctx.stroke()
var d = new Date(t)
ctx.fillText((d.getHours() < 10 ? "0" : "") + d.getHours() + ":00", x + 3, h - 3)
}
// Z0 tratteggiata (disegno manuale: setLineDash non garantito su Qt 5.6)
ctx.strokeStyle = Theme.rgba(Theme.secondaryColor, 0.6)
var zy = Y(port[4][0])
for (var zx = pad; zx < w - pad; zx += 10) {
ctx.beginPath()
ctx.moveTo(zx, zy)
ctx.lineTo(Math.min(zx + 5, w - pad), zy)
ctx.stroke()
}
// curva
ctx.strokeStyle = Theme.highlightColor
ctx.lineWidth = 2
ctx.beginPath()
for (i = 0; i < pts.length; i++) {
if (i === 0) ctx.moveTo(X(pts[i][0]), Y(pts[i][1]))
else ctx.lineTo(X(pts[i][0]), Y(pts[i][1]))
}
ctx.stroke()
// estremi
var ex = Tides.extrema(port, now, days)
var shown = 0
for (i = 0; i < ex.length && shown < days * 4; i++) {
if (ex[i].t < now.getTime() - 60000 || ex[i].t > now.getTime() + span) continue
shown++
var up = ex[i].kind === "high"
ctx.fillStyle = up ? Theme.highlightColor : Theme.errorColor
ctx.beginPath(); ctx.arc(X(ex[i].t), Y(ex[i].h), 4, 0, 7); ctx.fill()
var d2 = new Date(ex[i].t)
var lbl = (d2.getHours() < 10 ? "0" : "") + d2.getHours() + ":" + (d2.getMinutes() < 10 ? "0" : "") + d2.getMinutes()
ctx.fillText(lbl, X(ex[i].t) - 16, up ? Y(ex[i].h) - 8 : Y(ex[i].h) + 16)
}
}
Component.onCompleted: init()
}