Jordglob, spara plats och delade pins på stadsnivå
- Platsrutan: MapLibre GL 6.11 (självhostad, verifierad mot npm) med globprojektion, dag-/nattskugga i realtid med mjuk skymning, Natural Earth-konturer, röd nål, scroll- och knappzoom - "Spara som min plats" (bara i webbläsaren) i stället för automatisk sparning - "Dela min pin": öppet anonymt index i Cloudflare D1. Servern avrundar till 0,1°, slår själv upp stad/land (ingen fritext), en pin per ägare, borttagbar, spärr via saltad IP-hash som går ut - vendor/ markerad som -text i .gitattributes (filerna hålls byte-exakta) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
074d96566e
commit
aeb1c76428
@@ -0,0 +1,2 @@
|
|||||||
|
# third-party files are kept byte-for-byte (verified against their sources)
|
||||||
|
site/public/vendor/** -text
|
||||||
@@ -18,7 +18,9 @@ Källkoden till **https://publiccloud.se**: en helskärmshimmel som följer sole
|
|||||||
|
|
||||||
**Fyra rutor**
|
**Fyra rutor**
|
||||||
- **Just nu:** väder, temperatur, vind, måne, sol och plats.
|
- **Just nu:** väder, temperatur, vind, måne, sol och plats.
|
||||||
- **Plats:** karta med zoom (+/−), knappen "Utgå från min plats" (GPS), klick i kartan för att välja plats, och standardplatsen Örebro slott. Allt räknas om för vald plats.
|
- **Plats:** en jordglob (MapLibre GL 6.11, globprojektion) med dag- och nattskugga i realtid, kust- och landsgränser (Natural Earth), en röd nål för vald plats, scroll- och knappzoom, "Utgå från min plats" (GPS), klick på globen och standardplatsen Örebro slott. Allt räknas om för vald plats.
|
||||||
|
- **Spara som min plats:** sidan startar där nästa gång (sparas bara i webbläsaren).
|
||||||
|
- **Dela min pin:** ett öppet, anonymt index på stadsnivå. Servern avrundar positionen till 0,1°, slår själv upp stad och land (ingen fritext) och visar alla delade städer som glödande punkter på globen. Ägaren kan ta bort sin pin. Spärr: en delning per nätverk och 5 min (saltad IP-hash som går ut, IP sparas aldrig).
|
||||||
- **Kväll & natt:** betyg för stjärnhimlen, chans till norrsken (Kp nu och prognos), meteorregn samt fullmåne och nymåne.
|
- **Kväll & natt:** betyg för stjärnhimlen, chans till norrsken (Kp nu och prognos), meteorregn samt fullmåne och nymåne.
|
||||||
- **Fotoljus:** gyllene och blå timmen med väderprognos, månens upp- och nedgång, dimma, regnbåge och nattlysande moln.
|
- **Fotoljus:** gyllene och blå timmen med väderprognos, månens upp- och nedgång, dimma, regnbåge och nattlysande moln.
|
||||||
|
|
||||||
@@ -34,8 +36,11 @@ site/
|
|||||||
public/img/moon-photographer.svg nymåne-överraskningen
|
public/img/moon-photographer.svg nymåne-överraskningen
|
||||||
public/_headers cache-regler (filmerna cachas i ett år → nytt filnamn vid ny film)
|
public/_headers cache-regler (filmerna cachas i ett år → nytt filnamn vid ny film)
|
||||||
public/video/ day-v2 / night som WebM (VP9) + MP4 (H.264, reserv för äldre Safari)
|
public/video/ day-v2 / night som WebM (VP9) + MP4 (H.264, reserv för äldre Safari)
|
||||||
worker.js Cloudflare Worker: statiska filer + HTTP Range (206) för /video/* (krävs av Safari/iOS)
|
public/vendor/maplibre-6.11.2/ MapLibre GL JS (självhostad, verifierad mot npm-registrets integritet, BSD-3)
|
||||||
wrangler.jsonc Worker-konfiguration (namn publiccloud-sky)
|
public/vendor/ne/ Natural Earth 1:110m kust- och landsgränser (public domain)
|
||||||
|
worker.js Cloudflare Worker: statiska filer + HTTP Range (206) för /video/* + /api/pins (GET/POST/DELETE)
|
||||||
|
wrangler.jsonc Worker-konfiguration (namn publiccloud-sky, D1-databasen publiccloud-pins)
|
||||||
|
db/schema.sql tabellerna pins, ratelimit och geocache
|
||||||
tools/
|
tools/
|
||||||
upscale-slowmo.sh hur filmerna togs fram: Real-ESRGAN x4plus (uppskalning) + RIFE v4.6 (slowmotion)
|
upscale-slowmo.sh hur filmerna togs fram: Real-ESRGAN x4plus (uppskalning) + RIFE v4.6 (slowmotion)
|
||||||
```
|
```
|
||||||
@@ -48,6 +53,8 @@ Sajten körs som en Cloudflare Worker med statiska filer och custom domain `publ
|
|||||||
cd site
|
cd site
|
||||||
export CLOUDFLARE_API_TOKEN=... # konto-token med Workers-rättighet
|
export CLOUDFLARE_API_TOKEN=... # konto-token med Workers-rättighet
|
||||||
export CLOUDFLARE_ACCOUNT_ID=...
|
export CLOUDFLARE_ACCOUNT_ID=...
|
||||||
|
npx wrangler d1 execute publiccloud-pins --remote --file db/schema.sql # första gången
|
||||||
|
npx wrangler secret put PIN_SALT # slumpat salt för IP-spärren
|
||||||
npx wrangler deploy
|
npx wrangler deploy
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,6 +77,7 @@ npx wrangler deploy
|
|||||||
- Väder och prognos per timme: [Open-Meteo](https://open-meteo.com/) (gratis för icke-kommersiellt bruk)
|
- Väder och prognos per timme: [Open-Meteo](https://open-meteo.com/) (gratis för icke-kommersiellt bruk)
|
||||||
- Kp-index nu och 3-dygnsprognos: [NOAA SWPC](https://www.swpc.noaa.gov/)
|
- Kp-index nu och 3-dygnsprognos: [NOAA SWPC](https://www.swpc.noaa.gov/)
|
||||||
- Karta: [OpenStreetMap](https://www.openstreetmap.org/copyright) via [Leaflet](https://leafletjs.com/) 1.9.4 (cdnjs, SRI)
|
- Karta: [OpenStreetMap](https://www.openstreetmap.org/copyright) via [Leaflet](https://leafletjs.com/) 1.9.4 (cdnjs, SRI)
|
||||||
- Platsnamn: OpenStreetMap Nominatim
|
- Platsnamn: OpenStreetMap Nominatim (i webbläsaren för vald plats, i Workern för delade pins)
|
||||||
|
- Konturer: [Natural Earth](https://www.naturalearthdata.com) (public domain)
|
||||||
- Sol- och månberäkningar: formler från SunCalc (Vladimir Agafonkin), inbäddade i sidan
|
- Sol- och månberäkningar: formler från SunCalc (Vladimir Agafonkin), inbäddade i sidan
|
||||||
- Meteorregn: American Meteor Society / IMO:s kalendrar
|
- Meteorregn: American Meteor Society / IMO:s kalendrar
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
-- Shared pins: city level only. No free text; city/country come from the server's own reverse geocoding.
|
||||||
|
CREATE TABLE IF NOT EXISTS pins (
|
||||||
|
id TEXT PRIMARY KEY, -- random id
|
||||||
|
token_hash TEXT NOT NULL, -- sha-256 of the owner's secret (the secret itself only lives in their browser)
|
||||||
|
lat REAL NOT NULL, -- rounded to 0.1° (~11 km)
|
||||||
|
lng REAL NOT NULL,
|
||||||
|
city TEXT NOT NULL,
|
||||||
|
country TEXT NOT NULL,
|
||||||
|
cc TEXT NOT NULL, -- ISO country code
|
||||||
|
created INTEGER NOT NULL -- unix ms
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS pins_city ON pins (cc, city);
|
||||||
|
-- Rate limit: hashed IP (with a secret salt), expires after a few minutes. Nothing else about the visitor is stored.
|
||||||
|
CREATE TABLE IF NOT EXISTS ratelimit (k TEXT PRIMARY KEY, until INTEGER NOT NULL);
|
||||||
|
-- Server-side reverse-geocode cache per rounded position (keeps Nominatim traffic tiny)
|
||||||
|
CREATE TABLE IF NOT EXISTS geocache (k TEXT PRIMARY KEY, city TEXT, country TEXT, cc TEXT, created INTEGER NOT NULL);
|
||||||
+147
-32
@@ -6,10 +6,7 @@
|
|||||||
<title>publiccloud.se</title>
|
<title>publiccloud.se</title>
|
||||||
<meta name="description" content="Himlen över Örebro – följer solen och det aktuella vädret.">
|
<meta name="description" content="Himlen över Örebro – följer solen och det aktuella vädret.">
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.css"
|
<link rel="stylesheet" href="vendor/maplibre-6.11.2/maplibre-gl.css">
|
||||||
integrity="sha512-Zcn6bjR/8RZbLEpLIeOwNtzREBAJnUKESxces60Mpoj+2okopSAcSUIUOseddDm0cxnGQzxIR7vJgsLZbdLE3w==" crossorigin="anonymous">
|
|
||||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.js"
|
|
||||||
integrity="sha512-BwHfrr4c9kmRkLw6iXFdzcdWV/PGkVgiIyIWLLlTSXzWQzxuSg4DiQUCpauz/EWjgk5TYQqX/kvn9pG1NpYfqg==" crossorigin="anonymous"></script>
|
|
||||||
<style>
|
<style>
|
||||||
:root { --bg: #0b0d14; --fg: rgba(255,255,255,.85); }
|
:root { --bg: #0b0d14; --fg: rgba(255,255,255,.85); }
|
||||||
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
html, body { margin: 0; height: 100%; background: var(--bg); overflow: hidden; }
|
||||||
@@ -75,18 +72,22 @@
|
|||||||
.panel .row { display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
|
.panel .row { display: flex; justify-content: center; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; }
|
||||||
.grade { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: .82em; background: rgba(255,255,255,.16); white-space: nowrap; }
|
.grade { display: inline-block; padding: 1px 9px; border-radius: 999px; font-size: .82em; background: rgba(255,255,255,.16); white-space: nowrap; }
|
||||||
.grade.good { background: rgba(80, 200, 140, .38); } .grade.ok { background: rgba(230, 190, 80, .38); } .grade.poor { background: rgba(225, 95, 85, .38); }
|
.grade.good { background: rgba(80, 200, 140, .38); } .grade.ok { background: rgba(230, 190, 80, .38); } .grade.poor { background: rgba(225, 95, 85, .38); }
|
||||||
#map { height: clamp(110px, 22vh, 270px); border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: #1b2030; }
|
/* the globe (MapLibre, globe projection): transparent around the sphere so the glass shows through */
|
||||||
#map .leaflet-tile-pane { filter: invert(1) hue-rotate(180deg) brightness(.85) contrast(.9) saturate(.5); }
|
#map { height: clamp(150px, 26vh, 300px); border-radius: 10px; overflow: hidden; margin-bottom: 10px; background: transparent; position: relative; }
|
||||||
#map .leaflet-control-attribution { background: rgba(10,14,28,.6); color: rgba(255,255,255,.7); font-size: 10px; }
|
#map .maplibregl-ctrl-attrib { background: rgba(10,14,28,.6); color: rgba(255,255,255,.7); font-size: 10px; }
|
||||||
/* zoom buttons in the same glass style as the panels */
|
#map .maplibregl-ctrl-attrib a { color: #9cc3ff; }
|
||||||
#map .leaflet-bar { border: 1px solid rgba(255,255,255,.28); border-radius: 8px; overflow: hidden; box-shadow: none; }
|
#map .maplibregl-ctrl-attrib-button { filter: invert(1); background-color: transparent; }
|
||||||
#map .leaflet-bar a { width: 32px; height: 32px; line-height: 30px; font-size: 20px; font-weight: 400;
|
/* own + / − buttons in the glass style */
|
||||||
background: rgba(10,14,28,.62); color: #fff; border-bottom: 1px solid rgba(255,255,255,.2);
|
#map .gz { position: absolute; top: 8px; left: 8px; z-index: 2; display: flex; flex-direction: column;
|
||||||
backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); }
|
border: 1px solid rgba(255,255,255,.28); border-radius: 8px; overflow: hidden; }
|
||||||
#map .leaflet-bar a:last-child { border-bottom: 0; }
|
#map .gz button { width: 32px; height: 32px; padding: 0; border: 0; border-radius: 0; font: 400 20px/1 system-ui, sans-serif;
|
||||||
#map .leaflet-bar a:hover { background: rgba(40,48,70,.85); color: #fff; }
|
color: #fff; background: rgba(10,14,28,.62); backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); cursor: pointer; }
|
||||||
#map .leaflet-bar a.leaflet-disabled { color: rgba(255,255,255,.35); background: rgba(10,14,28,.62); }
|
#map .gz button + button { border-top: 1px solid rgba(255,255,255,.2); }
|
||||||
#map .leaflet-control-attribution a { color: #9cc3ff; }
|
#map .gz button:hover { background: rgba(40,48,70,.85); }
|
||||||
|
.me-pin { width: 24px; height: 34px; filter: drop-shadow(0 2px 3px rgba(0,0,0,.55)); cursor: default; }
|
||||||
|
#pin-tip { position: absolute; z-index: 3; pointer-events: none; padding: 4px 9px; border-radius: 7px; font-size: .8em; white-space: nowrap;
|
||||||
|
background: rgba(10,14,28,.8); color: #fff; transform: translate(-50%, -140%); display: none; }
|
||||||
|
.panel .btns.small button { font-size: .82em; padding: 5px 10px; }
|
||||||
.btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; justify-content: center; }
|
.btns { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; justify-content: center; }
|
||||||
.panel button { font: inherit; font-size: .9em; color: #fff; background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.28);
|
.panel button { font: inherit; font-size: .9em; color: #fff; background: rgba(255,255,255,.14); border: 1px solid rgba(255,255,255,.28);
|
||||||
border-radius: 8px; padding: 6px 12px; cursor: pointer; }
|
border-radius: 8px; padding: 6px 12px; cursor: pointer; }
|
||||||
@@ -167,13 +168,18 @@
|
|||||||
</section>
|
</section>
|
||||||
<section class="panel tr" aria-label="Plats">
|
<section class="panel tr" aria-label="Plats">
|
||||||
<h2>Plats</h2>
|
<h2>Plats</h2>
|
||||||
<div id="map" role="application" aria-label="Karta – klicka för att välja plats"></div>
|
<div id="map" role="application" aria-label="Jordglob – klicka för att välja plats"><div id="pin-tip"></div></div>
|
||||||
<div class="row"><strong id="p-place">Örebro slott</strong><span class="note" id="p-coords" style="margin:0"></span></div>
|
<div class="row"><strong id="p-place">Örebro slott</strong><span class="note" id="p-coords" style="margin:0"></span></div>
|
||||||
<div class="btns">
|
<div class="btns">
|
||||||
<button id="b-mine" type="button">Utgå från min plats</button>
|
<button id="b-mine" type="button">Utgå från min plats</button>
|
||||||
<button id="b-home" type="button">Örebro slott</button>
|
<button id="b-home" type="button">Örebro slott</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="note" id="p-loc-note">Klicka i kartan för att välja en annan plats.</div>
|
<div class="btns small">
|
||||||
|
<button id="b-save" type="button">Spara som min plats</button>
|
||||||
|
<button id="b-share" type="button">Dela min pin</button>
|
||||||
|
</div>
|
||||||
|
<div class="note" id="p-loc-note">Klicka på globen för att välja en annan plats.</div>
|
||||||
|
<div class="note" id="p-pins-note"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="panel bl" aria-label="Natten">
|
<section class="panel bl" aria-label="Natten">
|
||||||
<h2>Kväll & natt</h2>
|
<h2>Kväll & natt</h2>
|
||||||
@@ -193,7 +199,11 @@
|
|||||||
// (GPS) or click the map; everything (sun, moon, weather, aurora) is then computed for that place. Saved per browser.
|
// (GPS) or click the map; everything (sun, moon, weather, aurora) is then computed for that place. Saved per browser.
|
||||||
const HOME = { lat: 59.27389, lng: 15.21528, name: 'Örebro slott', home: true };
|
const HOME = { lat: 59.27389, lng: 15.21528, name: 'Örebro slott', home: true };
|
||||||
let loc = HOME;
|
let loc = HOME;
|
||||||
try { const saved = JSON.parse(localStorage.getItem('pc-loc') || 'null'); if (saved && isFinite(saved.lat) && isFinite(saved.lng)) loc = saved; } catch (_) {}
|
const store = { get(k) { try { return JSON.parse(localStorage.getItem(k) || 'null'); } catch (_) { return null; } },
|
||||||
|
set(k, v) { try { v == null ? localStorage.removeItem(k) : localStorage.setItem(k, JSON.stringify(v)); } catch (_) {} } };
|
||||||
|
if (!store.get('pc-home') && store.get('pc-loc')) store.set('pc-home', store.get('pc-loc')); // earlier versions auto-saved
|
||||||
|
store.set('pc-loc', null);
|
||||||
|
{ const saved = store.get('pc-home'); if (saved && isFinite(saved.lat) && isFinite(saved.lng)) loc = saved; }
|
||||||
let LAT = loc.lat, LNG = loc.lng;
|
let LAT = loc.lat, LNG = loc.lng;
|
||||||
const FPS = 24;
|
const FPS = 24;
|
||||||
const webm = document.createElement('video').canPlayType('video/webm; codecs="vp9"') !== '';
|
const webm = document.createElement('video').canPlayType('video/webm; codecs="vp9"') !== '';
|
||||||
@@ -973,17 +983,122 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------- location: map, own position, Örebro slott ----------
|
// ---------- location: map, own position, Örebro slott ----------
|
||||||
let map = null, marker = null;
|
// ---------- the globe: MapLibre GL (self-hosted), globe projection, day/night shading, shared city pins ----------
|
||||||
function initMap() {
|
let map = null, marker = null, ml = null, pinData = { cities: [], total: 0 };
|
||||||
if (!window.L || map) return;
|
// the night side of the Earth right now: everything beyond the terminator (subsolar point from the same sun formulas)
|
||||||
map = L.map('map', { zoomControl: false, scrollWheelZoom: false }).setView([LAT, LNG], 11);
|
function nightPolygon(t) {
|
||||||
L.control.zoom({ position: 'topleft', zoomInTitle: 'Zooma in', zoomOutTitle: 'Zooma ut' }).addTo(map); // + / − buttons
|
// grid of 1.5° cells; sun altitude per cell centre; darkness grows through twilight (0 → −12°); equal cells in a row are merged.
|
||||||
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 18,
|
// Stable on every date (a closed-form terminator line breaks down around the equinoxes, when the sun's declination ≈ 0).
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' }).addTo(map);
|
const d = toDays(t), L = eclLong(meanAnomaly(d));
|
||||||
marker = L.circleMarker([LAT, LNG], { radius: 7, color: '#fff', weight: 2, fillColor: '#ffcf5c', fillOpacity: .9 }).addTo(map);
|
const dec = Math.asin(Math.sin(e) * Math.sin(L)), ra = Math.atan2(Math.sin(L) * Math.cos(e), Math.cos(L));
|
||||||
map.on('click', e => setLocation({ lat: e.latlng.lat, lng: e.latlng.lng, name: null, home: false }));
|
const lonS = (ra - rad * (280.16 + 360.9856235 * d)) / rad;
|
||||||
|
const feats = [], S = 1.5;
|
||||||
|
for (let la = -84; la < 84; la += S) {
|
||||||
|
const phi = (la + S / 2) * rad; let run = null;
|
||||||
|
const flush = () => { if (run && run.o > 0) feats.push({ type: 'Feature', properties: { o: run.o },
|
||||||
|
geometry: { type: 'Polygon', coordinates: [[[run.a, la], [run.b, la], [run.b, la + S], [run.a, la + S], [run.a, la]]] } }); run = null; };
|
||||||
|
for (let lo = -180; lo < 180; lo += S) {
|
||||||
|
const H = (lo + S / 2 - lonS) * rad;
|
||||||
|
const alt = Math.asin(Math.sin(phi) * Math.sin(dec) + Math.cos(phi) * Math.cos(dec) * Math.cos(H)) / rad;
|
||||||
|
const o = Math.round(Math.min(1, Math.max(0, (.5 - alt) / 12.5)) * 16) / 16; // 0 in daylight … 1 in full night, 16 steps
|
||||||
|
if (run && run.o === o) run.b = lo + S; else { flush(); run = { a: lo, b: lo + S, o }; }
|
||||||
|
}
|
||||||
|
flush();
|
||||||
|
}
|
||||||
|
return { type: 'FeatureCollection', features: feats };
|
||||||
|
}
|
||||||
|
function pinsGeo() {
|
||||||
|
return { type: 'FeatureCollection', features: pinData.cities.map(c => ({ type: 'Feature',
|
||||||
|
properties: { n: c.n, label: `${c.city}, ${c.country} · ${c.n} ${c.n === 1 ? 'delad pin' : 'delade pins'}` },
|
||||||
|
geometry: { type: 'Point', coordinates: [c.lng, c.lat] } })) };
|
||||||
|
}
|
||||||
|
async function initMap() {
|
||||||
|
if (map) return;
|
||||||
|
try { ml = await import('./vendor/maplibre-6.11.2/maplibre-gl.mjs'); }
|
||||||
|
catch (_) { $('map').innerHTML = '<div class="note" style="padding:40px 10px">Globen kunde inte laddas.</div>'; return; }
|
||||||
|
try {
|
||||||
|
// start zoom so the whole globe fits the panel (globe diameter ≈ 512·2^z/π px)
|
||||||
|
const box = $('map').getBoundingClientRect(), fitZ = Math.log2(.62 * Math.min(box.width, box.height) * Math.PI / 512); // .62: comfortable margin (MapLibre draws the globe ~17 % larger than 512·2^z/π)
|
||||||
|
map = new ml.Map({ container: 'map', center: [LNG, LAT], zoom: fitZ, minZoom: fitZ - .4, attributionControl: { compact: true }, dragRotate: false,
|
||||||
|
style: { version: 8, projection: { type: 'globe' },
|
||||||
|
sources: { osm: { type: 'raster', tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tileSize: 256, maxzoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>' },
|
||||||
|
night: { type: 'geojson', data: nightPolygon(now()) },
|
||||||
|
coast: { type: 'geojson', data: location.origin + '/vendor/ne/coast-110m.json' },
|
||||||
|
borders: { type: 'geojson', data: location.origin + '/vendor/ne/borders-110m.json' },
|
||||||
|
pins: { type: 'geojson', data: pinsGeo() } },
|
||||||
|
layers: [
|
||||||
|
{ id: 'osm', type: 'raster', source: 'osm', paint: { 'raster-brightness-max': .8, 'raster-saturation': .05, 'raster-contrast': .18 } },
|
||||||
|
{ id: 'night', type: 'fill', source: 'night', paint: { 'fill-color': '#040610', 'fill-opacity': ['*', .62, ['get', 'o']], 'fill-antialias': false } },
|
||||||
|
{ id: 'borders', type: 'line', source: 'borders', paint: { 'line-color': 'rgba(70,64,60,.6)', 'line-dasharray': [2, 1.5],
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 0, .5, 5, 1] } },
|
||||||
|
{ id: 'coast', type: 'line', source: 'coast', paint: { 'line-color': 'rgba(20,52,88,.9)',
|
||||||
|
'line-width': ['interpolate', ['linear'], ['zoom'], 0, .9, 5, 1.6] } },
|
||||||
|
{ id: 'pins-glow', type: 'circle', source: 'pins', paint: { 'circle-color': '#ffd76a', 'circle-opacity': .3, 'circle-blur': .9,
|
||||||
|
'circle-radius': ['+', 7, ['*', 3, ['sqrt', ['get', 'n']]]] } },
|
||||||
|
{ id: 'pins', type: 'circle', source: 'pins', paint: { 'circle-color': '#ffe7a3', 'circle-stroke-color': '#fff', 'circle-stroke-width': .8,
|
||||||
|
'circle-radius': ['+', 2.2, ['*', 1.1, ['sqrt', ['get', 'n']]]] } } ] } });
|
||||||
|
} catch (_) { $('map').insertAdjacentHTML('beforeend', '<div class="note" style="padding:40px 10px">Din webbläsare kan inte visa globen (WebGL).</div>'); return; }
|
||||||
|
$('map').insertAdjacentHTML('beforeend', '<div class="gz"><button type="button" id="gz-in" aria-label="Zooma in" title="Zooma in">+</button>'
|
||||||
|
+ '<button type="button" id="gz-out" aria-label="Zooma ut" title="Zooma ut">−</button></div>');
|
||||||
|
$('gz-in').onclick = () => map.zoomIn(); $('gz-out').onclick = () => map.zoomOut();
|
||||||
|
setInterval(() => map.getSource('night') && map.getSource('night').setData(nightPolygon(now())), 60 * 1000);
|
||||||
|
map.on('load', () => { fetchPins(); $('map').dataset.proj = JSON.stringify(map.getProjection && map.getProjection()); }); // diagnostics
|
||||||
|
map.on('error', ev => { $('map').dataset.err = String(ev && ev.error && ev.error.message || ev).slice(0, 200); });
|
||||||
|
const el = document.createElement('div'); el.className = 'me-pin'; el.title = 'Din valda plats';
|
||||||
|
el.innerHTML = '<svg viewBox="0 0 24 34" width="24" height="34" aria-hidden="true"><path d="M12 33c-.6 0-1-.4-1.3-.9C8.6 27.6 1 19.6 1 12a11 11 0 0 1 22 0c0 7.6-7.6 15.6-9.7 20.1-.3.5-.7.9-1.3.9z" fill="#e63946" stroke="#8d1a24" stroke-width="1"/><circle cx="12" cy="12" r="4.2" fill="#fff"/></svg>';
|
||||||
|
marker = new ml.Marker({ element: el, anchor: 'bottom' }).setLngLat([LNG, LAT]).addTo(map);
|
||||||
|
const tip = $('pin-tip');
|
||||||
|
map.on('mousemove', 'pins', ev => { const f = ev.features[0]; tip.textContent = f.properties.label; tip.style.left = ev.point.x + 'px';
|
||||||
|
tip.style.top = ev.point.y + 'px'; tip.style.display = 'block'; map.getCanvas().style.cursor = 'pointer'; });
|
||||||
|
map.on('mouseleave', 'pins', () => { tip.style.display = 'none'; map.getCanvas().style.cursor = ''; });
|
||||||
|
map.on('click', ev => {
|
||||||
|
const hit = map.getLayer('pins') && map.queryRenderedFeatures(ev.point, { layers: ['pins', 'pins-glow'] })[0];
|
||||||
|
if (hit) { tip.textContent = hit.properties.label; tip.style.left = ev.point.x + 'px'; tip.style.top = ev.point.y + 'px'; tip.style.display = 'block'; return; }
|
||||||
|
setLocation({ lat: ev.lngLat.lat, lng: ((ev.lngLat.lng + 540) % 360) - 180, name: null, home: false });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
addEventListener('load', initMap);
|
addEventListener('load', initMap);
|
||||||
|
|
||||||
|
// ---------- shared pins: an open, anonymous index at city level (server looks up the city; no free text) ----------
|
||||||
|
async function fetchPins() {
|
||||||
|
try { pinData = await (await fetch('/api/pins', { cache: 'no-store' })).json(); } catch (_) { return; }
|
||||||
|
if (map && map.getSource('pins')) map.getSource('pins').setData(pinsGeo());
|
||||||
|
const n = pinData.total || 0, c = (pinData.cities || []).length;
|
||||||
|
$('p-pins-note').textContent = n ? `${n} ${n === 1 ? 'person' : 'personer'} i ${c} ${c === 1 ? 'stad' : 'städer'} följer himlen här.` : '';
|
||||||
|
}
|
||||||
|
setInterval(fetchPins, 5 * 60 * 1000);
|
||||||
|
function showPinButtons() {
|
||||||
|
const mine = store.get('pc-pin'), saved = store.get('pc-home');
|
||||||
|
$('b-share').textContent = mine ? `Ta bort min pin (${mine.city})` : 'Dela min pin';
|
||||||
|
$('b-share').title = mine ? '' : 'Delas anonymt: bara stad och land, ingen exakt position. Du kan ta bort den när du vill.';
|
||||||
|
const same = saved && Math.abs(saved.lat - LAT) < 1e-6 && Math.abs(saved.lng - LNG) < 1e-6;
|
||||||
|
$('b-save').textContent = same ? 'Glöm min plats' : 'Spara som min plats';
|
||||||
|
}
|
||||||
|
$('b-save').addEventListener('click', () => {
|
||||||
|
const saved = store.get('pc-home'), same = saved && Math.abs(saved.lat - LAT) < 1e-6 && Math.abs(saved.lng - LNG) < 1e-6;
|
||||||
|
if (same) { store.set('pc-home', null); $('p-loc-note').textContent = 'Glömt – nästa gång startar sidan på Örebro slott.'; }
|
||||||
|
else { store.set('pc-home', { lat: LAT, lng: LNG, name: loc.name, home: !!loc.home });
|
||||||
|
$('p-loc-note').textContent = `Sparad! Sidan startar på ${loc.name || 'den här platsen'} nästa gång (bara i din webbläsare).`; }
|
||||||
|
showPinButtons();
|
||||||
|
});
|
||||||
|
$('b-share').addEventListener('click', async () => {
|
||||||
|
const mine = store.get('pc-pin'), btn = $('b-share'); btn.disabled = true;
|
||||||
|
try {
|
||||||
|
if (mine) {
|
||||||
|
await fetch('/api/pins', { method: 'DELETE', headers: { 'X-Pin-Token': mine.token } });
|
||||||
|
store.set('pc-pin', null); $('p-loc-note').textContent = 'Din pin är borttagen.';
|
||||||
|
} else {
|
||||||
|
const r = await fetch('/api/pins', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ lat: LAT, lng: LNG }) });
|
||||||
|
const d = await r.json();
|
||||||
|
if (!r.ok) { $('p-loc-note').textContent = d.error || 'Kunde inte dela just nu.'; return; }
|
||||||
|
store.set('pc-pin', { token: d.token, city: d.city, country: d.country });
|
||||||
|
$('p-loc-note').textContent = `Delad som ${d.city}, ${d.country} – bara stad och land, anonymt.`;
|
||||||
|
}
|
||||||
|
fetchPins();
|
||||||
|
} catch (_) { $('p-loc-note').textContent = 'Kunde inte nå servern just nu.'; }
|
||||||
|
finally { btn.disabled = false; showPinButtons(); }
|
||||||
|
});
|
||||||
async function placeName(lat, lng) { // OpenStreetMap Nominatim, only when the place changes
|
async function placeName(lat, lng) { // OpenStreetMap Nominatim, only when the place changes
|
||||||
try {
|
try {
|
||||||
const r = await (await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=14&accept-language=sv&lat=${lat.toFixed(5)}&lon=${lng.toFixed(5)}`)).json();
|
const r = await (await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=14&accept-language=sv&lat=${lat.toFixed(5)}&lon=${lng.toFixed(5)}`)).json();
|
||||||
@@ -997,22 +1112,22 @@
|
|||||||
$('p-place').textContent = loc.name || 'Vald plats';
|
$('p-place').textContent = loc.name || 'Vald plats';
|
||||||
$('p-coords').textContent = `${LAT.toFixed(3)}°, ${LNG.toFixed(3)}°`;
|
$('p-coords').textContent = `${LAT.toFixed(3)}°, ${LNG.toFixed(3)}°`;
|
||||||
$('b-home').disabled = !!loc.home;
|
$('b-home').disabled = !!loc.home;
|
||||||
if (map) { map.setView([LAT, LNG], map.getZoom()); marker.setLatLng([LAT, LNG]); }
|
if (map) { map.easeTo({ center: [LNG, LAT], duration: 1200 }); marker.setLngLat([LNG, LAT]); }
|
||||||
|
showPinButtons();
|
||||||
}
|
}
|
||||||
async function setLocation(l) {
|
async function setLocation(l) {
|
||||||
loc = l; LAT = l.lat; LNG = l.lng; sunDay = null; lastPhase = -1;
|
loc = l; LAT = l.lat; LNG = l.lng; sunDay = null; lastPhase = -1;
|
||||||
setTZ(l.home ? 'Europe/Stockholm' : (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Stockholm'));
|
setTZ(l.home ? 'Europe/Stockholm' : (Intl.DateTimeFormat().resolvedOptions().timeZone || 'Europe/Stockholm'));
|
||||||
showLoc(); tick(); fetchWeather();
|
showLoc(); tick(); fetchWeather();
|
||||||
if (!l.name) { l.name = await placeName(l.lat, l.lng); if (loc === l) showLoc(); }
|
if (!l.name) { l.name = await placeName(l.lat, l.lng); if (loc === l) showLoc(); }
|
||||||
try { localStorage.setItem('pc-loc', JSON.stringify(l)); } catch (_) {}
|
|
||||||
renderPanels();
|
renderPanels();
|
||||||
}
|
}
|
||||||
$('b-home').addEventListener('click', () => { $('p-loc-note').textContent = 'Klicka i kartan för att välja en annan plats.'; setLocation({ ...HOME }); });
|
$('b-home').addEventListener('click', () => { $('p-loc-note').textContent = 'Klicka på globen för att välja en annan plats.'; setLocation({ ...HOME }); });
|
||||||
$('b-mine').addEventListener('click', () => {
|
$('b-mine').addEventListener('click', () => {
|
||||||
if (!navigator.geolocation) { $('p-loc-note').textContent = 'Din webbläsare kan inte dela position.'; return; }
|
if (!navigator.geolocation) { $('p-loc-note').textContent = 'Din webbläsare kan inte dela position.'; return; }
|
||||||
$('p-loc-note').textContent = 'Hämtar din position…';
|
$('p-loc-note').textContent = 'Hämtar din position…';
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
p => { $('p-loc-note').textContent = `Din position (±${Math.round(p.coords.accuracy)} m). Sparas bara i din webbläsare.`;
|
p => { $('p-loc-note').textContent = `Din position (±${Math.round(p.coords.accuracy)} m). Tryck "Spara som min plats" för att starta här nästa gång.`;
|
||||||
setLocation({ lat: p.coords.latitude, lng: p.coords.longitude, name: null, home: false }); },
|
setLocation({ lat: p.coords.latitude, lng: p.coords.longitude, name: null, home: false }); },
|
||||||
err => { $('p-loc-note').textContent = err.code === 1 ? 'Du nekade positionsdelning – vi utgår från Örebro slott.' : 'Kunde inte hämta din position.'; },
|
err => { $('p-loc-note').textContent = err.code === 1 ? 'Du nekade positionsdelning – vi utgår från Örebro slott.' : 'Kunde inte hämta din position.'; },
|
||||||
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 10 * 60 * 1000 });
|
{ enableHighAccuracy: false, timeout: 15000, maximumAge: 10 * 60 * 1000 });
|
||||||
|
|||||||
+116
@@ -0,0 +1,116 @@
|
|||||||
|
Copyright (c) 2023, MapLibre contributors
|
||||||
|
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of MapLibre GL JS nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Contains code from mapbox-gl-js v1.13 and earlier
|
||||||
|
|
||||||
|
Version v1.13 of mapbox-gl-js and earlier are licensed under a BSD-3-Clause license
|
||||||
|
|
||||||
|
Copyright (c) 2020, Mapbox
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer.
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
* Neither the name of Mapbox GL JS nor the names of its contributors
|
||||||
|
may be used to endorse or promote products derived from this software
|
||||||
|
without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
||||||
|
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
|
||||||
|
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
|
||||||
|
CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
|
||||||
|
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
|
||||||
|
PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
|
||||||
|
LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
|
||||||
|
NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
|
||||||
|
EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
|
|
||||||
|
|
||||||
|
-------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Contains code from glfx.js
|
||||||
|
|
||||||
|
Copyright (C) 2011 by Evan Wallace
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in
|
||||||
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||||
|
THE SOFTWARE.
|
||||||
|
|
||||||
|
--------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
Contains a portion of d3-color https://github.com/d3/d3-color
|
||||||
|
|
||||||
|
Copyright 2010-2016 Mike Bostock
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without modification,
|
||||||
|
are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of the author nor the names of contributors may be used to
|
||||||
|
endorse or promote products derived from this software without specific prior
|
||||||
|
written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
|
||||||
|
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
||||||
|
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
|
||||||
|
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
||||||
|
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
||||||
|
LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
|
||||||
|
ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
||||||
|
SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+819
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
Natural Earth (https://www.naturalearthdata.com) - public domain. 1:110m coastline + admin-0 land boundary lines, properties removed, coordinates rounded to 2 decimals.
|
||||||
+1
File diff suppressed because one or more lines are too long
+1
File diff suppressed because one or more lines are too long
+87
-1
@@ -1,7 +1,13 @@
|
|||||||
// Static assets + HTTP Range for /video/* (Safari/iOS will not play video without 206 responses)
|
// Static assets + HTTP Range for /video/* (Safari/iOS will not play video without 206 responses)
|
||||||
|
// + /api/pins: an open, anonymous index of shared pins at CITY level (no free text, name looked up by the server).
|
||||||
export default {
|
export default {
|
||||||
async fetch(req, env) {
|
async fetch(req, env, ctx) {
|
||||||
const url = new URL(req.url);
|
const url = new URL(req.url);
|
||||||
|
if (url.pathname === '/api/pins') {
|
||||||
|
try { return await pins(req, env, ctx, url); }
|
||||||
|
catch (e) { return json({ error: 'Något gick fel', detail: String(e && e.message || e).slice(0, 120) }, 500); }
|
||||||
|
}
|
||||||
|
|
||||||
const range = req.headers.get('Range');
|
const range = req.headers.get('Range');
|
||||||
if (!url.pathname.startsWith('/video/') || !range) return env.ASSETS.fetch(req);
|
if (!url.pathname.startsWith('/video/') || !range) return env.ASSETS.fetch(req);
|
||||||
|
|
||||||
@@ -22,3 +28,83 @@ export default {
|
|||||||
return new Response(req.method === 'HEAD' ? null : buf.slice(start, end + 1), { status: 206, headers: h });
|
return new Response(req.method === 'HEAD' ? null : buf.slice(start, end + 1), { status: 206, headers: h });
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const json = (data, status = 200, extra = {}) =>
|
||||||
|
new Response(JSON.stringify(data), { status, headers: { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store', ...extra } });
|
||||||
|
|
||||||
|
async function sha256(s) {
|
||||||
|
const b = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s));
|
||||||
|
return [...new Uint8Array(b)].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
}
|
||||||
|
const randHex = n => [...crypto.getRandomValues(new Uint8Array(n))].map(x => x.toString(16).padStart(2, '0')).join('');
|
||||||
|
const LIST_KEY = new Request('https://publiccloud.se/__pins-v1');
|
||||||
|
|
||||||
|
// City and country for a rounded position, from OpenStreetMap Nominatim (server side, cached in D1).
|
||||||
|
// The visitor never sends a name, so nobody can put arbitrary text on the globe.
|
||||||
|
async function cityAt(env, lat, lng) {
|
||||||
|
const k = `${lat.toFixed(1)},${lng.toFixed(1)}`;
|
||||||
|
const hit = await env.DB.prepare('SELECT city, country, cc FROM geocache WHERE k = ?').bind(k).first();
|
||||||
|
if (hit) return hit.city && hit.country ? hit : null;
|
||||||
|
const r = await fetch(`https://nominatim.openstreetmap.org/reverse?format=jsonv2&zoom=10&accept-language=sv&lat=${lat}&lon=${lng}`,
|
||||||
|
{ headers: { 'User-Agent': 'publiccloud.se shared-pins (https://publiccloud.se)', 'Referer': 'https://publiccloud.se/' } });
|
||||||
|
if (!r.ok) throw new Error('geocode ' + r.status);
|
||||||
|
const a = (await r.json()).address || {};
|
||||||
|
const city = a.city || a.town || a.village || a.municipality || a.county || null;
|
||||||
|
const out = { city: city ? String(city).slice(0, 80) : null, country: a.country ? String(a.country).slice(0, 80) : null,
|
||||||
|
cc: a.country_code ? String(a.country_code).slice(0, 2).toUpperCase() : null };
|
||||||
|
await env.DB.prepare('INSERT OR REPLACE INTO geocache (k, city, country, cc, created) VALUES (?, ?, ?, ?, ?)')
|
||||||
|
.bind(k, out.city, out.country, out.cc, Date.now()).run();
|
||||||
|
return out.city && out.country ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pins(req, env, ctx, url) {
|
||||||
|
// ---- list: one entry per city with a count (last 365 days), cached briefly at the edge ----
|
||||||
|
if (req.method === 'GET') {
|
||||||
|
const hit = await caches.default.match(LIST_KEY); if (hit) return hit;
|
||||||
|
const { results } = await env.DB.prepare(
|
||||||
|
`SELECT city, country, cc, ROUND(AVG(lat), 2) AS lat, ROUND(AVG(lng), 2) AS lng, COUNT(*) AS n
|
||||||
|
FROM pins WHERE created > ? GROUP BY cc, city ORDER BY n DESC LIMIT 3000`).bind(Date.now() - 365 * 864e5).all();
|
||||||
|
const res = json({ cities: results, total: results.reduce((s, r) => s + r.n, 0) }, 200, { 'Cache-Control': 'public, max-age=60' });
|
||||||
|
ctx.waitUntil(caches.default.put(LIST_KEY, res.clone()));
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- share: body { lat, lng, token? } -> rounded to 0.1°, city looked up here, one pin per owner ----
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const b = await req.json().catch(() => ({}));
|
||||||
|
let lat = Number(b.lat), lng = Number(b.lng);
|
||||||
|
if (!isFinite(lat) || !isFinite(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return json({ error: 'Ogiltig position' }, 400);
|
||||||
|
lat = Math.round(lat * 10) / 10; lng = Math.round(lng * 10) / 10;
|
||||||
|
|
||||||
|
// rate limit: one share per network per 5 minutes (salted hash of the IP, expires; the IP itself is never stored)
|
||||||
|
const ip = req.headers.get('CF-Connecting-IP') || 'x';
|
||||||
|
const rk = await sha256((env.PIN_SALT || '') + '|' + ip), now = Date.now();
|
||||||
|
const rl = await env.DB.prepare('SELECT until FROM ratelimit WHERE k = ?').bind(rk).first();
|
||||||
|
if (rl && rl.until > now) return json({ error: 'Vänta några minuter innan du delar igen' }, 429);
|
||||||
|
ctx.waitUntil(env.DB.prepare('DELETE FROM ratelimit WHERE until < ?').bind(now).run());
|
||||||
|
|
||||||
|
const place = await cityAt(env, lat, lng);
|
||||||
|
if (!place) return json({ error: 'Hittar ingen stad här – välj en plats på land' }, 422);
|
||||||
|
|
||||||
|
const token = typeof b.token === 'string' && /^[0-9a-f]{64}$/.test(b.token) ? b.token : randHex(32);
|
||||||
|
const th = await sha256(token);
|
||||||
|
await env.DB.batch([
|
||||||
|
env.DB.prepare('DELETE FROM pins WHERE token_hash = ?').bind(th), // one pin per owner: sharing again moves it
|
||||||
|
env.DB.prepare('INSERT INTO pins (id, token_hash, lat, lng, city, country, cc, created) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||||
|
.bind(randHex(8), th, lat, lng, place.city, place.country, place.cc, now),
|
||||||
|
env.DB.prepare('INSERT OR REPLACE INTO ratelimit (k, until) VALUES (?, ?)').bind(rk, now + 5 * 6e4),
|
||||||
|
]);
|
||||||
|
ctx.waitUntil(caches.default.delete(LIST_KEY));
|
||||||
|
return json({ token, city: place.city, country: place.country, cc: place.cc });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- remove: only with the owner's secret ----
|
||||||
|
if (req.method === 'DELETE') {
|
||||||
|
const token = req.headers.get('X-Pin-Token') || '';
|
||||||
|
if (!/^[0-9a-f]{64}$/.test(token)) return json({ error: 'Saknar nyckel' }, 400);
|
||||||
|
const r = await env.DB.prepare('DELETE FROM pins WHERE token_hash = ?').bind(await sha256(token)).run();
|
||||||
|
ctx.waitUntil(caches.default.delete(LIST_KEY));
|
||||||
|
return json({ removed: r.meta.changes });
|
||||||
|
}
|
||||||
|
return json({ error: 'Okänd förfrågan' }, 405);
|
||||||
|
}
|
||||||
|
|||||||
+4
-1
@@ -2,6 +2,9 @@
|
|||||||
"name": "publiccloud-sky",
|
"name": "publiccloud-sky",
|
||||||
"main": "worker.js",
|
"main": "worker.js",
|
||||||
"compatibility_date": "2026-09-01",
|
"compatibility_date": "2026-09-01",
|
||||||
"assets": { "directory": "./public", "binding": "ASSETS", "run_worker_first": ["/video/*"] },
|
"assets": { "directory": "./public", "binding": "ASSETS", "run_worker_first": ["/video/*", "/api/*"] },
|
||||||
|
"d1_databases": [
|
||||||
|
{ "binding": "DB", "database_name": "publiccloud-pins", "database_id": "45f2cdf7-2681-4cd0-88d6-9b1ad56e6f47" }
|
||||||
|
],
|
||||||
"workers_dev": true
|
"workers_dev": true
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user