Add interactive school map to homepage
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
Some checks failed
Build and Deploy / deploy (push) Has been cancelled
Replace the text list of schools with a Leaflet map showing anonymous orange pins for 110 customer schools across Australia. Includes build-time Airtable fetch script with Nominatim geocoding and gradient glow effect on clustered pins. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
164
scripts/fetch-schools.ts
Normal file
164
scripts/fetch-schools.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Fetches active AU school organisations from Airtable,
|
||||
* geocodes their addresses via Nominatim, and writes
|
||||
* an anonymous lat/lng JSON file for the homepage map.
|
||||
*
|
||||
* Usage:
|
||||
* AIRTABLE_API_KEY=pat... AIRTABLE_BASE_ID=app... npx tsx scripts/fetch-schools.ts
|
||||
*/
|
||||
|
||||
import { writeFileSync, mkdirSync } from "fs";
|
||||
import { join } from "path";
|
||||
|
||||
const AIRTABLE_API_KEY = process.env.AIRTABLE_API_KEY;
|
||||
const AIRTABLE_BASE_ID = process.env.AIRTABLE_BASE_ID;
|
||||
const TABLE_NAME = "Organisation";
|
||||
|
||||
if (!AIRTABLE_API_KEY || !AIRTABLE_BASE_ID) {
|
||||
console.error("Missing AIRTABLE_API_KEY or AIRTABLE_BASE_ID env vars");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
interface AirtableRecord {
|
||||
id: string;
|
||||
fields: {
|
||||
"Google Formatted Address"?: string;
|
||||
State?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AirtableResponse {
|
||||
records: AirtableRecord[];
|
||||
offset?: string;
|
||||
}
|
||||
|
||||
interface SchoolPin {
|
||||
lat: number;
|
||||
lng: number;
|
||||
}
|
||||
|
||||
async function fetchAllRecords(): Promise<AirtableRecord[]> {
|
||||
const allRecords: AirtableRecord[] = [];
|
||||
let offset: string | undefined;
|
||||
|
||||
const filterFormula = "{Organisation Type}='Customer'";
|
||||
const fields = ["Google Formatted Address", "State"];
|
||||
|
||||
do {
|
||||
const params = new URLSearchParams({
|
||||
filterByFormula: filterFormula,
|
||||
});
|
||||
fields.forEach((f) => params.append("fields[]", f));
|
||||
if (offset) params.set("offset", offset);
|
||||
|
||||
const url = `https://api.airtable.com/v0/${AIRTABLE_BASE_ID}/${encodeURIComponent(TABLE_NAME)}?${params}`;
|
||||
|
||||
const res = await fetch(url, {
|
||||
headers: { Authorization: `Bearer ${AIRTABLE_API_KEY}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`Airtable API error ${res.status}: ${body}`);
|
||||
}
|
||||
|
||||
const data: AirtableResponse = await res.json();
|
||||
allRecords.push(...data.records);
|
||||
offset = data.offset;
|
||||
} while (offset);
|
||||
|
||||
return allRecords;
|
||||
}
|
||||
|
||||
function simplifyAddress(address: string): string[] {
|
||||
const variants: string[] = [];
|
||||
|
||||
// Strip unit/lot prefixes like "4506/793" → "793"
|
||||
let simplified = address.replace(/\d+\/(\d+)/, "$1");
|
||||
// Replace "&" intersections with just the first street
|
||||
simplified = simplified.replace(/\s*&\s*[^,]+/, "");
|
||||
if (simplified !== address) variants.push(simplified);
|
||||
|
||||
// Try just suburb + state + postcode (everything after the first comma)
|
||||
const afterFirstComma = address.replace(/^[^,]+,\s*/, "");
|
||||
if (afterFirstComma !== address) variants.push(afterFirstComma);
|
||||
|
||||
return variants;
|
||||
}
|
||||
|
||||
async function geocodeSingle(query: string): Promise<{ lat: number; lng: number } | null> {
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
format: "json",
|
||||
limit: "1",
|
||||
countrycodes: "au",
|
||||
});
|
||||
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { "User-Agent": "micromelon-website-build/1.0" },
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
|
||||
const data = await res.json();
|
||||
if (!data.length) return null;
|
||||
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
|
||||
async function geocode(address: string): Promise<{ lat: number; lng: number } | null> {
|
||||
// Try the full address first
|
||||
const result = await geocodeSingle(address);
|
||||
if (result) return result;
|
||||
|
||||
// Try simplified variants
|
||||
for (const variant of simplifyAddress(address)) {
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const fallback = await geocodeSingle(variant);
|
||||
if (fallback) return fallback;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log("Fetching records from Airtable...");
|
||||
const records = await fetchAllRecords();
|
||||
console.log(`Found ${records.length} active AU school records`);
|
||||
|
||||
const pins: SchoolPin[] = [];
|
||||
let failed = 0;
|
||||
|
||||
for (const record of records) {
|
||||
const address = record.fields["Google Formatted Address"];
|
||||
if (!address) {
|
||||
failed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Rate-limit: Nominatim asks for max 1 req/sec
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
|
||||
const coords = await geocode(address);
|
||||
if (coords) {
|
||||
pins.push(coords);
|
||||
process.stdout.write(` Geocoded ${pins.length}/${records.length}\r`);
|
||||
} else {
|
||||
console.warn(` Failed to geocode: ${address}`);
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nGeocoded ${pins.length} schools (${failed} failed)`);
|
||||
|
||||
const outDir = join(__dirname, "..", "src", "data");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const outPath = join(outDir, "schools.json");
|
||||
writeFileSync(outPath, JSON.stringify(pins, null, 2) + "\n");
|
||||
console.log(`Wrote ${outPath}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
63
scripts/seed-schools-json.mjs
Normal file
63
scripts/seed-schools-json.mjs
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* One-time script: geocodes the existing KNOWN_SCHOOLS list to create
|
||||
* an initial src/data/schools.json. Run once, then use fetch-schools.ts
|
||||
* with Airtable for ongoing updates.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, mkdirSync } from "fs";
|
||||
import { join, dirname } from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
// Parse the school names from schools.ts
|
||||
const schoolsTs = readFileSync(join(__dirname, "..", "src", "data", "schools.ts"), "utf-8");
|
||||
const match = schoolsTs.match(/\[([^\]]+)\]/s);
|
||||
const schools = match
|
||||
? match[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/^"|"$/g, "").replace(/^'|'$/g, ""))
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
console.log(`Found ${schools.length} schools to geocode`);
|
||||
|
||||
async function geocode(query) {
|
||||
const params = new URLSearchParams({
|
||||
q: query + ", Australia",
|
||||
format: "json",
|
||||
limit: "1",
|
||||
countrycodes: "au",
|
||||
});
|
||||
|
||||
const res = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, {
|
||||
headers: { "User-Agent": "micromelon-website-seed/1.0" },
|
||||
});
|
||||
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data.length) return null;
|
||||
return { lat: parseFloat(data[0].lat), lng: parseFloat(data[0].lon) };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const pins = [];
|
||||
for (let i = 0; i < schools.length; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
const coords = await geocode(schools[i]);
|
||||
if (coords) {
|
||||
pins.push(coords);
|
||||
process.stdout.write(` ${i + 1}/${schools.length} — ${pins.length} geocoded\r`);
|
||||
} else {
|
||||
console.log(` MISS: ${schools[i]}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nDone: ${pins.length} geocoded out of ${schools.length}`);
|
||||
const outDir = join(__dirname, "..", "src", "data");
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(join(outDir, "schools.json"), JSON.stringify(pins, null, 2) + "\n");
|
||||
console.log("Wrote src/data/schools.json");
|
||||
}
|
||||
|
||||
main().catch(console.error);
|
||||
Reference in New Issue
Block a user