pgx
PGX — Lightweight Postgres for AI agents. Written in Rust. Thousands of isolated databases in one runtime. 🐘
翻译准备中
尚未检测到更新。GitHub 发布版本后会显示在这里。
个人 GitHub 账号下公开仓库的所有者可以登录并验证所有权,自行管理介绍,无需管理员批准。
登录并生成验证文件,在 30 分钟内将其提交到此仓库的默认分支。其他分支或分叉仓库中的文件无效。然后在下方检查文件,即可无需管理员批准直接管理项目。请保留文件以维持管理权限。
如需登录,将前往登录页面。登录后请回到此处,再次点击所选的验证按钮。
在项目文件夹中运行 /nomorevibe verify,即可验证所有权并自行管理介绍。
一分钟完成验证Live volcanic ash advisory map: real ICAO VAA polygons from Darwin VAAC, live wind, GSAP-animated forecast drift, GIS export. No API keys.
翻译准备中
取自仓库 README
正在加载完整 README。 以下为已保存的摘录。
Monitor volcanic ash in Indonesia. A Next.js + Leaflet application that plots
real Volcanic Ash Advisory (VAA) polygons and live wind vectors on a
map, with the forecast drift animated.
Vercel (what this is set up for): import the repo at
vercel.com/new. Next.js is detected automatically and
there is nothing required to configure — every data source the app depends on
is keyless.
One optional variable enables the NOTAM lookup. See
.env.example; locally:
On Vercel, add it under Settings → Environment Variables, or:查看完整 README ↗Monitor volcanic ash in Indonesia. A Next.js + Leaflet application that plots real Volcanic Ash Advisory (VAA) polygons and live wind vectors on a map, with the forecast drift animated.
Live: https://ash-map-blush.vercel.app
Vercel (what this is set up for): import the repo at vercel.com/new. Next.js is detected automatically and there is nothing required to configure — every data source the app depends on is keyless.
One optional variable enables the NOTAM lookup. See .env.example; locally:
cp .env.example .env.local # then paste the key into SKYLINK_API_KEY
On Vercel, add it under Settings → Environment Variables, or:
vercel env add SKYLINK_API_KEY
SkyLink sells the same data through two channels, and they are not interchangeable:
| Direct | RapidAPI | |
|---|---|---|
| Key | UUID licence key (own checkout, billed via Polar) | long dashless marketplace key |
| Base URL | https://data.skylinkapi.com/v3.1 | https://skylink-api.p.rapidapi.com |
| Header | x-api-key | x-rapidapi-key + host |
The route infers the channel from the key's shape, because sending one
channel's key to the other's host returns 403 {"message":"You are not subscribed to this API."} — which reads like a bug here rather than the
credential mismatch it is. SKYLINK_API_CHANNEL overrides the inference. On
RapidAPI the account must additionally be subscribed to the API; a valid key
alone is not enough.
Without a key, /api/notams/<icao> reports itself unconfigured and the airport
panel says so; everything else works unchanged. The key is read server-side
only and never reaches the browser. The free tier is 1,000 requests a month, so
responses are cached per ICAO for 10 minutes.
Two things to know about running the FTP route on serverless:
Outbound FTP. /api/darwin opens a plain socket to ftp.bom.gov.au
(passive mode). Vercel's Node runtime allows it, but verify it on the first
deploy rather than assuming: hit /api/darwin?area=indonesia and check the
fetch block in the response is non-zero. If a platform blocks non-HTTP
egress, that route is the only thing that breaks; the rest of the app works.
Cold starts drop the cache, unless Redis is configured. The bulletin cache is three layers: memory, then Redis when its env vars are present, then the OS temp dir. On a server the first and third are enough. On serverless every cold start begins with an empty process and an empty filesystem, so without Redis a cold request re-downloads the live slots — 8 small files and about 4 seconds, cheap but wasteful.
To make it durable, add a Redis store from the Vercel Marketplace
(Storage → Redis, which provisions Upstash) and redeploy. The integration
injects UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN, which is all
the cache looks for; the legacy KV_REST_API_* names are accepted too.
Vercel KV itself is deprecated — its own package says so, and existing
stores were migrated to Upstash Redis. Do not reach for @vercel/kv.
maxDuration = 30 is declared on both FTP routes because the serverless
default is 10s and a cold fetch is ~4s, which leaves no headroom if BOM is slow.
GitHub Pages will not work. It serves static files only, and all four API routes do server-side network work (FTP, an Open-Meteo proxy, a CORS-avoiding HTML fetch). A static export would leave a map that cannot load anything.
A container host (Fly.io, Railway, Render) is the better technical fit if this becomes more than a prototype: one long-lived process keeps the cache warm, there is no function timeout, and the filesystem persists.
npm install
npm run dev
Open http://localhost:3000. Needs normal internet access for the live NOAA and Open-Meteo fetches. No API keys are required anywhere in this app.
npm test # parser + morph + wind-grid checks (node:test, no framework)
Map — Leaflet via react-leaflet, on Esri's keyless light/dark gray
canvas. (CARTO's keyless tiles are now watermarked "API KEY REQUIRED"
server-side, baked into the PNG, so they are not usable unauthenticated.)
The basemap follows the light/dark theme.
Ash polygons, like the Darwin VAAC chart in your screenshot — every VAAC (Darwin, Washington, Tokyo, London, Toulouse, ...) issues advisories in the same standardized ICAO text format:
DTG: 20260906/0630Z
VAAC: DARWIN
VOLCANO: KRAKATAU 262000
PSN: S0606 E10525
OBS VA CLD: SFC/FL500 S0400 E08700 - S1200 E08100 - ... MOV SW 10KT
FCST VA CLD +6HR: 06/1210Z SFC/FL500 ...
lib/vaa.ts parses this into normalized JSON + GeoJSON polygons. It's one
parser for every VAAC, since the format is the same everywhere. Click
"Krakatau sample" in the sidebar to see the reference example rendered.
Animated forecast drift — the transport bar under the map plays the advisory forward: the OBS cloud tweens into +6HR into +12HR, so the drift is something you watch rather than something you infer from toggling frames. A GSAP timeline drives one scalar, and each tick pushes interpolated rings straight into the Leaflet layers (no React re-render per frame). Scrub or step with the slider.
Frames are hand-drawn independently, so they rarely share a vertex count
and vertex 0 of one frame is not the same corner as vertex 0 of the next —
lib/morph.ts resamples each pair to a common count by arc length and
rotationally aligns them first, otherwise the cloud turns inside out
mid-tween. Polygons are matched across frames by flight level, so the
FL500 cloud morphs into the next FL500 cloud.
Darwin VAAC feed — /api/darwin polls BOM's anonymous FTP. No auth, no
key. There is no HTTPS path for that tree, and the BOM aviation web page
renders its list client-side, so FTP is the machine-readable route.
Two trees, and only one is live. /anon/gen/fwo/ holds the current
products: one slot per volcano under advisory (IDY41280.txt,
IDY41285.txt, ...), each containing that volcano's latest bulletin under a
fixed name. The dated /anon/gen/vaac/<year>/ tree is an archive and lags
real time by days. Poll the archive and you get last week's eruption; poll
one slot and you see one volcano out of five. The route reads every slot
(?live=0 for the archive).
Slot-to-volcano assignment is not fixed, so the only correct approach is to read each slot and see what is in it. Slots are mutable — same name, new content — so their cache key carries the server modified time.
Responses are cached 5 minutes server-side, concurrent requests collapse onto one FTP session, and a failed fetch serves the last good data rather than an error. The directory holds ~8000 entries per year, mostly PNG charts, so the listing uses a server-side glob (~1s instead of ~10s).
The short DTG used by every cloud frame (06/1240Z) carries a day but no
month, so lib/dtg.ts resolves it against the advisory's own issue time and
picks the month that lands nearest. A +18HR forecast issued on the 31st
otherwise resolves into the wrong month, and Date.UTC would report the
rollover as a real date instead of an invalid one.
Playback and refresh rates — the transport plays at 1x to 12x (GSAP
timeScale, applied mid-playback). Auto-refresh is selectable: off, 15 min,
30 min or 1 hour, defaulting to 30, since Darwin re-advises a volcano at
most hourly. A tab that sat hidden past the interval refreshes when it comes
back, and there is a manual Refresh button.
Heights in metres, not just flight levels — every altitude reads
FL500 (50,000 ft / 15,200 m). Flight levels are hundreds of feet and mean
nothing outside aviation, and Indonesia is metric. A legend fixed to the
map's right edge carries the same conversion for the colour bands, since
the sidebar is closed on a phone and collapsed on a laptop. It starts
collapsed below 640px, where it would otherwise take 40% of the width.
English and Indonesian — every interface string is translated, with the
locale taken from the browser and remembered. A plain typed dictionary in
lib/i18n.ts, no i18n library: two locales, Indonesian has no plural
categories, dates already go through Intl, and next-intl would add a
dependency, middleware and a routing scheme to do less. Tests assert the
two catalogues hold the same keys, the same placeholders, and no string
left identical by copy-paste.
The generated sentences are localized too, which needed the data to stay
structured: assessAsh keeps a pre-rendered English summary for API
consumers, and a band's drift is { dir, knots } rather than "southwest at
10 kt", so lib/ash-text.ts can build the sentence in either language. A
test asserts the English build still matches the API's own string, so the
two cannot drift apart in wording.
The raw VAA bulletin and the NOTAM text are deliberately not translated: they are source documents an operator may need to quote verbatim.
First-run onboarding — one dialog, shown once, explaining the thing the map cannot: that three independent official sources are on screen, each answering a different question, and that they can disagree. It also explains what a flight level is, since the colour bands mean nothing without that. Carries the language switch, so a first-time Indonesian reader can change it before reading anything else.
Source health — four independent sources feed this map, and every
failure this project has had was silent: polling one product slot showed
one volcano when five were advised; the archive tree served ten-day-old
data; an EST suffix turned a real closure end time into "no end time";
EST VA CLD dropped the current cloud for four of five volcanoes; a
licence key sent to the wrong channel returned 403. Each rendered
confidently and nothing said otherwise.
FCST VA CLD +6 HR: with a space; Washington writes +6HR:.= terminator.S1111\nE10619 is one vertex).EST VA CLD (estimated) replaces OBS VA CLD when satellite cannot
confirm the cloud. Four of five live Indonesian volcanoes use it. Worse,
EST VA DTG contains the substring DTG, so a parser knowing only the
bare DTG label overwrites the issue time with the estimated cloud's text
and never plots the cloud. Estimated clouds are flagged, and reported as
"estimated" rather than "observed" — a real difference in confidence.The Met Office-style chart in your first reference image (colored bands for mg/m³ concentration: Low/Medium/High/Very High) comes from an actual atmospheric dispersion model (NAME/HYSPLIT) run internally by each VAAC. That model output isn't published as an open, real-time API for arbitrary volcanoes/regions, so this prototype doesn't fabricate concentration bands.
What it plots instead — the actual VAA polygons and flight levels VAACs
publish for aviation, colored by flight-level band (matching the visual
language of your second reference image, the Darwin 4-panel OBS/FCST chart)
— is the real, live, publicly available data. If you later get access to a
VAAC's or a research institution's dispersion-model grid output, the same
map/legend pattern (lib/style.ts, the GeoJSON layer in
components/AshMap.tsx) extends directly to concentration bands instead of
flight-level bands.
npm test — 104 checks over the VAA parser, the morph math, the wind grid,
the Darwin feed's file selection, and DTG parsing across month and year
boundaries, on node:test + node:assert with no test framework.npm run build and tsc --noEmit complete cleanly; eslint . is clean./api/advisory POST (raw text) returns correct GeoJSON; GET rejects
non-VAAC hosts via the allowlist./api/wind returns live vectors, including for views crossing the
antimeridian and for flipped/overscrolled bounds./api/darwin was exercised against the live BOM server: real KRAKATAU
(Indonesia) and LANGILA (PNG) advisories, grouped to one layer per volcano,
with the file cache serving 23 of 23 files on a repeat call.Target is WCAG 2.2 AA (see PRODUCT.md). Measured, not assumed:
lab() here because the tokens are OKLCH, so a naive channel parse
reports nonsense; the check normalizes through a canvas first.role=button named "↑", which buries every real control behind ~65 tab
stops and fails target size at 18px.useId).prefers-reduced-motion steps the timeline between frames instead of
tweening, so the feature survives the preference.Built by Victor Chandra (@mzmznasipadang). Source code MIT licensed; see LICENSE.
The data is not mine and carries its publishers' terms:
| Data | Holder |
|---|---|
| Darwin VAAC advisories | © Commonwealth of Australia, Bureau of Meteorology |
| Washington VAAC advisories | NOAA / NWS Satellite Analysis Branch |
| Volcano alert levels | PVMBG, Badan Geologi, Kementerian ESDM |
| Wind forecast | © Open-Meteo, CC BY 4.0 |
| Basemap tiles | © Esri and its data contributors |
Built with Next.js, Leaflet, GSAP, shadcn/ui and Tailwind.
Not an official aviation product. For flight planning, use the advisories and NOTAMs issued by the responsible VAAC and your national AIS.
app/
page.tsx app shell + state (map, sidebar, slide-over)
layout.tsx theme provider
en/page.tsx the app in English
id/page.tsx the app in Indonesian
page.tsx redirects "/" by Accept-Language
robots.ts crawler rules; the API routes are disallowed
sitemap.ts one route, hourly
manifest.ts installable web app
icon.svg favicon (the mark; Next serves it as rel=icon)
apple-icon.tsx the same mark rendered to PNG for iOS
opengraph-image.png 1200x630 link-preview card
api/advisory/route.ts fetch-or-parse a VAA text advisory -> GeoJSON
api/darwin/route.ts poll BOM's FTP for the newest Darwin bulletins
api/darwin/geojson/ one FeatureCollection for GIS, area-filterable
api/notams/[icao]/ published NOTAMs for an aerodrome (needs a key)
api/pvmbg/route.ts Indonesian volcano alert levels
api/wind/route.ts wind vector grid from Open-Meteo
components/
AshMap.tsx Leaflet map, GSAP timeline, transport bar
advisory-panel.tsx sidebar sections, advisory card, legend
darwin-feed.tsx the FTP feed poller + its list view
raw-bulletin.tsx the bulletin as issued, with copy
credits.tsx author and data-source attribution
time-mode.tsx Zulu/local preference, explainer, <Dtg>
map-legend.tsx the colour bands, fixed to the map
sources-health.tsx source health panel + header indicator
ash-map-app.tsx the app shell the locale routes render
i18n.tsx locale context, t(), language switch
onboarding.tsx first-run explanation of the three sources
airport-impact.tsx affected-airport list + NOTAM lookup
wind-layer.tsx the wind field as one SVG layer
theme-provider.tsx next-themes wiring
theme-toggle.tsx light/dark button
ui/ shadcn/ui components
lib/
coords.ts "N1428 W09052" -> [lat, lon]
vaa.ts VAA text parser + GeoJSON builder
airports.ts airports in/around Indonesia (from OurAirports)
darwin.ts BOM FTP client + product-file selection
impact.ts which airports sit under a cloud, and how high
notams.ts SkyLink channel/field normalization
ash-text.ts the ash sentence, built per locale
health.ts per-source trust assessment
locale-route.ts per-locale title, canonical and hreflang
site.ts canonical facts + JSON-LD
i18n.ts EN/ID message catalogue
notify.ts what counts as news, for notifications
pvmbg.ts alert-level scrape + VAAC name matching
logo.ts the app mark, shared by the generated icons
area.ts Indonesia bbox + area matching
bulletin-cache.ts immutable-file cache (memory + temp dir)
eruption.ts per-band ash status, height and drift
dtg.ts ICAO date-time groups, Zulu/local formatting
morph.ts ring resampling / alignment / interpolation
grid.ts wind sampling grid, longitude normalization
style.ts flight-level / wind-speed color scales
types.ts shared types
*.test.ts node:test checks
Polling cost. A published bulletin is immutable, so it is downloaded once and cached by filename (memory, plus a best-effort copy in the OS temp dir that survives restarts). A poll then costs one directory listing plus only the files that are genuinely new. Measured on the same call twice:
call 1 (cold): {scanned: 20, downloaded: 20, fromCache: 0}
call 2 (warm): {scanned: 20, downloaded: 0, fromCache: 20}
Every response carries that fetch block, so the cost is visible rather
than assumed. Other levers already in place: a 5-minute server cache,
concurrent requests collapsed onto one FTP session, and client polling that
pauses while the tab is hidden.
Most bulletins are stand-downs with nothing to plot, so the scan reads up to
40 files back looking for ones that carry ash, then stops. Advisories are
deduplicated to the newest per volcano (?group=0 for the full history).
Deployment note: this opens an outbound FTP connection. Fine on a server or container; many serverless platforms block non-HTTP egress. Verified working on Vercel.
Every volcano at once — the feed plots the newest advisory for every volcano currently under advisory, not one. The map fits the view to all of them, the selected one animates, the rest render dashed and low-contrast as context. Click any volcano icon (or its polygon) to move the timeline to it, or jump straight to its GIS plot.
Controls in Layers on the map: show/hide each volcano, and a flight-level floor ("FL300 and above") for when only ash at cruising altitude matters.
Raw bulletin view — the exact text as issued, with a copy button. The parsed view is a convenience; the bulletin is the source of truth.
Wind projection — /api/wind queries
Open-Meteo (free, no API key) for wind
speed/direction at a chosen pressure level across a grid covering the
current map view, and draws it as rotated arrows colored by speed.
Longitudes are normalized before the upstream call — Leaflet reports
out-of-range bounds past the antimeridian, which Open-Meteo rejects.
Airports under ash — the map shows where ash is; this shows what it hits. Every airport in and around Indonesia is tested against every frame's polygons, and the panel lists them worst-first: affected now before forecast, ash reaching the surface before ash only at altitude, then by height. Affected airports are ringed on the map, filled when affected now.
The distinction that matters is SFC/FL150 versus FL150/FL500: the first
is ash on the runway and in the approach, the second is clear air below and
a problem at cruise. The band's own flight levels carry it, so they are
reported rather than flattened into one verdict.
Two things it is not. It is not a forecast of its own — it is geometry over
published polygons. And it is not an operational clearance: whether an
aerodrome is restricted is decided by its authority and published as a NOTAM
or ASHTAM. /api/notams/<icao> reads those, via the SkyLink NOTAM API
(FAA SWIM FNS), when SKYLINK_API_KEY is set — see Deploy. Without a key
the lookup reports itself unconfigured and nothing else changes.
Airport coordinates come from OurAirports (public domain), filtered to scheduled-service airports.
A note on the boundary: ray casting answers points exactly on a polygon edge
arbitrarily, depending on which edge the ray clips. For a hazard overlay that
is not acceptable, so lib/impact.ts tests edges explicitly and treats the
boundary as inclusive — an aerodrome on the rim of a cloud is reported as
affected. Rings crossing the antimeridian are shifted before testing, or a
Pacific cloud would flag airports in Indonesia.
Volcano alert levels — /api/pvmbg reads PVMBG (Indonesia's
volcanology agency) for each volcano's own status: I Normal, II Waspada,
III Siaga, IV Awas, where Level IV means evacuation is under way.
This is the third of three sources, and they answer different questions. The advisory says whether ash is in the air and how high; a NOTAM says whether the aerodrome is restricted; PVMBG says what the mountain is doing. A volcano can sit at Siaga with no ash aloft, or drift ash across a country while back at Waspada.
Scraped, because the JSON API needs credentials while the public page does
not. PVMBG and the VAACs also name the same volcano differently — "Anak
Krakatau" against KRAKATAU, "Ili Lewotolok" against LEWOTOLOK — and the
page carries no shared identifier, so the join is normalized names plus an
explicit alias list. LEWOTOBI and LEWOTOLOK share five letters, so the
fallback match is longest-first and length-guarded; a prefix match would
put one volcano's alert level on another.
An empty parse is treated as an error, not as "no volcano is under alert", and the last good scrape is served instead.
Alerts — a desktop notification when Darwin issues a new advisory for a volcano on the map, or when an aerodrome under the ash is reported closed. The first load announces nothing (everything is new on open, which would be noise), and repeats for one volcano share a tag so an hourly re-advisory replaces its own notification rather than stacking.
Ceiling: the Notification API only, no service worker and no push server, so the tab has to be open. That matches the feed, which already stops polling when the tab is hidden. Web push needs somewhere to run the poller.
Times you can actually read — advisories are timed in Zulu (UTC), which assumes the reader both knows that and can convert it. A "Times & time zone" section explains it and switches every timestamp to the reader's own zone; hovering a time always shows the other. Zulu stays the default, because it is what the bulletin says.
So the state that matters most is not "failed" — a failure is visible where the data was needed. It is suspect: the request succeeded and returned nothing plausible. An empty PVMBG parse is suspect because Indonesia is never empty; a listing with zero product slots is suspect because a quiet feed still lists its slots. A feed that lists slots and reports no current advisory is genuinely fine, and is reported as fine.
Derived entirely from responses the client already holds, so it costs no extra request and cannot itself be the thing that breaks. A dot appears in the header only when something needs attention.
Discoverability — robots.txt, sitemap.xml, a web manifest (so it
installs on a phone, which is where "monitor volcanic ash" gets read), a
canonical URL, and JSON-LD describing both the application and the dataset,
including the Indonesia bounding box and the GeoJSON distribution.
Structured data carries more weight here than on an ordinary page: the map
is client-rendered, so the served HTML is the app shell and nothing more.
JSON-LD is read without executing any of it. A <noscript> block states
what the app is and points at the GeoJSON endpoint, which needs no
JavaScript at all.
Each language has its own URL — /en and /id — so hreflang has
something real to point at, and a shared link keeps the language it was
read in. Each declares itself canonical and names the other, plus
x-default. / is a redirect chosen from Accept-Language, not a third
copy of the app: serving the same content at three addresses would have the
pages competing with each other.
robots.txt disallows the API routes. They open FTP sessions and spend a
metered NOTAM quota, so a crawl should not be paying for them — but the
GeoJSON export stays reachable to anyone following the link.
UI — shadcn/ui/Tailwind sidebar with collapsible sections, an advisory
detail card, and a legend; a slide-over panel below lg; light/dark theme
with a toggle in the header.