commit 18c6147d229e21d3ef45699cc7cd972f7ede378b Author: Carlo Baratto Date: Mon Aug 24 13:30:31 2026 +0200 harbour-maree 0.1.0: motore armonico 7 costituenti, 16 porti, ricerca libera (Nominatim+locale), grafico 24/48/72h, EN/IT, preferenze (lingua, porto, unità, intervallo), icone harbour diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..806dbd2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +build/ +build-*/ +*.rpm +*.user +Makefile +.qmake.stash +*.qm diff --git a/README.md b/README.md new file mode 100644 index 0000000..79f871e --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# harbour-maree + +Previsioni di marea **universali** per Sailfish OS — client nativo Silica con +motore armonico locale (nessuna API di maree: calcolo offline, per sempre). + +## Funzionalità + +- **Motore armonico a 7 costituenti** (M2, S2, N2, K1, O1, P1, K2): + `h(t) = Z₀ + Σ Aᵢ·cos(ωᵢt + φᵢ)`, calcolato localmente +- **16 porti noti** con costanti realistiche (Italia + Londra, New York, + San Francisco, Tokyo, Sydney, Honolulu) +- **Ricerca libera delle città**: geocoding OpenStreetMap (Nominatim) con + fallback su dataset locale offline; le costanti arrivano dal porto noto + entro 222 km, altrimenti da una stima sintetica da lat/lon (etichettata) +- **Card "porto più vicino"** in evidenza quando si apre una città cercata +- **Grafico 24/48/72 h** con alte/basse maree marcate e orari, tap per + altezza in un punto +- **Fase lunare e alba/tramonto** calcolati +- **Italiano e inglese** (selettore lingua nelle impostazioni, applicata al + riavvio; catalogo `.qm` nel data dir dell'app) +- **Preferenze salvate**: lingua, porto preferito (aperto all'avvio e + mostrato sulla cover), unità (m/ft), intervallo del grafico + +## Build + +Serve il [Sailfish SDK](https://sailfishos.org/wiki/Sailfish_SDK). + +```sh +sfdk target install SailfishOS-5.0.0.62-aarch64 # o il target del dispositivo +sfdk target use SailfishOS-5.0.0.62-aarch64 +sfdk build # produce l'RPM in RPMS/ +sfdk deploy # installa sul dispositivo connesso +``` + +Target consigliati: `aarch64` (Jolla Phone 2026, Xperia 10 II/III), +`armv7hl` (Xperia X, Jolla C), `x86_64` (emulatore). + +## Struttura + +``` +src/ C++: main.cpp (lingua + translator), settings + (QSettings: language, preferredPort, unit, days) +qml/js/tides.js motore armonico (ES5 puro, compatibile Qt 5.6): + costanti, predizione, estremi, stima sintetica, + geocoding locale, luna, sole +qml/pages/ MainPage (porti + ricerca libera), DetailPage + (grafico canvas, eventi, card porto vicino, astro), + SettingsPage (lingua, unità, intervallo, porto) +qml/cover/ cover con porto preferito e prossima marea +translations/ harbour-maree.ts (EN sorgente), harbour-maree_it.ts +icons/ icone 86/108/128/172 (make_icons.py, stile harbour) +rpm/ spec e desktop file (SailJail: Internet) +``` + +## Configurazione + +File: `~/.config/harbour/maree/maree.conf` (QSettings, zona persistente +della sandbox SailJail). Chiavi: `language` (system/en/it), `preferredPort`, +`unit` (m/ft), `days` (1/2/3). + +## Limiti noti + +- Le costanti dei porti sono sintetiche ma realistiche; la stima da lat/lon + non distingue bacini chiusi (Mediterraneo) da oceano aperto. +- Precisione di un vero modello globale (FES2014/TPXO9): ~10–20 cm in oceano + aperto; il motore è predisposto a ricevere le costanti da griglia. +- Non per navigazione. +- La lingua si applica al riavvio dell'app. + +## Licenza + +MIT diff --git a/harbour-maree.pro b/harbour-maree.pro new file mode 100644 index 0000000..fcddb63 --- /dev/null +++ b/harbour-maree.pro @@ -0,0 +1,42 @@ +TEMPLATE = app +TARGET = harbour-maree + +CONFIG += sailfishapp +CONFIG += c++11 + +SOURCES += \ + src/main.cpp \ + src/settings.cpp + +HEADERS += \ + src/settings.h + +OTHER_FILES += \ + qml/harbour-maree.qml \ + qml/pages/*.qml \ + qml/cover/*.qml \ + qml/js/*.js \ + rpm/harbour-maree.spec \ + rpm/harbour-maree.desktop \ + translations/harbour-maree.ts \ + translations/harbour-maree_it.ts + +DISTFILES += $$OTHER_FILES + +# Le traduzioni NON sono gestite dal template sailfishapp_i18n sul kit 5.1 +# (il .qm non viene generato/installato in modo affidabile). Lo spec compila +# il catalogo con lrelease e lo installa in /usr/share/harbour-maree/translations; +# main.cpp carica il catalogo italiano esplicitamente (pattern calibreweb). +# Le stringhe sorgente sono INGLESI (lingua di default); harbour-maree_it.ts +# fornisce il catalogo italiano. + +# Icone installate via make install (struttura standard hicolor) +icons86.path = /usr/share/icons/hicolor/86x86/apps +icons86.files = $$PWD/icons/86x86/harbour-maree.png +icons108.path = /usr/share/icons/hicolor/108x108/apps +icons108.files = $$PWD/icons/108x108/harbour-maree.png +icons128.path = /usr/share/icons/hicolor/128x128/apps +icons128.files = $$PWD/icons/128x128/harbour-maree.png +icons172.path = /usr/share/icons/hicolor/172x172/apps +icons172.files = $$PWD/icons/172x172/harbour-maree.png +INSTALLS += icons86 icons108 icons128 icons172 diff --git a/icons/108x108/harbour-maree.png b/icons/108x108/harbour-maree.png new file mode 100644 index 0000000..bb0c246 Binary files /dev/null and b/icons/108x108/harbour-maree.png differ diff --git a/icons/128x128/harbour-maree.png b/icons/128x128/harbour-maree.png new file mode 100644 index 0000000..0f0f0da Binary files /dev/null and b/icons/128x128/harbour-maree.png differ diff --git a/icons/172x172/harbour-maree.png b/icons/172x172/harbour-maree.png new file mode 100644 index 0000000..589fcb5 Binary files /dev/null and b/icons/172x172/harbour-maree.png differ diff --git a/icons/86x86/harbour-maree.png b/icons/86x86/harbour-maree.png new file mode 100644 index 0000000..2c7e6bd Binary files /dev/null and b/icons/86x86/harbour-maree.png differ diff --git a/icons/make_icons.py b/icons/make_icons.py new file mode 100644 index 0000000..8f76caf --- /dev/null +++ b/icons/make_icons.py @@ -0,0 +1,80 @@ +#!/usr/bin/env python3 +"""Genera le icone dell'app in stile Sailfish OS: + sfondo a gradiente azzurro->blu con angoli molto arrotondati, + due onde bianche (marea) con ombra morbida e una luna in alto. + Dimensioni: 86, 108, 128, 172 (struttura standard hicolor).""" +import math +import os +from PIL import Image, ImageDraw + +# gradiente Sailfish: azzurro chiaro (alto) -> blu profondo (basso) +C_TOP = (94, 210, 240) # #5ED2F0 +C_BOT = (14, 111, 168) # #0E6FA8 +WAVE1 = (255, 255, 255) # onda principale, bianca piena +WAVE2 = (255, 255, 255) # onda secondaria, semitrasparente +MOON = (255, 255, 255) + + +def gradient(size, c1, c2): + img = Image.new("RGB", (size, size)) + d = ImageDraw.Draw(img) + for y in range(size): + t = y / (size - 1) + d.line([(0, y), (size, y)], + fill=(int(c1[0] + (c2[0] - c1[0]) * t), + int(c1[1] + (c2[1] - c1[1]) * t), + int(c1[2] + (c2[2] - c1[2]) * t))) + return img + + +def wave_layer(size, y0, amp, phase, lw, alpha): + """Layer RGBA con una sinusoide orizzontale (onda).""" + layer = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(layer) + pts = [] + for x in range(0, size + 2, 2): + y = y0 + amp * math.sin(2 * math.pi * x / size * 1.6 + phase) + pts.append((x, y)) + d.line(pts, fill=WAVE1 + (alpha,), width=lw) + return layer + + +def draw_icon(size): + m = size * 0.05 + r = size * 0.22 + out = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + + # sfondo: gradiente ritagliato nel rounded rect + grad = gradient(size, C_TOP, C_BOT).convert("RGBA") + mask = Image.new("L", (size, size), 0) + ImageDraw.Draw(mask).rounded_rectangle([m, m, size - m, size - m], + radius=r, fill=255) + out.paste(grad, (0, 0), mask) + + # onde: due sinusoidi, la seconda sfasata e piu' bassa + lw1 = max(2, int(size * 0.045)) + lw2 = max(2, int(size * 0.032)) + out.alpha_composite(wave_layer(size, size * 0.58, size * 0.07, 0.0, lw1, 255)) + out.alpha_composite(wave_layer(size, size * 0.74, size * 0.06, math.pi, lw2, 150)) + + # luna in alto a destra (cerchio pieno, con ombra leggera) + d = ImageDraw.Draw(out) + lx = size * 0.78 + ly = size * 0.22 + lr = size * 0.09 + d.ellipse([lx - lr + size * 0.01, ly - lr + size * 0.01, + lx + lr + size * 0.01, ly + lr + size * 0.01], + fill=(0, 60, 100, 70)) + d.ellipse([lx - lr, ly - lr, lx + lr, ly + lr], fill=MOON) + return out + + +def main(): + for s in (86, 108, 128, 172): + os.makedirs(f"icons/{s}x{s}", exist_ok=True) + draw_icon(s).save(f"icons/{s}x{s}/harbour-maree.png") + print("icone scritte: 86, 108, 128, 172") + + +if __name__ == "__main__": + main() diff --git a/qml/cover/CoverPage.qml b/qml/cover/CoverPage.qml new file mode 100644 index 0000000..9f3b605 --- /dev/null +++ b/qml/cover/CoverPage.qml @@ -0,0 +1,62 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "../js/tides.js" as Tides + +CoverBackground { + id: cover + + Column { + anchors.centerIn: parent + spacing: Theme.paddingSmall + width: parent.width + + Label { + text: "🌊 Maree" + anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: Theme.fontSizeLarge + color: Theme.primaryColor + } + Label { + id: portLabel + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + text: qsTr("No port selected") + } + Label { + id: nextLabel + anchors.horizontalCenter: parent.horizontalCenter + color: Theme.highlightColor + font.pixelSize: Theme.fontSizeSmall + wrapMode: Text.Wrap + horizontalAlignment: Text.AlignHCenter + } + } + + onStatusChanged: { + if (status === Cover.Active) refresh() + } + + Component.onCompleted: refresh() + + function refresh() { + var pref = appSettings.preferredPort + if (pref === "") { + portLabel.text = qsTr("No port selected") + nextLabel.text = "" + return + } + var p = null + for (var i = 0; i < Tides.PORTS.length; i++) { + if (Tides.PORTS[i][0] === pref) { p = Tides.PORTS[i]; break } + } + if (!p) { portLabel.text = qsTr("No port selected"); nextLabel.text = ""; return } + portLabel.text = p[0] + var ex = Tides.extrema(p, new Date(), 1) + if (ex.length > 0) { + var e = ex[0] + var kind = e.kind === "high" ? qsTr("High") : qsTr("Low") + nextLabel.text = kind + " " + Tides.fmtHM(e.t) + " · " + Tides.fmtH(e.h, appSettings.unit) + } + } +} diff --git a/qml/harbour-maree.qml b/qml/harbour-maree.qml new file mode 100644 index 0000000..3be7e99 --- /dev/null +++ b/qml/harbour-maree.qml @@ -0,0 +1,14 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "pages" +import "cover" + +ApplicationWindow { + id: app + + initialPage: Component { MainPage {} } + cover: Component { CoverPage {} } + + allowedOrientations: Orientation.Portrait + _defaultPageOrientations: Orientation.Portrait +} diff --git a/qml/js/tides.js b/qml/js/tides.js new file mode 100644 index 0000000..5992537 --- /dev/null +++ b/qml/js/tides.js @@ -0,0 +1,176 @@ +// tides.js — motore armonico delle maree (ES5 puro, compatibile Qt 5.6 QML) +// Formato porto: [nome, paese, lat, lon, cost, flag] +// cost = [Z0, [A,phi] per M2,S2,N2,K1,O1,P1,K2] (phi in gradi) +// flag = "" (porto noto) | "near::" | "synth" + +var W = { + M2: 28.9841042, S2: 30.0, N2: 28.4397295, + K1: 15.0410686, O1: 13.9430356, P1: 14.9589314, K2: 30.0821373 +}; +var WK = ["M2", "S2", "N2", "K1", "O1", "P1", "K2"]; + +var PORTS = [ + ["Venezia", "Italia", 45.43, 12.34, [0.35, [0.22, 215], [0.10, 245], [0.05, 210], [0.19, 100], [0.12, 85], [0.06, 100], [0.03, 245]]], + ["Chioggia", "Italia", 45.22, 12.28, [0.34, [0.21, 212], [0.10, 242], [0.05, 207], [0.18, 98], [0.11, 83], [0.06, 98], [0.03, 242]]], + ["Trieste", "Italia", 45.65, 13.77, [0.34, [0.20, 220], [0.09, 250], [0.05, 215], [0.22, 105], [0.14, 90], [0.07, 105], [0.03, 250]]], + ["Ancona", "Italia", 43.62, 13.52, [0.25, [0.12, 210], [0.05, 240], [0.03, 205], [0.10, 95], [0.06, 80], [0.03, 95], [0.02, 240]]], + ["Genova", "Italia", 44.41, 8.93, [0.22, [0.08, 200], [0.04, 230], [0.02, 195], [0.10, 95], [0.07, 80], [0.03, 95], [0.01, 230]]], + ["Livorno", "Italia", 43.55, 10.31, [0.24, [0.10, 205], [0.05, 235], [0.03, 200], [0.11, 95], [0.07, 80], [0.03, 95], [0.01, 235]]], + ["Napoli", "Italia", 40.84, 14.27, [0.22, [0.10, 210], [0.05, 240], [0.03, 205], [0.11, 90], [0.07, 75], [0.03, 90], [0.01, 240]]], + ["Palermo", "Italia", 38.12, 13.36, [0.21, [0.09, 205], [0.04, 235], [0.02, 200], [0.10, 90], [0.06, 75], [0.03, 90], [0.01, 235]]], + ["Cagliari", "Italia", 39.21, 9.11, [0.20, [0.07, 200], [0.03, 230], [0.02, 195], [0.09, 88], [0.06, 73], [0.03, 88], [0.01, 230]]], + ["Bari", "Italia", 41.13, 16.87, [0.25, [0.14, 200], [0.06, 230], [0.04, 195], [0.10, 92], [0.06, 77], [0.03, 92], [0.02, 230]]], + ["Londra (London Bridge)", "Regno Unito", 51.51, -0.09, [3.50, [2.10, 150], [0.85, 180], [0.45, 145], [0.12, 200], [0.10, 190], [0.04, 200], [0.23, 180]]], + ["New York (The Battery)", "USA", 40.70, -74.01, [0.80, [0.67, 180], [0.28, 205], [0.14, 175], [0.12, 60], [0.10, 55], [0.04, 60], [0.08, 205]]], + ["San Francisco (Golden Gate)", "USA", 37.81, -122.47, [1.00, [0.58, 135], [0.26, 160], [0.12, 130], [0.37, 290], [0.24, 275], [0.11, 290], [0.07, 160]]], + ["Tokyo (Bay)", "Giappone", 35.68, 139.77, [1.20, [0.45, 170], [0.20, 195], [0.09, 165], [0.18, 240], [0.12, 225], [0.05, 240], [0.05, 195]]], + ["Sydney", "Australia", -33.87, 151.21, [1.00, [0.50, 100], [0.23, 125], [0.10, 95], [0.10, 300], [0.07, 285], [0.03, 300], [0.06, 125]]], + ["Honolulu", "USA", 21.31, -157.86, [0.35, [0.20, 115], [0.09, 140], [0.05, 110], [0.18, 320], [0.12, 305], [0.05, 320], [0.02, 140]]] +]; + +// Citta' con coordinate per ricerca offline (fallback senza rete) +var CITIES = [ + ["Milano", "Italia", 45.46, 9.19], ["Roma", "Italia", 41.90, 12.50], ["Torino", "Italia", 45.07, 7.69], + ["Bologna", "Italia", 44.49, 11.34], ["Firenze", "Italia", 43.77, 11.26], ["Verona", "Italia", 45.44, 10.99], + ["Padova", "Italia", 45.41, 11.88], ["Ravenna", "Italia", 44.42, 12.20], ["Pescara", "Italia", 42.46, 14.21], + ["Reggio Calabria", "Italia", 38.11, 15.65], ["Catania", "Italia", 37.51, 15.09], ["Trapani", "Italia", 38.02, 12.54], + ["Olbia", "Italia", 40.92, 9.50], ["Alghero", "Italia", 40.56, 8.32], ["Imperia", "Italia", 43.89, 8.03], + ["La Spezia", "Italia", 44.10, 9.82], ["Viareggio", "Italia", 43.87, 10.25], ["Civitavecchia", "Italia", 42.09, 11.79], + ["Salerno", "Italia", 40.68, 14.77], ["Taranto", "Italia", 40.46, 17.24], ["Brindisi", "Italia", 40.64, 17.95], + ["Otranto", "Italia", 40.15, 18.49], ["Marsala", "Italia", 37.80, 12.44], ["Porto Torres", "Italia", 40.84, 8.40], + ["Amsterdam", "Paesi Bassi", 52.37, 4.90], ["Rotterdam", "Paesi Bassi", 51.92, 4.48], + ["Barcellona", "Spagna", 41.39, 2.17], ["Marsiglia", "Francia", 43.30, 5.37], ["Atene", "Grecia", 37.98, 23.73], + ["Istanbul", "Turchia", 41.01, 28.98], ["Lisbona", "Portogallo", 38.72, -9.14], ["Dublino", "Irlanda", 53.35, -6.26], + ["Edimburgo", "Regno Unito", 55.95, -3.19], ["Amburgo", "Germania", 53.55, 9.99], ["Copenaghen", "Danimarca", 55.68, 12.57], + ["Oslo", "Norvegia", 59.91, 10.75], ["Stoccolma", "Svezia", 59.33, 18.07], ["Helsinki", "Finlandia", 60.17, 24.94], + ["Miami", "USA", 25.76, -80.19], ["Los Angeles", "USA", 34.05, -118.24], ["Seattle", "USA", 47.61, -122.33], + ["Vancouver", "Canada", 49.28, -123.12], ["Città del Messico", "Messico", 19.43, -99.13], + ["Lima", "Perù", -12.05, -77.04], ["Buenos Aires", "Argentina", -34.60, -58.38], + ["Città del Capo", "Sudafrica", -33.92, 18.42], ["Mumbai", "India", 19.08, 72.88], + ["Singapore", "Singapore", 1.35, 103.82], ["Hong Kong", "Cina", 22.32, 114.17], ["Shanghai", "Cina", 31.23, 121.47], + ["Seoul", "Corea del Sud", 37.57, 126.98], ["Manila", "Filippine", 14.60, 120.98], ["Jakarta", "Indonesia", -6.21, 106.85], + ["Auckland", "Nuova Zelanda", -36.85, 174.76], ["Wellington", "Nuova Zelanda", -41.29, 174.78], + ["Rio de Janeiro", "Brasile", -22.91, -43.17], ["Il Cairo", "Egitto", 30.04, 31.24], ["Dubai", "EAU", 25.20, 55.27], + ["Gibilterra", "Regno Unito", 36.14, -5.35], ["Malta (La Valletta)", "Malta", 35.90, 14.51] +]; + +var EPOCH = Date.UTC(2000, 0, 1) / 3600000; // ore dal 1970 all'epoca armonica + +// Altezza della marea (metri) in un dato istante +function tide(port, date) { + var t = date.getTime() / 3600000 - EPOCH; + var C = port[4]; + var h = C[0]; + for (var i = 0; i < 7; i++) { + var a = C[i + 1][0], p = C[i + 1][1]; + h += a * Math.cos((W[WK[i]] * t + p) * Math.PI / 180); + } + return h; +} + +// Estremi (alta/bassa) da adesso nei prossimi giorni +function extrema(port, now, days) { + var step = 2 * 60 * 1000; + var out = []; + var end = now.getTime() + days * 86400000; + var prev = tide(port, new Date(now.getTime() - step)); + var cur = tide(port, now); + var rising = cur > prev; + for (var t = now.getTime() + step; t <= end; t += step) { + var nx = tide(port, new Date(t)); + if (rising && nx < cur) { + var bt = t - step, hh = cur; + for (var dt = bt - 10 * 60000; dt <= t + 10 * 60000; dt += 30000) { + var hh2 = tide(port, new Date(dt)); + if (hh2 > hh) { hh = hh2; bt = dt; } + } + out.push({ t: bt, h: hh, kind: "high" }); + } else if (!rising && nx > cur) { + var bt2 = t - step, hh3 = cur; + for (var dt2 = bt2 - 10 * 60000; dt2 <= t + 10 * 60000; dt2 += 30000) { + var hh4 = tide(port, new Date(dt2)); + if (hh4 < hh3) { hh3 = hh4; bt2 = dt2; } + } + out.push({ t: bt2, h: hh3, kind: "low" }); + } + prev = cur; cur = nx; rising = nx > prev; + } + return out; +} + +// Distanza approssimata in km tra un porto e un punto +function portDist(p, lat, lon) { + var dlat = (p[2] - lat) * 111; + var dlon = (p[3] - lon) * 111 * Math.cos(lat * Math.PI / 180); + return Math.sqrt(dlat * dlat + dlon * dlon); +} + +// Porto noto piu' vicino a lat/lon +function nearestPort(lat, lon) { + var best = null, bd = 1e9; + for (var i = 0; i < PORTS.length; i++) { + var d = portDist(PORTS[i], lat, lon); + if (d < bd) { bd = d; best = PORTS[i]; } + } + return { port: best, km: bd }; +} + +// Costanti stimate da lat/lon (modello sintetico, per il mockup) +function synthConst(lat, lon) { + var a = Math.abs(lat); + var g = Math.exp(-0.5 * Math.pow((a - 48) / 18, 2)); + var M2 = 0.08 + 1.6 * g; + var S2 = 0.38 * M2, N2 = 0.19 * M2, K2 = 0.27 * S2; + var dg = Math.exp(-0.5 * Math.pow(a / 25, 2)); + var K1 = 0.10 + 0.22 * dg, O1 = 0.70 * K1, P1 = 0.33 * K1; + var pM = ((lon * 2.8 + 100) % 360 + 360) % 360; + return [0.3, [M2, pM], [S2, (pM + 30) % 360], [N2, (pM - 25 + 360) % 360], [K1, 80], [O1, 65], [P1, 80], [K2, (pM + 30) % 360]]; +} + +// Citta' nel dataset locale (ricerca offline) +function findCityLocal(q) { + q = q.toLowerCase(); + for (var i = 0; i < CITIES.length; i++) { + if (CITIES[i][0].toLowerCase().indexOf(q) !== -1) return CITIES[i]; + } + return null; +} + +// Fase lunare: {icon, idx (0-7), age (giorni)} +function moonPhase(date) { + var syn = 29.53058867; + var known = Date.UTC(2000, 0, 6, 18, 14) / 86400000; + var age = ((date.getTime() / 86400000 - known) % syn + syn) % syn; + var icons = ["\uD83C\uDF11", "\uD83C\uDF12", "\uD83C\uDF13", "\uD83C\uDF14", "\uD83C\uDF15", "\uD83C\uDF16", "\uD83C\uDF17", "\uD83C\uDF18"]; + return { icon: icons[Math.round(age / syn * 8) % 8], idx: Math.round(age / syn * 8) % 8, age: age }; +} + +// Alba/tramonto approssimati (ora solare, ore decimali) +function sunTimes(lat, date) { + var N = Math.floor((date.getTime() - Date.UTC(date.getFullYear(), 0, 1)) / 86400000); + var dec = 23.45 * Math.sin((360 / 365) * (284 + N) * Math.PI / 180); + var latR = lat * Math.PI / 180, decR = dec * Math.PI / 180; + var cosH = (-Math.sin(latR) * Math.sin(decR)) / (Math.cos(latR) * Math.cos(decR)); + cosH = Math.max(-1, Math.min(1, cosH)); + var H = Math.acos(cosH) * 180 / Math.PI / 15; + return { rise: 12 - H, set: 12 + H }; +} + +// Formato altezza secondo l'unita' scelta +function fmtH(h, unit) { + return unit === "ft" ? (h * 3.28084).toFixed(1) + " ft" : h.toFixed(2) + " m"; +} + +// Formato ora HH:MM +function fmtHM(t) { + var d = new Date(t); + var hh = d.getHours(), mm = d.getMinutes(); + return (hh < 10 ? "0" : "") + hh + ":" + (mm < 10 ? "0" : "") + mm; +} + +// Formato data breve +function fmtDate(t) { + var d = new Date(t); + var giorni = ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"]; + return giorni[d.getDay()] + " " + (d.getDate() < 10 ? "0" : "") + d.getDate() + "/" + ((d.getMonth() + 1) < 10 ? "0" : "") + (d.getMonth() + 1); +} diff --git a/qml/pages/DetailPage.qml b/qml/pages/DetailPage.qml new file mode 100644 index 0000000..30ca0b1 --- /dev/null +++ b/qml/pages/DetailPage.qml @@ -0,0 +1,350 @@ +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 + 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 + } + } + refresh() + } + + function refresh() { + if (!port) return + // 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), + 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() + } + + 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") + ] + + 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: 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: pageStack.push(Qt.resolvedUrl("DetailPage.qml"), + { port: page.nearPort, + portIndex: Tides.PORTS.indexOf(page.nearPort), + custom: false }) + } + } + + // 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()) + " " + 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 + background: Rectangle { color: Theme.rgba(Theme.primaryColor, 0.1); radius: Theme.paddingSmall } + } + 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 { + 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 + } + Label { + text: model.date + " " + model.time + color: Theme.primaryColor + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + 100 + anchors.verticalCenter: parent.verticalCenter + } + 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 + ctx.setLineDash([4, 4]) + ctx.strokeStyle = Theme.rgba(Theme.secondaryColor, 0.6) + ctx.beginPath(); ctx.moveTo(pad, Y(port[4][0])); ctx.lineTo(w - pad, Y(port[4][0])); ctx.stroke() + ctx.setLineDash([]) + + // 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] + pageStack.replace(Qt.resolvedUrl("MainPage.qml")) + } + } + MenuItem { + text: qsTr("Settings") + onClicked: pageStack.push(Qt.resolvedUrl("SettingsPage.qml")) + } + } +} diff --git a/qml/pages/MainPage.qml b/qml/pages/MainPage.qml new file mode 100644 index 0000000..6193f81 --- /dev/null +++ b/qml/pages/MainPage.qml @@ -0,0 +1,202 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "../js/tides.js" as Tides + +Page { + id: page + + property var customPorts: [] // porti creati dalla ricerca libera (sessione) + + SilicaListView { + id: list + anchors.fill: parent + model: ListModel { id: portModel } + + header: Column { + width: parent.width + PageHeader { title: "Maree" } + + SearchField { + id: filter + placeholderText: qsTr("Filter ports…") + onTextChanged: rebuild() + } + + Item { width: parent.width; height: Theme.paddingMedium } + + TextField { + id: geoField + width: parent.width - 2 * Theme.horizontalPageMargin + anchors.horizontalCenter: parent.horizontalCenter + placeholderText: qsTr("Free search: any city (online)…") + EnterKey.enabled: text.length > 0 + EnterKey.onClicked: findCity() + } + + Button { + text: qsTr("Find") + anchors.horizontalCenter: parent.horizontalCenter + onClicked: findCity() + } + + Label { + id: geoMsg + visible: false + width: parent.width - 2 * Theme.horizontalPageMargin + anchors.horizontalCenter: parent.horizontalCenter + wrapMode: Text.Wrap + color: Theme.errorColor + font.pixelSize: Theme.fontSizeSmall + } + + SectionHeader { text: qsTr("Ports") } + } + + delegate: ListItem { + contentHeight: Theme.itemSizeMedium + highlighted: model.isPreferred + + Label { + text: model.name + color: model.isPreferred ? Theme.highlightColor : Theme.primaryColor + font.pixelSize: Theme.fontSizeMedium + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + truncationMode: TruncationMode.Fade + width: parent.width - 3 * Theme.horizontalPageMargin + } + Label { + text: model.country + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.top: parent.top + anchors.topMargin: Theme.paddingSmall + } + Label { + text: model.range + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + anchors.verticalCenter: parent.verticalCenter + } + onClicked: { + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), + { port: model.port, portIndex: model.realIndex, custom: model.custom }) + } + } + + VerticalScrollDecorator {} + } + + function rebuild() { + portModel.clear() + var f = filter.text.toLowerCase() + var pref = appSettings.preferredPort + var i, p + for (i = 0; i < Tides.PORTS.length; i++) { + p = Tides.PORTS[i] + if (f !== "" && (p[0] + " " + p[1]).toLowerCase().indexOf(f) === -1) + continue + portModel.append({ + name: p[0], country: p[1], + range: "±" + (p[4][1][0] + p[4][2][0]).toFixed(2) + " m", + port: p, realIndex: i, custom: false, + isPreferred: (pref === p[0]) + }) + } + for (i = 0; i < customPorts.length; i++) { + p = customPorts[i] + if (f !== "" && p[0].toLowerCase().indexOf(f) === -1) + continue + portModel.append({ + name: p[0], country: qsTr("searched city"), + range: "±" + (p[4][1][0] + p[4][2][0]).toFixed(2) + " m", + port: p, realIndex: -1, custom: true, + isPreferred: (pref === p[0]) + }) + } + } + + // --- Ricerca libera (geocoding) --- + function geoFail() { + geoMsg.text = qsTr("City not found (offline and not in local dataset).") + geoMsg.visible = true + } + + function geoFound(name, lat, lon, src) { + if (!name) { geoFail(); return } + var np = Tides.nearestPort(lat, lon) + var cost, flag + if (np.km < 222) { + cost = np.port[4].slice() + flag = "near:" + np.port[0] + ":" + (np.km / 111).toFixed(1) + } else { + cost = Tides.synthConst(lat, lon) + flag = "synth" + } + var port = [name, "", lat, lon, cost, flag] + customPorts.push(port) + rebuild() + geoMsg.text = qsTr("Found %1 (via %2)").arg(name).arg(src) + geoMsg.visible = true + geoMsg.color = Theme.highlightColor + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), + { port: port, portIndex: -1, custom: true }) + } + + function geoXhr(query, cb) { + var xhr = new XMLHttpRequest() + xhr.open("GET", "https://nominatim.openstreetmap.org/search?format=jsonv2&limit=1&q=" + + encodeURIComponent(query), true) + xhr.onreadystatechange = function() { + if (xhr.readyState === 4) { + if (xhr.status === 200) { + try { + var j = JSON.parse(xhr.responseText) + if (j && j.length > 0) { + cb(j[0].name || j[0].display_name.split(",")[0], + parseFloat(j[0].lat), parseFloat(j[0].lon), "OpenStreetMap") + } else { + cb(null) + } + } catch (e) { cb(null) } + } else { + cb(null) + } + } + } + xhr.send() + } + + function findCity() { + var q = geoField.text.trim() + if (q === "") return + geoMsg.text = qsTr("Searching…") + geoMsg.visible = true + geoMsg.color = Theme.secondaryColor + var loc = Tides.findCityLocal(q) + if (loc) { + geoFound(loc[0], loc[2], loc[3], qsTr("local dataset")) + } else { + geoXhr(q, geoFound) + } + } + + Component.onCompleted: { + rebuild() + var pref = appSettings.preferredPort + if (pref !== "") { + for (var i = 0; i < Tides.PORTS.length; i++) { + if (Tides.PORTS[i][0] === pref) { + pageStack.push(Qt.resolvedUrl("DetailPage.qml"), + { port: Tides.PORTS[i], portIndex: i, custom: false }) + break + } + } + } + } +} diff --git a/qml/pages/SettingsPage.qml b/qml/pages/SettingsPage.qml new file mode 100644 index 0000000..156b522 --- /dev/null +++ b/qml/pages/SettingsPage.qml @@ -0,0 +1,141 @@ +import QtQuick 2.0 +import Sailfish.Silica 1.0 +import "../js/tides.js" as Tides + +Page { + id: page + + PageHeader { title: qsTr("Settings") } + + SilicaFlickable { + anchors.fill: parent + contentHeight: col.height + + Column { + id: col + width: parent.width + + ComboBox { + id: langBox + label: qsTr("Language") + currentIndex: appSettings.language === "it" ? 2 + : (appSettings.language === "en" ? 1 : 0) + menu: ContextMenu { + MenuItem { + text: qsTr("System") + onClicked: appSettings.language = "system" + } + MenuItem { + text: "English" + onClicked: appSettings.language = "en" + } + MenuItem { + text: "Italiano" + onClicked: appSettings.language = "it" + } + } + } + + Label { + text: qsTr("Language changes on restart") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + wrapMode: Text.Wrap + } + + Item { width: parent.width; height: Theme.paddingMedium } + + ComboBox { + id: unitBox + label: qsTr("Units") + currentIndex: appSettings.unit === "ft" ? 1 : 0 + menu: ContextMenu { + MenuItem { + text: qsTr("metres") + onClicked: appSettings.unit = "m" + } + MenuItem { + text: qsTr("feet") + onClicked: appSettings.unit = "ft" + } + } + } + + ComboBox { + id: daysBox + label: qsTr("Default range") + currentIndex: Math.max(0, appSettings.days - 1) + menu: ContextMenu { + MenuItem { + text: qsTr("24 h") + onClicked: appSettings.days = 1 + } + MenuItem { + text: qsTr("48 h") + onClicked: appSettings.days = 2 + } + MenuItem { + text: qsTr("72 h") + onClicked: appSettings.days = 3 + } + } + } + + ComboBox { + id: prefBox + label: qsTr("Preferred port") + currentIndex: prefIndex() + menu: ContextMenu { + MenuItem { + text: qsTr("None") + onClicked: appSettings.preferredPort = "" + } + Repeater { + model: Tides.PORTS.length + delegate: MenuItem { + text: Tides.PORTS[index][0] + onClicked: appSettings.preferredPort = Tides.PORTS[index][0] + } + } + } + } + + function prefIndex() { + var p = appSettings.preferredPort + if (p === "") return 0 + for (var i = 0; i < Tides.PORTS.length; i++) + if (Tides.PORTS[i][0] === p) return i + 1 + return 0 + } + + SectionHeader { text: qsTr("About") } + + Label { + text: "Maree " + appVersion + "\n" + qsTr("Universal tide predictions") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + wrapMode: Text.Wrap + } + Label { + text: qsTr("Harmonic model: M2, S2, N2, K1, O1, P1, K2 — known ports or synthetic estimate from lat/lon. Not for navigation.") + color: Theme.secondaryColor + font.pixelSize: Theme.fontSizeSmall + anchors.left: parent.left + anchors.leftMargin: Theme.horizontalPageMargin + anchors.right: parent.right + anchors.rightMargin: Theme.horizontalPageMargin + wrapMode: Text.Wrap + } + + Item { width: parent.width; height: Theme.paddingLarge } + } + } +} diff --git a/rpm/harbour-maree.desktop b/rpm/harbour-maree.desktop new file mode 100644 index 0000000..3ec1a51 --- /dev/null +++ b/rpm/harbour-maree.desktop @@ -0,0 +1,20 @@ +[Desktop Entry] +Name=Maree +Name[it_IT]=Maree +Comment=Universal tide predictions +Comment[it_IT]=Previsioni di marea universali +Exec=harbour-maree +Icon=harbour-maree +Type=Application +X-Nemo-Application-Type=silica-qt5 +Categories=Utility; + +# Profilo SailJail esplicito: senza X-Sailjail la sandbox di Sailfish 5.1 usa +# il profilo default, che nasconde ~/.config/harbour (config non persistente). +# Con OrganizationName/ApplicationName la sandbox rende persistente +# ~/.config/harbour/maree/ (AppConfigLocation). Internet = geocoding +# OpenStreetMap per la ricerca libera delle città. +[X-Sailjail] +Permissions=Internet +OrganizationName=harbour +ApplicationName=maree diff --git a/rpm/harbour-maree.spec b/rpm/harbour-maree.spec new file mode 100644 index 0000000..c00281b --- /dev/null +++ b/rpm/harbour-maree.spec @@ -0,0 +1,75 @@ +Name: harbour-maree +Summary: Universal tide predictions +Version: 0.1.0 +Release: 1 +Group: Qt/Qt +License: MIT +URL: https://git.hackatoniclife.com/kaneda/harbour-maree +Source0: %{name}-%{version}.tar.gz +Source1: %{name}.desktop + +Requires: sailfishsilica-qt5 >= 0.10.9 +BuildRequires: pkgconfig(sailfishapp) >= 1.0.2 +BuildRequires: pkgconfig(Qt5Core) +BuildRequires: pkgconfig(Qt5Qml) +BuildRequires: pkgconfig(Qt5Quick) +BuildRequires: desktop-file-utils + +%description +Universal tide predictions for Sailfish OS. Harmonic model +(M2, S2, N2, K1, O1, P1, K2) computed locally: known ports with +realistic constants, plus a free city search (OpenStreetMap +geocoding) that falls back to a synthetic estimate from lat/lon. +English and Italian UI, preferred port, units (m/ft) and chart +range saved in settings. + +%changelog +* Fri Aug 21 2026 Carlo Baratto - 0.1.0-1 +- Prima release: motore armonico a 7 costituenti, 16 porti noti, + ricerca libera delle citta' (Nominatim + dataset locale), + card "porto piu' vicino", grafico 24/48/72 h, fase lunare e + alba/tramonto, lingua EN/IT (catalogo it nel data dir), porto + preferito, unita' e intervallo salvati in QSettings + +%prep +%setup -q -n %{name}-%{version} + +%build +%qmake5 +make %{?_smp_mflags} + +# Catalogo di traduzione generato esplicitamente con lrelease (il template +# sailfishapp_i18n non produce il .qm in modo affidabile sul kit 5.1). +if [ -f %{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts ]; then + TS=%{_builddir}/../%{name}-%{version}/translations/%{name}_it.ts +elif [ -f %{_builddir}/%{name}-%{version}/translations/%{name}_it.ts ]; then + TS=%{_builddir}/%{name}-%{version}/translations/%{name}_it.ts +elif [ -f %{_builddir}/translations/%{name}_it.ts ]; then + TS=%{_builddir}/translations/%{name}_it.ts +else + echo "ERROR: %{name}_it.ts not found" >&2 + exit 1 +fi +lrelease "$TS" -qm %{_builddir}/%{name}_it.qm + +%install +make install INSTALL_ROOT=%{buildroot} +mkdir -p %{buildroot}%{_datadir}/applications +install -m 0644 %{SOURCE1} %{buildroot}%{_datadir}/applications/%{name}.desktop +desktop-file-validate %{buildroot}%{_datadir}/applications/%{name}.desktop || true + +# Traduzione italiana nel data dir dell'app (il validatore RPM rifiuta +# /usr/share/translations); main.cpp la carica da qui. +mkdir -p %{buildroot}%{_datadir}/%{name}/translations +install -m 0644 %{_builddir}/%{name}_it.qm %{buildroot}%{_datadir}/%{name}/translations/ + +%files +%defattr(-,root,root,-) +%{_bindir}/%{name} +%{_datadir}/applications/%{name}.desktop +%{_datadir}/icons/hicolor/86x86/apps/%{name}.png +%{_datadir}/icons/hicolor/108x108/apps/%{name}.png +%{_datadir}/icons/hicolor/128x128/apps/%{name}.png +%{_datadir}/icons/hicolor/172x172/apps/%{name}.png +%{_datadir}/%{name}/qml +%{_datadir}/%{name}/translations/%{name}*.qm diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..0ab5df8 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,61 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "settings.h" + +Q_DECL_EXPORT int main(int argc, char *argv[]) +{ + QGuiApplication *app = SailfishApp::application(argc, argv); + + // Identita' allineata al profilo X-Sailjail del desktop file: + // AppConfigLocation = ~/.config/harbour/maree (zona persistente della sandbox). + app->setOrganizationName(QStringLiteral("harbour")); + app->setApplicationName(QStringLiteral("maree")); + app->setApplicationVersion(QStringLiteral("0.1.0")); + + Settings settings; + + // Lingua scelta dall'utente (Settings -> Language): "system" segue la + // lingua UI di Sailfish, "en" forza l'inglese (default, stringhe sorgente), + // "it" carica il catalogo italiano dal data dir dell'app (il validatore + // RPM rifiuta /usr/share/translations, quindi il .qm sta in + // /usr/share/harbour-maree/translations e libsailfishapp non lo carica). + QString lang = settings.language(); + if (lang == QLatin1String("system")) { + const QString sys = QLocale::system().name(); + lang = sys.startsWith(QLatin1String("it")) ? QStringLiteral("it") + : QStringLiteral("en"); + } + if (lang == QLatin1String("it")) { + const QList trs = app->findChildren(); + foreach (QTranslator *t, trs) { + app->removeTranslator(t); + t->deleteLater(); + } + QTranslator *translator = new QTranslator(app); + if (translator->load(QStringLiteral("harbour-maree_it"), + QStringLiteral("/usr/share/harbour-maree/translations"))) { + app->installTranslator(translator); + } else { + qWarning("harbour-maree: cannot load harbour-maree_it.qm"); + } + } + + QQuickView *view = SailfishApp::createView(); + + qDebug() << "harbour-maree v0.1.0 build" << __DATE__ << __TIME__; + + view->rootContext()->setContextProperty(QStringLiteral("appSettings"), &settings); + view->rootContext()->setContextProperty(QStringLiteral("appVersion"), + QCoreApplication::applicationVersion()); + + view->setSource(SailfishApp::pathTo(QStringLiteral("qml/harbour-maree.qml"))); + view->show(); + + return app->exec(); +} diff --git a/src/settings.cpp b/src/settings.cpp new file mode 100644 index 0000000..0b2f53d --- /dev/null +++ b/src/settings.cpp @@ -0,0 +1,59 @@ +#include "settings.h" + +Settings::Settings(QObject *parent) + : QObject(parent) + , m_settings(QStringLiteral("harbour"), QStringLiteral("maree")) +{ +} + +QString Settings::language() const +{ + return m_settings.value(QStringLiteral("language"), QStringLiteral("system")).toString(); +} + +void Settings::setLanguage(const QString &value) +{ + if (value == language()) + return; + m_settings.setValue(QStringLiteral("language"), value); + emit languageChanged(); +} + +QString Settings::preferredPort() const +{ + return m_settings.value(QStringLiteral("preferredPort"), QString()).toString(); +} + +void Settings::setPreferredPort(const QString &value) +{ + if (value == preferredPort()) + return; + m_settings.setValue(QStringLiteral("preferredPort"), value); + emit preferredPortChanged(); +} + +QString Settings::unit() const +{ + return m_settings.value(QStringLiteral("unit"), QStringLiteral("m")).toString(); +} + +void Settings::setUnit(const QString &value) +{ + if (value == unit()) + return; + m_settings.setValue(QStringLiteral("unit"), value); + emit unitChanged(); +} + +int Settings::days() const +{ + return m_settings.value(QStringLiteral("days"), 1).toInt(); +} + +void Settings::setDays(int value) +{ + if (value == days()) + return; + m_settings.setValue(QStringLiteral("days"), value); + emit daysChanged(); +} diff --git a/src/settings.h b/src/settings.h new file mode 100644 index 0000000..77a7fb5 --- /dev/null +++ b/src/settings.h @@ -0,0 +1,46 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include + +// Preferenze dell'app, persistite in QSettings ("harbour", "maree"): +// language "system" (segue la lingua UI Sailfish) | "en" | "it" +// preferredPort nome del porto preferito (vuoto = nessuno) +// unit "m" | "ft" +// days 1 | 2 | 3 (intervallo del grafico) +class Settings : public QObject +{ + Q_OBJECT + + Q_PROPERTY(QString language READ language WRITE setLanguage NOTIFY languageChanged) + Q_PROPERTY(QString preferredPort READ preferredPort WRITE setPreferredPort NOTIFY preferredPortChanged) + Q_PROPERTY(QString unit READ unit WRITE setUnit NOTIFY unitChanged) + Q_PROPERTY(int days READ days WRITE setDays NOTIFY daysChanged) + +public: + explicit Settings(QObject *parent = 0); + + QString language() const; + void setLanguage(const QString &value); + + QString preferredPort() const; + void setPreferredPort(const QString &value); + + QString unit() const; + void setUnit(const QString &value); + + int days() const; + void setDays(int value); + +signals: + void languageChanged(); + void preferredPortChanged(); + void unitChanged(); + void daysChanged(); + +private: + QSettings m_settings; +}; + +#endif // SETTINGS_H diff --git a/translations/harbour-maree.ts b/translations/harbour-maree.ts new file mode 100644 index 0000000..90bb8fd --- /dev/null +++ b/translations/harbour-maree.ts @@ -0,0 +1,64 @@ + + + + + MainPage + Filter ports… + Free search: any city (online)… + Find + City not found (offline and not in local dataset). + Found %1 (via %2) + Searching… + local dataset + searched city + Ports + + + DetailPage + lat %1° + 📍 Nearest port: %1 — %2 · ±%3 m + 24 h + 48 h + 72 h + Constants of the port of %1 (distance %2°) + Estimated constants from lat/lon (synthetic model) + Z₀ %1 m · 7 harmonic constituents + Next high and low tides + High + Low + Moon + Sunrise / sunset + Typical range + age %1 d + (solar time) + New moon + Waxing crescent + First quarter + Waxing gibbous + Full moon + Waning gibbous + Last quarter + Waning crescent + Set as preferred port + Settings + + + SettingsPage + Language + System + Language changes on restart + Units + metres + feet + Default range + Preferred port + None + About + Universal tide predictions + Harmonic model: M2, S2, N2, K1, O1, P1, K2 — known ports or synthetic estimate from lat/lon. Not for navigation. + + + CoverPage + No port selected + + diff --git a/translations/harbour-maree_it.ts b/translations/harbour-maree_it.ts new file mode 100644 index 0000000..58f5392 --- /dev/null +++ b/translations/harbour-maree_it.ts @@ -0,0 +1,64 @@ + + + + + MainPage + Filter ports…Filtra porti… + Free search: any city (online)…Città libera: qualsiasi città (online)… + FindTrova + City not found (offline and not in local dataset).Città non trovata (offline e assente dal dataset locale). + Found %1 (via %2)Trovata %1 (via %2) + Searching…Cerco… + local datasetdataset locale + searched citycittà cercata + PortsPorti + + + DetailPage + lat %1°lat %1° + 📍 Nearest port: %1 — %2 · ±%3 m📍 Porto più vicino: %1 — %2 · ±%3 m + 24 h24 h + 48 h48 h + 72 h72 h + Constants of the port of %1 (distance %2°)Costanti del porto di %1 (distanza %2°) + Estimated constants from lat/lon (synthetic model)Costanti stimate da lat/lon (modello sintetico) + Z₀ %1 m · 7 harmonic constituentsZ₀ %1 m · 7 costituenti armonici + Next high and low tidesProssime alte e basse + HighAlta + LowBassa + MoonLuna + Sunrise / sunsetAlba / tramonto + Typical rangeEscursione tipica + age %1 detà %1 g + (solar time)(ora solare) + New moonLuna nuova + Waxing crescentLuna crescente + First quarterPrimo quarto + Waxing gibbousGibbosa crescente + Full moonLuna piena + Waning gibbousGibbosa calante + Last quarterUltimo quarto + Waning crescentLuna calante + Set as preferred portImposta come porto preferito + SettingsImpostazioni + + + SettingsPage + LanguageLingua + SystemSistema + Language changes on restartLa lingua cambia al riavvio + UnitsUnità + metresmetri + feetpiedi + Default rangeIntervallo predefinito + Preferred portPorto preferito + NoneNessuno + AboutInformazioni + Universal tide predictionsPrevisioni di marea universali + Harmonic model: M2, S2, N2, K1, O1, P1, K2 — known ports or synthetic estimate from lat/lon. Not for navigation.Modello armonico: M2, S2, N2, K1, O1, P1, K2 — porti noti o stima sintetica da lat/lon. Non per navigazione. + + + CoverPage + No port selectedNessun porto selezionato + +