This commit is contained in:
Marcin Czop 2025-01-30 04:26:17 +01:00
parent 75ce77128e
commit 9f8406ae3e
No known key found for this signature in database
GPG key ID: 44BCC84471234D0D
8 changed files with 434 additions and 0 deletions

2
.gitignore vendored
View file

@ -86,3 +86,5 @@ typings/
# DynamoDB Local files # DynamoDB Local files
.dynamodb/ .dynamodb/
*.sqlite

9
Dockerfile Normal file
View file

@ -0,0 +1,9 @@
FROM denoland/deno:alpine
EXPOSE 8000
WORKDIR /app
COPY deno.* .
COPY public ./public
COPY src ./src
RUN deno cache src/server.ts
RUN deno eval --unstable-ffi "import '@db/sqlite'"
CMD ["run", "-A", "src/server.ts"]

12
deno.json Normal file
View file

@ -0,0 +1,12 @@
{
"tasks": {
"dev": "deno run --watch -A src/server.ts"
},
"imports": {
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@deno-library/compress": "jsr:@deno-library/compress@^0.5.5",
"@std/assert": "jsr:@std/assert@1",
"h3-js": "npm:h3-js@^4.1.0",
"hono": "npm:hono@^4.6.19"
}
}

149
deno.lock generated Normal file
View file

@ -0,0 +1,149 @@
{
"version": "4",
"specifiers": {
"jsr:@db/sqlite@0.12": "0.12.0",
"jsr:@db/sqlite@0.12.0": "0.12.0",
"jsr:@deno-library/compress@~0.5.5": "0.5.5",
"jsr:@deno-library/crc32@1.0.2": "1.0.2",
"jsr:@denosaurs/plug@1": "1.0.6",
"jsr:@std/assert@0.217": "0.217.0",
"jsr:@std/assert@0.221": "0.221.0",
"jsr:@std/assert@1": "1.0.11",
"jsr:@std/bytes@^1.0.2": "1.0.4",
"jsr:@std/encoding@0.221": "0.221.0",
"jsr:@std/fmt@0.221": "0.221.0",
"jsr:@std/fs@0.221": "0.221.0",
"jsr:@std/fs@1.0.5": "1.0.5",
"jsr:@std/internal@^1.0.5": "1.0.5",
"jsr:@std/io@0.225.0": "0.225.0",
"jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@0.221": "0.221.0",
"jsr:@std/path@1.0.8": "1.0.8",
"jsr:@std/path@^1.0.7": "1.0.8",
"jsr:@std/streams@^1.0.7": "1.0.8",
"jsr:@std/tar@0.1.3": "0.1.3",
"jsr:@zip-js/zip-js@2.7.53": "2.7.53",
"npm:h3-js@*": "4.1.0",
"npm:h3-js@^4.1.0": "4.1.0",
"npm:hono@^4.6.19": "4.6.19"
},
"jsr": {
"@db/sqlite@0.12.0": {
"integrity": "dd1ef7f621ad50fc1e073a1c3609c4470bd51edc0994139c5bf9851de7a6d85f",
"dependencies": [
"jsr:@denosaurs/plug",
"jsr:@std/path@0.217"
]
},
"@deno-library/compress@0.5.5": {
"integrity": "18b651a33eac87d96ae8c941487045724a665d654e9d94120da43777393655d9",
"dependencies": [
"jsr:@deno-library/crc32",
"jsr:@std/fs@1.0.5",
"jsr:@std/io",
"jsr:@std/path@1.0.8",
"jsr:@std/tar",
"jsr:@zip-js/zip-js"
]
},
"@deno-library/crc32@1.0.2": {
"integrity": "d2061bfee30c87c97f285dfca0fdc4458e632dc072a33ecfc73ca5177a5a39a0"
},
"@denosaurs/plug@1.0.6": {
"integrity": "6cf5b9daba7799837b9ffbe89f3450510f588fafef8115ddab1ff0be9cb7c1a7",
"dependencies": [
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs@0.221",
"jsr:@std/path@0.221"
]
},
"@std/assert@0.217.0": {
"integrity": "c98e279362ca6982d5285c3b89517b757c1e3477ee9f14eb2fdf80a45aaa9642"
},
"@std/assert@0.221.0": {
"integrity": "a5f1aa6e7909dbea271754fd4ab3f4e687aeff4873b4cef9a320af813adb489a"
},
"@std/assert@1.0.11": {
"integrity": "2461ef3c368fe88bc60e186e7744a93112f16fd110022e113a0849e94d1c83c1",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/bytes@1.0.4": {
"integrity": "11a0debe522707c95c7b7ef89b478c13fb1583a7cfb9a85674cd2cc2e3a28abc"
},
"@std/encoding@0.221.0": {
"integrity": "d1dd76ef0dc5d14088411e6dc1dede53bf8308c95d1537df1214c97137208e45"
},
"@std/fmt@0.221.0": {
"integrity": "379fed69bdd9731110f26b9085aeb740606b20428ce6af31ef6bd45ef8efa62a"
},
"@std/fs@0.221.0": {
"integrity": "028044450299de8ed5a716ade4e6d524399f035513b85913794f4e81f07da286",
"dependencies": [
"jsr:@std/assert@0.221",
"jsr:@std/path@0.221"
]
},
"@std/fs@1.0.5": {
"integrity": "41806ad6823d0b5f275f9849a2640d87e4ef67c51ee1b8fb02426f55e02fd44e",
"dependencies": [
"jsr:@std/path@^1.0.7"
]
},
"@std/internal@1.0.5": {
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
},
"@std/io@0.225.0": {
"integrity": "c1db7c5e5a231629b32d64b9a53139445b2ca640d828c26bf23e1c55f8c079b3",
"dependencies": [
"jsr:@std/bytes"
]
},
"@std/path@0.217.0": {
"integrity": "1217cc25534bca9a2f672d7fe7c6f356e4027df400c0e85c0ef3e4343bc67d11",
"dependencies": [
"jsr:@std/assert@0.217"
]
},
"@std/path@0.221.0": {
"integrity": "0a36f6b17314ef653a3a1649740cc8db51b25a133ecfe838f20b79a56ebe0095",
"dependencies": [
"jsr:@std/assert@0.221"
]
},
"@std/path@1.0.8": {
"integrity": "548fa456bb6a04d3c1a1e7477986b6cffbce95102d0bb447c67c4ee70e0364be"
},
"@std/streams@1.0.8": {
"integrity": "b41332d93d2cf6a82fe4ac2153b930adf1a859392931e2a19d9fabfb6f154fb3"
},
"@std/tar@0.1.3": {
"integrity": "531270fc707b37ab9b5f051aa4943e7b16b86905e0398a4ebe062983b0c93115",
"dependencies": [
"jsr:@std/streams"
]
},
"@zip-js/zip-js@2.7.53": {
"integrity": "acea5bd8e01feb3fe4c242cfbde7d33dd5e006549a4eb1d15283bc0c778ed672"
}
},
"npm": {
"h3-js@4.1.0": {
"integrity": "sha512-LQhmMl1dRQQjMXPzJc7MpZ/CqPOWWuAvVEoVJM9n/s7vHypj+c3Pd5rLQCkAsOgAoAYKbNCsYFE++LF7MvSfCQ=="
},
"hono@4.6.19": {
"integrity": "sha512-Xw5DwU2cewEsQ1DkDCdy6aBJkEBARl5loovoL1gL3/gw81RdaPbXrNJYp3LoQpzpJ7ECC/1OFi/vn3UZTLHFEw=="
}
},
"workspace": {
"dependencies": [
"jsr:@db/sqlite@0.12",
"jsr:@deno-library/compress@~0.5.5",
"jsr:@std/assert@1",
"npm:h3-js@^4.1.0",
"npm:hono@^4.6.19"
]
}
}

125
public/index.html Normal file
View file

@ -0,0 +1,125 @@
<!DOCTYPE html>
<html lang="pl">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NepMap</title>
<style>
#map {
height: 80vh;
width: 100%;
}
.filter-menu {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
background: white;
padding: 15px;
border-radius: 5px;
box-shadow: 0 0 15px rgba(0, 0, 0, 0.2);
}
.filter-group {
margin-bottom: 10px;
}
label {
display: block;
margin: 5px 0;
}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script src="https://unpkg.com/h3-js@4.1.0/dist/h3-js.umd.js"></script>
</head>
<body>
<div class="filter-menu">
<div class="filter-group">
<h3>Czas aktualizacji</h3>
<select id="timeFilter">
<option value="any">Dowolny czas</option>
<option value="3600">Ostatnia godzina</option>
<option value="86400">Ostatnie 24 godziny</option>
<option value="604800">Ostatnie 7 dni</option>
</select>
</div>
<div class="filter-group">
<h3>Filtry sygnałów</h3>
<label><input type="checkbox" class="signal-filter" value="wifi" /> WiFi</label>
<label><input type="checkbox" class="signal-filter" value="ble" /> BLE</label>
<label><input type="checkbox" class="signal-filter" value="gsm" /> GSM</label>
<label><input type="checkbox" class="signal-filter" value="wcdma" /> WCDMA</label>
<label><input type="checkbox" class="signal-filter" value="lte" /> LTE</label>
<p style="color: #666; margin-top: 5px">(Zaznacz jakie sygnały mają być obecne)</p>
</div>
</div>
<div id="map"></div>
<script>
let currentMarkers = [];
const map = L.map("map").setView([50.3, 18.7], 13);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
}).addTo(map);
generateHexes();
async function generateHexes() {
const req = await fetch("/api/v1/hexes");
const hexes = await req.json();
const timeFilter = document.getElementById("timeFilter").value;
const selectedSignals = Array.from(document.querySelectorAll(".signal-filter:checked")).map((x) => x.value);
currentMarkers.forEach((marker) => map.removeLayer(marker));
currentMarkers = [];
hexes.forEach((hex) => {
const isTimeValid = timeFilter === "any" || Date.now() / 1000 - hex.last_update < timeFilter;
const hasAllSignals = selectedSignals.every((signal) => hex[signal] === 1);
const isSignalValid = selectedSignals.length === 0 || hasAllSignals;
if (isTimeValid && isSignalValid) {
const latLngs = h3.cellToBoundary(hex.hex_id).map((coord) => [coord[0], coord[1]]);
const marker = L.polygon(latLngs, {
color: "#3388ff",
fillColor: getColorForSignals(hex),
fillOpacity: 0.2,
})
.bindPopup(
`
<div style="line-height: 1.5;">
<strong>H3 ID:</strong> ${hex.hex_id}<br>
<strong>Aktualizacja:</strong> ${new Date(hex.last_update * 1000).toLocaleString("PL")}<br>
<strong>Technologie:</strong><br>
<strong>WiFi:</strong> ${hex.wifi ? "Tak" : "Nie"}<br>
<strong>BLE:</strong> ${hex.ble ? "Tak" : "Nie"}<br>
<strong>GSM:</strong> ${hex.gsm ? "Tak" : "Nie"}<br>
<strong>WCDMA:</strong> ${hex.wcdma ? "Tak" : "Nie"}<br>
<strong>LTE:</strong> ${hex.lte ? "Tak" : "Nie"}<br>
</div>
`,
)
.addTo(map);
currentMarkers.push(marker);
}
});
}
function formatSignalInfo(hex) {
return Object.entries(hex)
.filter(([key]) => ["wifi", "gsm", "lte"].includes(key))
.map(([key, value]) => `<b>${key.toUpperCase()}:</b> ${value ? "✓" : "✗"}<br>`)
.join("");
}
function getColorForSignals(hex) {
const activeSignals = [hex.wifi && "#00ff00", hex.gsm && "#0000ff", hex.lte && "#ff0000"].filter(Boolean);
return activeSignals.length > 0 ? activeSignals[0] : "#3388ff";
}
document.querySelectorAll("select, input").forEach((element) => {
element.addEventListener("change", generateHexes);
});
</script>
</body>
</html>

4
src/db.ts Normal file
View file

@ -0,0 +1,4 @@
import { Database } from "jsr:@db/sqlite@0.12.0";
export const db = await new Database("./neomap.sqlite");
db.prepare("CREATE TABLE IF NOT EXISTS hexes (hex_id TEXT PRIMARY KEY NOT NULL CHECK(hex_id GLOB '[0-9a-f]*'), wifi INTEGER DEFAULT 0 NOT NULL, gsm INTEGER DEFAULT 0 NOT NULL, wcdma INTEGER DEFAULT 0 NOT NULL, lte INTEGER DEFAULT 0 NOT NULL, ble INTEGER DEFAULT 0 NOT NULL, created_at INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL, last_update INTEGER DEFAULT (strftime('%s', 'now')) NOT NULL);").run();

94
src/server.ts Normal file
View file

@ -0,0 +1,94 @@
import h3 from "npm:h3-js";
import { gunzip, gzip } from "@deno-library/compress";
import { Hono } from "hono";
import { serveStatic } from "hono/deno";
import { logger } from "hono/logger";
import { db } from "./db.ts";
import { Geosubmit } from "./types.d.ts";
const app = new Hono();
app.use(logger());
app.use("/", serveStatic({ root: "./public" }));
app.post("/api/v1/geosubmit", async (c) => {
const enconding = await c.req.header("Content-Encoding");
if (enconding !== "gzip") {
return c.json({ status: 400, message: "Bad Request" });
}
const body = await c.req.arrayBuffer();
const ftch = await fetch("https://api.beacondb.net/v2/geosubmit", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Encoding": "gzip",
},
body: body,
});
if (ftch.status !== 200) {
return c.json({ status: ftch.status, message: "Bad Request" });
}
let json: Geosubmit;
const arr = new Uint8Array(body);
const data = await gunzip(arr);
json = JSON.parse(new TextDecoder().decode(data));
json.items.forEach((item) => {
const timestamp = Math.floor(item.timestamp / 1000);
const hex = h3.latLngToCell(item.position.latitude, item.position.longitude, 11);
let hasGsm = false,
hasWcdma = false,
hasLte = false,
hasWifi = false,
hasBle = false;
if (item.cellTowers) {
item.cellTowers.forEach((cell) => {
if (hasGsm && hasWcdma && hasLte) return;
if (cell.radioType === "gsm") hasGsm = true;
if (cell.radioType === "wcdma") hasWcdma = true;
if (cell.radioType === "lte") hasLte = true;
});
}
if (item.wifiAccessPoints) {
item.wifiAccessPoints.forEach((wifi) => {
if (hasWifi) return;
hasWifi = true;
});
}
if (item.bluetoothBeacons) {
item.bluetoothBeacons.forEach((ble) => {
if (hasBle) return;
hasBle = true;
});
}
const isHexInDb = db.prepare("SELECT hex_id FROM hexes WHERE hex_id = ?").get(hex);
if (isHexInDb) {
db.prepare(
`
UPDATE hexes
SET
wifi = MAX(wifi, ?),
gsm = MAX(gsm, ?),
wcdma = MAX(wcdma, ?),
lte = MAX(lte, ?),
ble = MAX(ble, ?),
last_update = ?
WHERE hex_id = ?
`,
).run(hasWifi, hasGsm, hasWcdma, hasLte, hasBle, timestamp, hex);
} else {
db.prepare("INSERT INTO hexes (hex_id, wifi, gsm, wcdma, lte, ble, last_update) VALUES (?, ?, ?, ?, ?, ?, ?)").run(hex, hasWifi, hasGsm, hasWcdma, hasLte, hasBle, timestamp);
}
});
return c.json({ status: 200, message: "OK" });
});
app.get("/api/v1/hexes", async (c) => {
const hexes = db.prepare("SELECT hex_id, wifi, gsm, wcdma, lte, ble, last_update FROM hexes").all();
return c.json(hexes);
});
Deno.serve(app.fetch);

39
src/types.d.ts vendored Normal file
View file

@ -0,0 +1,39 @@
export type Geosubmit = {
items: {
timestamp: number;
position: {
latitude: number;
longitude: number;
accuracy: number;
age: number;
altitude: number;
altitudeAccuracy: number;
heading: number;
speed: number;
source: string;
};
cellTowers?: {
radioType: "gsm" | "wcdma" | "lte";
mobileCountryCode: number;
mobileNetworkCode: number;
age: number;
asu: number;
primaryScramblingCode: number;
serving: number;
signalStrength: number;
arfcn: number;
}[];
wifiAccessPoints?: {
macAddress: string;
signalStrength: number;
channel: number;
ssid: string;
}[];
bluetoothBeacons?: {
macAddress: string;
signalStrength: number;
age: number;
name: string;
}[];
}[];
};