as-app/src/db/defibsRepo.js
2026-03-05 18:02:47 +01:00

194 lines
6.2 KiB
JavaScript

// Defibrillator repository — nearby queries with H3 geo-indexing.
import { latLngToCell, gridDisk } from "h3-js";
import getDb from "./openDb";
import haversine from "~/utils/geo/haversine";
// H3 average edge lengths in meters per resolution (0..15).
const H3_EDGE_M = [
1107712, 418676, 158244, 59810, 22606, 8544, 3229, 1220, 461, 174, 65, 24,
9, 3, 1, 0.5,
];
const H3_RES = 8;
// SQLite max variable number is 999 by default; chunk IN() queries accordingly.
const SQL_VAR_LIMIT = 900;
// Compute k (ring size) needed to cover a given radius at a given H3 resolution.
function kForRadius(radiusMeters, res = H3_RES) {
const edge = H3_EDGE_M[res];
// sqrt(3) * edge ≈ diameter between parallel edges of a hexagon
return Math.max(1, Math.ceil(radiusMeters / (edge * Math.sqrt(3))));
}
// Build a bounding-box fallback SQL clause + params.
function bboxClause(lat, lon, radiusMeters) {
// 1 degree latitude ≈ 111_320 m
const dLat = radiusMeters / 111_320;
// 1 degree longitude shrinks with cos(lat)
const dLon = radiusMeters / (111_320 * Math.cos((lat * Math.PI) / 180));
return {
clause:
"latitude BETWEEN ? AND ? AND longitude BETWEEN ? AND ?",
params: [lat - dLat, lat + dLat, lon - dLon, lon + dLon],
};
}
/**
* @typedef {Object} Defib
* @property {string} id
* @property {number} latitude
* @property {number} longitude
* @property {string} nom
* @property {string} adresse
* @property {string} horaires
* @property {Object} horaires_std
* @property {number[]|null} horaires_std.days - ISO 8601 day numbers (1=Mon…7=Sun)
* @property {{open:string,close:string}[]|null} horaires_std.slots - Time ranges
* @property {boolean} horaires_std.is24h
* @property {boolean} horaires_std.businessHours
* @property {boolean} horaires_std.nightHours
* @property {boolean} horaires_std.events
* @property {string} horaires_std.notes
* @property {string} acces
* @property {number} disponible_24h
*/
/**
* Fetch defibrillators near a given point.
*
* @param {Object} params
* @param {number} params.lat - User latitude
* @param {number} params.lon - User longitude
* @param {number} params.radiusMeters - Search radius in meters
* @param {number} params.limit - Max results returned
* @param {boolean} [params.disponible24hOnly] - Filter 24/7 accessible only
* @param {boolean} [params.progressive] - Enable progressive expansion (k=1,2,3…)
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
*/
export async function getNearbyDefibs({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly = false,
progressive = false,
}) {
const db = await getDb();
const maxK = kForRadius(radiusMeters);
if (progressive) {
return progressiveSearch(db, lat, lon, radiusMeters, limit, disponible24hOnly, maxK);
}
// One-shot: compute full disk and query
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), maxK);
const candidates = await queryCells(db, cells, disponible24hOnly);
return rankAndFilter(candidates, lat, lon, radiusMeters, limit);
}
// Progressive expansion: start at k=1, expand until enough results or maxK.
async function progressiveSearch(db, lat, lon, radiusMeters, limit, dispo24h, maxK) {
let allCandidates = [];
const seenIds = new Set();
for (let k = 1; k <= maxK; k++) {
const cells = gridDisk(latLngToCell(lat, lon, H3_RES), k);
const rows = await queryCells(db, cells, dispo24h);
for (const row of rows) {
if (!seenIds.has(row.id)) {
seenIds.add(row.id);
allCandidates.push(row);
}
}
// Early exit: if we already have more candidates than limit, rank and check
if (allCandidates.length >= limit) {
const ranked = rankAndFilter(allCandidates, lat, lon, radiusMeters, limit);
if (ranked.length >= limit) return ranked;
}
}
return rankAndFilter(allCandidates, lat, lon, radiusMeters, limit);
}
// Query the DB for rows matching a set of H3 cells, chunking if needed.
async function queryCells(db, cells, dispo24h) {
if (cells.length === 0) return [];
const results = [];
// Chunk cells to stay under SQLite variable limit
for (let i = 0; i < cells.length; i += SQL_VAR_LIMIT) {
const chunk = cells.slice(i, i + SQL_VAR_LIMIT);
const placeholders = chunk.map(() => "?").join(",");
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
FROM defibs WHERE h3 IN (${placeholders})`;
const params = [...chunk];
if (dispo24h) {
sql += " AND disponible_24h = 1";
}
const rows = await db.getAllAsync(sql, params);
results.push(...rows);
}
return results;
}
// Parse horaires_std JSON string into object.
function parseHorairesStd(row) {
try {
return { ...row, horaires_std: JSON.parse(row.horaires_std) };
} catch {
return { ...row, horaires_std: null };
}
}
// Compute distance, filter by radius, sort, and limit.
function rankAndFilter(candidates, lat, lon, radiusMeters, limit) {
const withDist = [];
for (const row of candidates) {
const distanceMeters = haversine(lat, lon, row.latitude, row.longitude);
if (distanceMeters <= radiusMeters) {
withDist.push({ ...parseHorairesStd(row), distanceMeters });
}
}
withDist.sort((a, b) => a.distanceMeters - b.distanceMeters);
return withDist.slice(0, limit);
}
/**
* Bbox fallback — use when H3 is unavailable.
*
* @param {Object} params
* @param {number} params.lat
* @param {number} params.lon
* @param {number} params.radiusMeters
* @param {number} params.limit
* @param {boolean} [params.disponible24hOnly]
* @returns {Promise<(Defib & { distanceMeters: number })[]>}
*/
export async function getNearbyDefibsBbox({
lat,
lon,
radiusMeters,
limit,
disponible24hOnly = false,
}) {
const db = await getDb();
const { clause, params } = bboxClause(lat, lon, radiusMeters);
let sql = `SELECT id, latitude, longitude, nom, adresse, horaires, horaires_std, acces, disponible_24h
FROM defibs WHERE ${clause}`;
if (disponible24hOnly) {
sql += " AND disponible_24h = 1";
}
const rows = await db.getAllAsync(sql, params);
return rankAndFilter(rows, lat, lon, radiusMeters, limit);
}