|
| 1 | +import type { EvChargingConnector } from "@openmapx/mobility-core/ev-charging"; |
| 2 | +import type { PoiRow, PoiStaticParseFn } from "@openmapx/poi-source-registry"; |
| 3 | +import { unzipSync } from "fflate"; |
| 4 | +import proj4 from "proj4"; |
| 5 | +import { parseDelimited, rowsToObjects } from "./csv.js"; |
| 6 | +import { cleanString, connector, newestIsoString, parseLocalizedNumber } from "./utils.js"; |
| 7 | + |
| 8 | +export const BE_WALLONIA_URL = |
| 9 | + "https://geoservices.wallonie.be/geotraitement/spwdatadownload/results/c731a282-e378-49f3-8ab6-f9a90e7a5683/BORNES_RECHARGE_CSV.zip"; |
| 10 | +const SOURCE_URL = |
| 11 | + "https://geoportail.wallonie.be/catalogue/c731a282-e378-49f3-8ab6-f9a90e7a5683.html"; |
| 12 | + |
| 13 | +// The bulk download is a ZIP whose CSV carries geometry as WKT in EPSG:31370 |
| 14 | +// (Belgian Lambert 72) — the ArcGIS query endpoint serves no geometry, so the |
| 15 | +// zip is the only usable source. Reproject each point to WGS84. Verified |
| 16 | +// against the SPW control point 184781.2, 128873.1 → 4.858691, 50.469649. |
| 17 | +const LAMBERT72 = |
| 18 | + "+proj=lcc +lat_1=51.16666723333333 +lat_2=49.8333339 +lat_0=90 +lon_0=4.367486666666666 +x_0=150000.013 +y_0=5400088.438 +ellps=intl +towgs84=-106.8686,52.2978,-103.7239,0.3366,-0.457,1.8422,-1.2747 +units=m +no_defs"; |
| 19 | +const lambert72ToWgs84 = proj4(LAMBERT72, "WGS84"); |
| 20 | + |
| 21 | +function reprojectWkt(wkt: string | undefined): [number, number] | null { |
| 22 | + const match = wkt?.match(/POINT\s*\(\s*(-?\d+(?:\.\d+)?)\s+(-?\d+(?:\.\d+)?)\s*\)/i); |
| 23 | + if (!match) return null; |
| 24 | + const x = Number.parseFloat(match[1]); |
| 25 | + const y = Number.parseFloat(match[2]); |
| 26 | + if (!Number.isFinite(x) || !Number.isFinite(y)) return null; |
| 27 | + const [lng, lat] = lambert72ToWgs84.forward([x, y]); |
| 28 | + if (!Number.isFinite(lng) || !Number.isFinite(lat)) return null; |
| 29 | + return [lng, lat]; |
| 30 | +} |
| 31 | + |
| 32 | +// TYPE_RECHARGE is a power TIER (Normal/Rapide/Ultra-rapide), not a plug type, |
| 33 | +// and the feed carries no connector-standard field — so infer from power: |
| 34 | +// ≤22 kW is AC (Type 2), above is DC (CCS). Low-confidence but the only signal. |
| 35 | +function rowConnector(power: number | undefined): EvChargingConnector { |
| 36 | + const isDc = power !== undefined && power > 22; |
| 37 | + return connector({ |
| 38 | + type: isDc ? "CCS (Type 2)" : "Type 2", |
| 39 | + powerKw: power, |
| 40 | + currentType: isDc ? "DC" : "AC", |
| 41 | + quantity: 1, |
| 42 | + }); |
| 43 | +} |
| 44 | + |
| 45 | +interface WalloniaGroup { |
| 46 | + coordinates: [number, number]; |
| 47 | + operator?: string; |
| 48 | + address?: string; |
| 49 | + postcode?: string; |
| 50 | + town?: string; |
| 51 | + state?: string; |
| 52 | + connectors: EvChargingConnector[]; |
| 53 | + updates: (string | undefined)[]; |
| 54 | +} |
| 55 | + |
| 56 | +/** Parses the decompressed `;`-delimited CSV text into grouped station rows. */ |
| 57 | +export function parseWalloniaRows(text: string): PoiRow[] { |
| 58 | + const rows = rowsToObjects(parseDelimited(text.replace(/^/, ""), ";"), 0); |
| 59 | + const groups = new Map<string, WalloniaGroup>(); |
| 60 | + |
| 61 | + for (const row of rows) { |
| 62 | + const emplacementId = cleanString(row.EMPLACEMENT_ID); |
| 63 | + const coordinates = reprojectWkt(row.WKT_GEOM); |
| 64 | + if (!emplacementId || !coordinates) continue; |
| 65 | + |
| 66 | + const existing = groups.get(emplacementId); |
| 67 | + const power = parseLocalizedNumber(row.PUISSANCE_KW); |
| 68 | + if (existing) { |
| 69 | + existing.connectors.push(rowConnector(power)); |
| 70 | + existing.updates.push(cleanString(row.DATETRANS)); |
| 71 | + continue; |
| 72 | + } |
| 73 | + groups.set(emplacementId, { |
| 74 | + coordinates, |
| 75 | + operator: cleanString(row.OPERATEUR), |
| 76 | + address: cleanString(row.ADRESSE), |
| 77 | + postcode: cleanString(row.CODE_POSTAL), |
| 78 | + town: cleanString(row.VILLE), |
| 79 | + state: cleanString(row.PROVINCE), |
| 80 | + connectors: [rowConnector(power)], |
| 81 | + updates: [cleanString(row.DATETRANS)], |
| 82 | + }); |
| 83 | + } |
| 84 | + |
| 85 | + const out: PoiRow[] = []; |
| 86 | + for (const [poiId, group] of groups) { |
| 87 | + const name = [group.operator, group.address].filter(Boolean).join(" – "); |
| 88 | + out.push({ |
| 89 | + poiId, |
| 90 | + lng: group.coordinates[0], |
| 91 | + lat: group.coordinates[1], |
| 92 | + payload: { |
| 93 | + coordinates: group.coordinates, |
| 94 | + name: name || "EV Charging Station", |
| 95 | + address: { |
| 96 | + line1: group.address, |
| 97 | + town: group.town, |
| 98 | + state: group.state, |
| 99 | + postcode: group.postcode, |
| 100 | + country: "Belgium", |
| 101 | + }, |
| 102 | + operator: group.operator ? { name: group.operator } : undefined, |
| 103 | + status: "unknown", |
| 104 | + connectors: group.connectors, |
| 105 | + updatedAt: newestIsoString(group.updates), |
| 106 | + sourceUrl: SOURCE_URL, |
| 107 | + }, |
| 108 | + }); |
| 109 | + } |
| 110 | + return out; |
| 111 | +} |
| 112 | + |
| 113 | +export const parseBeWallonia: PoiStaticParseFn = (buffer) => { |
| 114 | + const files = unzipSync(new Uint8Array(buffer)); |
| 115 | + const csvName = Object.keys(files).find((name) => name.toLowerCase().endsWith(".csv")); |
| 116 | + if (!csvName) return []; |
| 117 | + return parseWalloniaRows(Buffer.from(files[csvName]).toString("utf8")); |
| 118 | +}; |
0 commit comments