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 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() } catch (e) { console.log("[tide] refresh error:", e) } } 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") ] 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 } } } 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 } } } // 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() PullDownMenu { MenuItem { text: qsTr("Set as preferred port") visible: page.port && page.port[0] !== appSettings.preferredPort onClicked: { appSettings.preferredPort = page.port[0] // se è un comune (custom) salva le coordinate per la cover if (page.custom) { appSettings.preferredLat = page.port[2] appSettings.preferredLon = page.port[3] } else { appSettings.preferredLat = -1 appSettings.preferredLon = -1 } pageStack.replace(Qt.resolvedUrl("MainPage.qml")) } } MenuItem { text: qsTr("30-day calendar") visible: page.port !== undefined onClicked: page.openCalendar() } MenuItem { text: qsTr("Settings") onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) } } }