194 lines
6.2 KiB
JavaScript
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);
|
|
}
|