rspace-online/modules/rpubs/printer-discovery.ts

209 lines
6.0 KiB
TypeScript

/**
* Multi-source printer discovery: curated ethical shops + OpenStreetMap.
* Ported from rPubs-online/src/lib/discover-printers.ts.
*/
import { readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const curatedShopsData = JSON.parse(readFileSync(join(__dirname, "curated-shops.json"), "utf-8"));
export type ProviderSource = "curated" | "discovered";
export interface DiscoveredProvider {
id: string;
name: string;
source: ProviderSource;
distance_km: number;
lat: number;
lng: number;
city: string;
address?: string;
website?: string;
phone?: string;
email?: string;
capabilities?: string[];
tags?: string[];
description?: string;
}
interface CuratedShop {
name: string;
lat: number;
lng: number;
city: string;
country: string;
address: string;
website: string;
email?: string;
phone?: string;
capabilities: string[];
tags: string[];
description: string;
formats?: string[];
}
const CURATED_SHOPS: CuratedShop[] = curatedShopsData as CuratedShop[];
function haversineKm(lat1: number, lng1: number, lat2: number, lng2: number): number {
const R = 6371;
const dLat = ((lat2 - lat1) * Math.PI) / 180;
const dLng = ((lng2 - lng1) * Math.PI) / 180;
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) *
Math.cos((lat2 * Math.PI) / 180) *
Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
function searchCurated(
lat: number,
lng: number,
radiusKm: number,
formatId?: string,
): DiscoveredProvider[] {
return CURATED_SHOPS.filter((shop) => {
const dist = haversineKm(lat, lng, shop.lat, shop.lng);
if (dist > radiusKm) return false;
if (formatId && shop.formats && !shop.formats.includes(formatId)) return false;
return true;
}).map((shop) => ({
id: `curated-${shop.name.toLowerCase().replace(/\s+/g, "-")}`,
name: shop.name,
source: "curated" as const,
distance_km: Math.round(haversineKm(lat, lng, shop.lat, shop.lng) * 10) / 10,
lat: shop.lat,
lng: shop.lng,
city: `${shop.city}, ${shop.country}`,
address: shop.address,
website: shop.website,
email: shop.email,
phone: shop.phone,
capabilities: shop.capabilities,
tags: shop.tags,
description: shop.description,
}));
}
const OVERPASS_API = "https://overpass-api.de/api/interpreter";
async function searchOSM(
lat: number,
lng: number,
radiusMeters: number,
): Promise<DiscoveredProvider[]> {
const query = `
[out:json][timeout:10];
(
nwr["shop"="copyshop"](around:${radiusMeters},${lat},${lng});
nwr["shop"="printing"](around:${radiusMeters},${lat},${lng});
nwr["craft"="printer"](around:${radiusMeters},${lat},${lng});
nwr["office"="printing"](around:${radiusMeters},${lat},${lng});
nwr["amenity"="copyshop"](around:${radiusMeters},${lat},${lng});
nwr["shop"="stationery"]["printing"="yes"](around:${radiusMeters},${lat},${lng});
);
out center tags;
`;
const res = await fetch(OVERPASS_API, {
method: "POST",
body: `data=${encodeURIComponent(query)}`,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": "rPubs/1.0 (rspace.online)",
},
signal: AbortSignal.timeout(12000),
});
if (!res.ok) return [];
const data = await res.json();
const elements: Array<{
id: number;
type: string;
lat?: number;
lon?: number;
center?: { lat: number; lon: number };
tags?: Record<string, string>;
}> = data.elements || [];
const seen = new Set<string>();
return elements
.filter((el) => {
const name = el.tags?.name;
if (!name) return false;
if (seen.has(name.toLowerCase())) return false;
seen.add(name.toLowerCase());
return true;
})
.map((el) => {
const elLat = el.lat ?? el.center?.lat ?? lat;
const elLng = el.lon ?? el.center?.lon ?? lng;
const tags = el.tags || {};
const city = tags["addr:city"] || tags["addr:suburb"] || tags["addr:town"] || "";
const street = tags["addr:street"] || "";
const housenumber = tags["addr:housenumber"] || "";
const address = [housenumber, street, city].filter(Boolean).join(" ").trim();
const capabilities: string[] = [];
if (tags["service:copy"] === "yes" || tags.shop === "copyshop") capabilities.push("laser-print");
if (tags["service:binding"] === "yes") capabilities.push("saddle-stitch", "perfect-bind");
if (tags["service:print"] === "yes" || tags.shop === "printing") capabilities.push("laser-print");
return {
id: `osm-${el.type}-${el.id}`,
name: tags.name!,
source: "discovered" as const,
distance_km: Math.round(haversineKm(lat, lng, elLat, elLng) * 10) / 10,
lat: elLat,
lng: elLng,
city: city || "Nearby",
address: address || undefined,
website: tags.website || tags["contact:website"] || undefined,
phone: tags.phone || tags["contact:phone"] || undefined,
email: tags.email || tags["contact:email"] || undefined,
capabilities: capabilities.length > 0 ? capabilities : undefined,
description: tags.description || undefined,
};
})
.sort((a, b) => a.distance_km - b.distance_km);
}
export interface DiscoverOptions {
lat: number;
lng: number;
radiusKm?: number;
formatId?: string;
}
export async function discoverPrinters(opts: DiscoverOptions): Promise<DiscoveredProvider[]> {
const { lat, lng, radiusKm = 100, formatId } = opts;
const radiusMeters = radiusKm * 1000;
const [curated, osm] = await Promise.all([
Promise.resolve(searchCurated(lat, lng, radiusKm, formatId)),
searchOSM(lat, lng, radiusMeters).catch((err) => {
console.error("[rpubs] OSM search failed:", err);
return [] as DiscoveredProvider[];
}),
]);
const curatedNames = new Set(curated.map((p) => p.name.toLowerCase()));
const filteredOsm = osm.filter((p) => !curatedNames.has(p.name.toLowerCase()));
let allCurated = curated;
if (curated.length === 0 && filteredOsm.length < 3) {
allCurated = searchCurated(lat, lng, 20000, formatId).slice(0, 5);
}
const combined = [...allCurated, ...filteredOsm];
combined.sort((a, b) => a.distance_km - b.distance_km);
return combined;
}