209 lines
6.0 KiB
TypeScript
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;
|
|
}
|