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 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 > 0) { var today = wh[0] var i0 = Tides.douglas(today) seaLabel.text = seaNames[i0] + " · " + Tides.fmtH(today, page.unit) seaLabel.color = i0 >= 5 ? Theme.errorColor : (i0 >= 4 ? Theme.secondaryColor : Theme.highlightColor) if (wh.length > 1) { var i1 = Tides.douglas(wh[1]) seaSub.text = qsTranslate("Sea", "Tomorrow") + ": " + seaNames[i1] + " · " + Tides.fmtH(wh[1], page.unit) seaSub.color = Theme.secondaryColor } else { seaSub.text = "" } 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) } } // Stato del mare (scala Douglas, previsione onde Open-Meteo) 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 + 130; anchors.right: parent.right; anchors.rightMargin: Theme.horizontalPageMargin; anchors.verticalCenter: parent.verticalCenter; horizontalAlignment: Text.AlignRight; font.pixelSize: Theme.fontSizeSmall; truncationMode: TruncationMode.Fade } } // 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 } } } 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("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() }