diff --git a/.oxlintrc.json b/.oxlintrc.json
index 1d32906..1b1db6d 100644
--- a/.oxlintrc.json
+++ b/.oxlintrc.json
@@ -1,3 +1,6 @@
{
- "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.astro/**"]
+ "$schema": "./node_modules/oxlint/configuration_schema.json",
+ "ignorePatterns": ["**/node_modules/**", "**/dist/**", "**/.astro/**"],
+ "jsPlugins": ["@northguild/gmt-oxlint"],
+ "extends": ["./node_modules/@northguild/gmt-oxlint/config/recommended.json"]
}
diff --git a/apps/site/package.json b/apps/site/package.json
index 00ed23a..eb0b627 100644
--- a/apps/site/package.json
+++ b/apps/site/package.json
@@ -10,6 +10,7 @@
"preview": "wrangler dev"
},
"dependencies": {
+ "@northguild/gmt": "^1.19.0",
"@northguild/theme": "workspace:*",
"astro": "7.2.7"
},
diff --git a/apps/site/public/stars.bin b/apps/site/public/stars.bin
new file mode 100644
index 0000000..b0b03d5
Binary files /dev/null and b/apps/site/public/stars.bin differ
diff --git a/apps/site/src/components/Aurora.astro b/apps/site/src/components/Aurora.astro
new file mode 100644
index 0000000..2a914bc
--- /dev/null
+++ b/apps/site/src/components/Aurora.astro
@@ -0,0 +1,307 @@
+---
+import {
+ SITES,
+ SKY_CREDITS,
+ compass,
+ coords,
+} from "../scripts/aurora.sites";
+---
+
+
Open-source tools for developers, built in the frigid north.
diff --git a/apps/site/src/scripts/aurora.moon.ts b/apps/site/src/scripts/aurora.moon.ts
new file mode 100644
index 0000000..ad0506f
--- /dev/null
+++ b/apps/site/src/scripts/aurora.moon.ts
@@ -0,0 +1,7 @@
+// Generated by scripts/build-moon.mjs from NASA SVS's CGI Moon Kit. Do not edit.
+// 128×64 greyscale albedo, equirectangular, longitude 0 at the centre.
+export const MOON = {
+ width: 128,
+ height: 64,
+ data: "qKioqKioqKipqampqKioqKioqKipqKmpqaipqamqqqqrq6urrK2trKurq6urq6urq6uqq6urq6qpqKenp6enqKenp6enpqenp6anp6enqKeoqKioqKioqaipqKmpqKeop6eoqKmpqqqqqqqqqamoqqmoqKenp6eoqKinp6enp6emp6ioqKenpqempqekpqeqq6yrq6moqqelpaSkpKWmpqenqKenqKqrqqioqaqqq6qqqqmqqaiop6enqKipqqqpp6WmpaSlpaWmp6ipq6uqqqmpqqmoqKioqKyqqampqamoqKioqKamp6Wmp6enp6iop6eoqainp6iqqKenqKeopqanp6enqauqqqqtra2wr6+urKurqqioqKenqqqop6ioq6ysq66wsbCxsK+trKyrq6ysrq2traysrKqpqKmprKuqqqqpq6qsra2sra2trKqpqquqp6ipp6epq6qnp6ioqKmpqKipqamqq6uqqamnqKepqqmpqainqqupqKmop6ampaWmpqanqaqrraytra6trKupqKipqKiopqenqKemp6eqrrW3tLCwsK+wsbOysbCxsbKysLCurq+vrqyrqqqrqq2wsK+vsLGxsK+wsK2sq6yqqqmqqaanqKiop6ioqKmoqKurraurrKytrqurq6yrra6tq6urrKuqqampqainp6eqqKemp6iqrKurq6upqqqoqKWkpaSlp6alqqqnpqWmpaenqKmqqaipq62trrGztLKvrq2usbKxsbCwsbGusK2wsLOysa6urq2srKqrq6upp6anqKipqamqqamop6ioqKeoqKmqqqioqaqqq6qtrrCys7Gyr66traysqqinp6qpqqqpqaenqKioqKimpqWnp6empaSkpqeoqKelpKeppqWlpKSmpqinqKeorKurqa2wr7GysrKzsbGwsbGytre4t7e2trOyrq2rq6iop6ilo6anpqeoqaiqqKenqqqrqqiqqampqaqqqKioqKiqqautraywsrGopaepp6inq6urqqusp6enp6ipqaioqaeop6eoqqWkoqKjpaalpaSmpqeop6impaWnpKWnqaqtsLGtrKyqq6qoq66uq62vsLGxs7e3t7e0sa6rp6SlpaWioKChpKWmpqenqKmqq6qrrK6wrq2sr62trq+urauqqqquqqepqKmnqKSkpKWlpaiqqqunp6emqKqqqqyqq6mnqqmmp6WloqKipqOhpKSlpKemp6mrqaupp6Woqaursba5t7S0sK2pqamoqKqrra6vsbCusbO2tLCsq6ampKejo6Oko6KjpKepqaysrrO2tri5u7i1ubWztLOys7S2sa+tra2oqq2ur62sqqmoqqqppaSlpqWlpqeqqq2opaeoo56hpJ6bm5yam5uam5WdmZeenqKhqaiuqqqopqurq6eutLi2tbWwqqiopKCfo6efoaKhoKKoq7SypqKgmaGhnp2foKKYmp+hq6+wsbnCxcTDxcrLxsDCxcfExcC+u7u4srGvsK2oqauvq6ewrK2rq6enpKSlpaqrpqe1s6mpqKqknJqdnqKZk5SYl5mZk5mVkpqhmqGboqSpp6mqqaqqqa+0r6qnqqibmZ2RkZORl5mRiYOJkZaWm5mZl56YoJuRj5WTm6Ccoayrsr3BwLWxuLu/wMjN08/Nz8rGx8XGw7uzsrGsqaaropyjp6mmoqeio5yblqSjrLCkoZuWlpydnaOorrWvo5iZlpaYlJabkpGTopubmZ+uraSgp6CcnqSkp5+fpKCfmpyRk4qIhH97dHJ3enZ4fnF3gouQh4WMjYqPkJadmZynr7rCtLWzqaOlrq+urLW5vsDGyczRy9HKvbi0r6utpZycmp2hoZ+knqGfmJyanKaqoJSYp6Cel56ZmaOtsrmimZ2blZabmJSWkJGWlJOUlpqjmJWOjZGQlJeeop+XlZKOiYaBfISGgHx5cHFvcnVzbHN3eX+De3t+en55fn+Ji52rs7ursKSnpJWZqKB+e4yaqri+xcbDusG1r6utp6eem5OXmZqenJaZmJubn5ySlqGUlpicnp+jnpudop+gpJ+ioKOdoZ6TlpeRk5CTk5CRk5iXj42Nin1ucX+SlI6FgXl4dHNzdYWQgIJ9gYCKiIqHh352ent5dnZzdG9ubW+EjJymrKyblqGhnpqZkIKCkJyrsbe7wsi7t7qys6mjmpeTlaCelpOZmJOamZubmJWWkI2ho6qpoqCal5aWlJual5+Vnp2emJ2Ul52VjZKSjZKWmJqhmIqEgGRlb3d4dWdpZG6Jg4uZjYyGgn97j4tld4WajYF8gnp6gHt3cHFxeIiTmZqWoJmen5iloZGXpamuq6ivur2+zcW3ureqpaWdnJudqJmUj46TlZmZl5iZmJSYlK24s6agnZqcp6WalZWTk6ChqJiRlZSloZiOlZGPj46SppWJh4JqZ2lobGpsZVtcaXiTkZOIfIB7bGJnbnJ2gY6VkIR7fYqLgH19f3qIkpeXlZiSiZaMmp6TkJiVoqSmnaKxvcfDubi3t7O1sauqp6SelJmQjJGXnKqimJmYpKuep6u2tKygoqOqrJuamp6cn56cm5GXnaCZm56emJOVko6Wi3yAbV5cXF1jYGVobHaEjoyRcmJibFpXW11saGJpeYmRiH+Gg4eNgH16hYeSkZSWqo6CgYyfl5iblpGbnqWjp66xtrawt77HtayxuL+vqaCioJeTipCVn5mTm56oqp+tq7u+qKCdnqCioqOnqqShmaOkqqOUnKepsquckpWOjpOEfndeWFxTV1lZYVpWWHV8foRpV11YTU5XXF5hYGRrhIqNiY6XjIZ9eoOGgIKEiZOUioqChomAlpman56hnp2hrKust7Cts7OtrLW3sKamoqmooqWXlo+TnJeaoKKjo620t6uko5+fn56hrLK1uamfnKOam5KYl5qsrZ2Sk4+HgoGAeHtsUlBTVFFZVVlTbnh1dmhTS0xQT1JXX1pcZ3F1hoiOjIiGeW90fX98gY2MmJCEm5OPj4+WqqicoqKho56qtbC8ta60q6GopK6kl5aZnpylqrKtoqCanZ6fnKGdssq6sraqpaeio6W0wsCyp6OSnKCPjZKQlJ2Vj5CXlZKJhX1vWFVSTlFRVVtSVlpka2ttUlVTTEpNUlNeW2BreHZ2hJSBd4F+aG9ydmd2kYqNjY+WmJuPjo6UmJyepJ+hpqKtw9XEtreupamrpKugmaGcnZ6lp6OmpaWnnJecoKKwvsC5sqqfpqqvrLW1t6ihnZ2Ul52ai4yQkIeHlZiRkI2OgmxcTFBQUFZYUVBTWVhcX1tQV1dST01TVVhYV2dweW99gVxXXm1sXW5vaXeKmZWQkZeqmpCNiI6Rl5unoqCdpq3Q3bi1vb2yqK6lo52joqOenJufnaCdqLGvpaCgr6qyp6ivoqitpqaxsLCtqKSsqJybnp6UlJqXjYmNkI+Kh4dzXUVKUGRUTVJTUVdZWVZaV1RcVE5PTlBVVVZTX257bXRkUFdWWF1agYJ9jZGYnZOVm6CTh4mVnJScoZyfp6aZo6Gsr6y0srqzsKSjm6ClsbO0nZGZoqCdqrStqrKrrq2eo6mptrWsrbG1rqypo6iupqaloJyVkZ6TjIuMlpOGhW1WXWBeUlFMS01XVVxkW1hYXVtVVFNTVFNXVV5rcnxxZFtTU1FZWU5ogZCUmKGjnJWelZKNjpeeop2gpauvpZ6Vjo6ep6ewtrW1n5qYn6OirJhzZZGnppeorKWrv6+wra+Znq+8uLGttsKpra2wp6ibmJiXoIuKmZCLjo2ZmZaCalVjX15eTU5MTVFUbmpfYFZdVlFXV1NPVmphdHVsY3hmU1VTVllSS1B+n52Wkp2Yl4mKlo2XkZeaoKewsKOSj4+Njpmlp6Sys76om56dm6KjcF5fg6OmmJecm6e2tsC5v7m1vru+r7K8uK2op7GjopqYnpucoZSSjZCXmqGdm416Zl9tZ09NT0tLUVqCf2VgUVBRXVJbV1ZVV1ZldXd2f15KSU9ZU01PV3uSk5OFhoWGjnp0f4uKlZakqqadn5SOk46TkqGmpamkpaWhnaKqrKOEcHaPoaGYnKars7W0urSytbrFu7i1r66qo6arsKKdn5+qrqmrpZ6fnJyUpp6ej4p2ZWhNTWNMRkdPWGFgY1tXVVloX1tdW1paV1RagYpzV0dNTlxPT05Ve3txl4iLj3JnZF5eaXmLlJ+fmJ+YmpORkJCVn6GipKCfnKWhpqumoaGVnJeToJqkp6iyrq67rKu0vLS6qKOjoqCgqaKknJ+fobKxrqyspJ2WlpednqOklI1uV1JKUkRDRU1OUFRVY1lTVl9iZ1lhZGBkX3N8cGhYVlhQUUtLTVZvY2B4maOXal1XVExOcISWjJJ8hJidjZCNiZSWm6qpqKmkoqCloq62n6GclZemqKexu7ywuLetqbm2p760n5mZn6CjoqKio6muraqwtbe6qJ2XipOam5WWjXtiU01HSkVCQ0hLUlRgbFFdcXh5c3N0c2hmb21ZVVxxVHhmU05KRVFTTHKDpp1vV1NOS1tvlYh6gmNqf4yIj5OLipaarbKxq6Wmo6WfnKCemaulnrK3s7q+wNrJrquywriptK+knJyknJyrpKCgo7qpr6qqsKqlnpeRpJ2Yj5eNiGxqVU1MR0FBR0dUUl1mYldyhH2JhXl1aFtKVlROS2JfblJAQEZKQkJAWXeNoHlZUlJVj3yUjIF+XFdfY3aJlpKMlpuwtbKoqbSnpZ6am5OVnpidrbS1v7q9wLyztLi+vrSupaOmpaieoqqlq6OgoaKioqCupJ6clpaelJKTlZici4BvUkhHRkZFSEdPZX10W2x9g6GReXZmV1Nna1RHWnhxST5AS0JDQEVKYHSWkH1ueX+GgoKOk4N/b2t1eYSOlpaVk7LAtq6wrq6gm5yZmZ6XmqClvLC1v7q5uLO2sK+0p6ulp6ygn7Sqsa2uo6Wdm5+up6anoJykopeTlZiXrKydjYVoTE5TTEpLSlVthn5maXF7eXJkY1dIaHx+eW11hXlQSU1CRURERldkZ22HjIeId25mdIuUk4R2e4uEgo+Xl5uVnL++uK6mqayjoKGgnp+fnqCmsrTAtba2uayloaqhqraroJ6dqqqutbWunKKgpJmlrbOrtMSooqOZmJellJqUmYZeR0NFSkpLTGBpaWRmbnt2alddUktdcnyOiJuNeWFNR0dITlFOVWZsV1R6hYOFcXaLiJGYh29qdX6Dn5+fsJmht9HBs6ujo52jraWgopqkqqq3uq6urL60paOjq6O4xK6tpqOqsLK+wKyor5+Vk56pr7GxtqadnpaYlY6So5OXiFhLQ0NHSUlIVVxcXWJnbXd5XVtaYWFeco2WpJiHdlJPTFBoaFReXlZPTEdSanBofn15gohtX09ghJGZlKKtmp6/ysmysKyrnZ6mo6KhnqKkqqmwr6ansK+gn5+mq7Gusbispquqsbe9srCpopiSmaStqKSkpqiioJ2QioqDmJp/ZU9HQkZJSERKS1JYW2VgYW5sZmh6fm58j5eUkpWMhXNVWYuFgXFUVUtLTFVic3d6goGIfGFaXWqOjYiPmqOSmKvEy8Syp6ymrqmgoKeipq2nlpCipaWssaKboa2qq6Crsa6mqqulrreqpKablpOXnKOqqqeypqmknpWRi4mTa396XkZBTU9ISEtJT1hfXVdicHNnjY6NioiPlpGSlqWlh2Nsh4SAd1xVUU9XZGF1fnyBiIl9bGZ8hIqLlJCQlI+NkbrRyLKlo5+mn6Cdnpqit6mdl5ehoq+tpaipuLKxqqivqKWppaikoqGeo5yWlZSeoKuxr62tq6Wam5WZkY1ZjZFqVEZCTUlJSVFQWnZoUl9wd1lyhoqNi5GinpeWrZt9b3GGjpV7YVRSUV1ohoiDgISFhIl5a3qNjZONhpOamZKTrsq4qJ6anJyUlpuZmaSopquinqqnsKaipq27xruqorCqp6SjoJyblpuYnpiRlqa3vLW4tLOopqGPl5eMj46Pi39lT1ZOUElST05Xa1dQVl93Wl1/iH2Gh5WSlKKhqpmOfXuDjpV9ZVpaaXuclo6HiJKKkpKKkZeclY+Eip2cjoabqqqeoZWYlY+VmZWZlpewt6KcoqWqnqqzsrzCt6uloLappaKdmpeZj5OYlJGmpLm+vb/AurKYm4mQlI+Sk4+Qn39gcVxhYXBcT01ZVVlYYHBjXnKEiYaKjZCToJqjrp+NdmV1lYZ5dW9ncICCjIaIg4ySkZuYm5qRi4eMkYyHi4+XoZ+YmZmPjpeYkJiam6ippZyxppyTmKWyq6qynp2gnp6opaSgnJePkJGXmZ2jrLS6s6aUpaOQmpORlJSKhouOg35rbG52e2pjUk1qfFtgXlhgaoaKh4uRm5WTk6CvsIJqZ3OZi4aEgWdyfnWDgH+SjI6SoJyin4+Pi5CbjIWPlpGdmZialZuTlZeVkZeboKCZo6ChkJCTnp6Vn6yakJudlqCZm5qcmJKZmJicoKOnrqmOboSAlJiYk5iOkJCTmY+FgHZ5iHx0YFhcY3RnXGBaV1WAi5OMmo+RkI6YlaKujnVnX45+hHRueIGEdoF9f42WnJ6pm5ulnpiSi5GEiKOcnZ+WmJeTj5Kgi6aUj5aTk5qtk5qMlIiJhI+XmpiUkomPl5SOi4uWmJqjlqSopaiwq6B6Z3igqqGjlpijoKilnpB/goWEXktJTHNrcHBuXVNZW4KUi4mMiI6Tl5OQmp2hg4KKe4x9hHCDn5eAhoOHkJSaiZGVj5SdrJeFgoSRpaCgnHCBoJiakZOUqI+Mi4mKl5idjoiEhI+CjJCQioOLiIiWlpySj5CfnKecn5ybpK+wrJR5iqqqqqOWlpWiwMirn5mMi4FZU0tHX2ldbmRVWFFbcpGMm5CPmp2cjI+frpuQk5KJlZWWlZCUmZOLjYiOiZOPi5eNk5Gbi357h4+Wk5eWm6yan5qcmZaZmZGOiZCKioiCgIKBhH96h4uBhH97f5CTpaWjn5qXl5CRlJymrLKjk5yprrOnnpqWl5mqsqCYmaCJfGBWUlZqdmJoaFVWWGF8mZypm56snJCSm6ajn6OYkJKYoKOpoaKmopefioeMoZ2PjY2LjIh1cYuMioWGiJKQnJmalpSZkZyWmYmRlo+NiZCLbXp8bHGBiHuAfnh8jJ6cpK2knZCSj4+Tl6Grp56amqmnqaSglJORk5WZmpCap5uQd2VqbYNodXuGd3d2jaKnpq2ro5qRk52klpuYmZOZlpmcrrm3rKybo6KPho6QoZmOiYaHhnx8f32Ak5eZkZOQlZyfl6iZjpCbjoSOfYl6eoJ3d3Juc32KdXh8goWHm5qgm6aejouSlpCTlpyek5ChnaGloZqWk42WlpGWiJOdp5SBf3eGcWNueaOjlJmtt8W6q6+mnZydqpygnpORkaGblpmsy7rGs6SqnJSNm4+LioVod4mCg4OHlJWTlpWKlJSJiZCWjXmAhpmTdGtdhHyCeWx2d2lde3xugF9tgIOJlKaZpJqSj42QkIuRkpOVmKWenKSZnJGQmZCLjJiNkoyQhXCBhIOBfIeQk6ipq7q8xrWtraWno5yYlJeVkZOSnJmanaivs7ahm5WSkpSciIBocnZvcnV0fY2eoZ2VlI2RiYSFko94ZISJkJWCdHB+fHptaXd6dWN8f259bXJ8fn6InJyVkZmVlJCMjImNi5KXlpiTmZaUl5OWl46Jpo+GhIeFfoGQjo2Kkp2epa65v8C/ubSvp6SbmqOYl5mZnpiXmJeXoa6fppmVlpuQi4t8e3tua3d2b4F7hpaemZONiZaSi4GKkIyEiZiQioF6eXuAcm98hYx3aXp/bGNkcXd4foWRnZKMlJSPl5KQkI6PjY+Ul5WVmJWZl5KSo5iVh36KjpOPkZeal5qMmaavtrSzur67ta2spqOrn6GjnpSWnZ+joqOlmouOjoyKh4iPgXRtdG1kcXhxeHmFh5eYlY6LkoeJiI+WmZicl42GfXV3foyAd4KDgnVwdHpwZGx7dXl9g4aHk4yGjY2UjYuLi4mNiY+Zm5mZmJmglZGTnJN7doyJg36Rl5mUl5ahpbCxv8Oyt7Szs7OnnKKbnKKTlZmcoqWsp5+WjYuMjo+KiHhzfH56cGNoeXt3dnuHlJuMjYiVjpCHjbizmJSZiYB0d3t7fnyAiIV6dXJudGllZWxwdHd7foiMjIiPi4yWkJKPkJGTkJahmZiVlZiZmJGQl5CNg4V+goqNl5yZlZ6jsLe0tLe5tLaxq6CTkpWUlZehqaWlr7anmo+Li4+Pk5GSb3aFeYN7enV1eniEeIaJjIuMj5aVjYuYkpWNi4R/i3BvgoF1cH+Jj3h1dXR1bm9scnJxend4hpWKkJOPk5mQlJWUlpSZkZmSk5KVoaSYkoyMj4uKgX2Dh4yUmZWYnJ6wtLq9ucW8t7mupqmfn5mZlp+bnZ2ioJuUjJqVoaCMhnVodYJnaHx/gHiHkoiLhoSHk56VjYaJi5WLiYqDg4+JhoCCfmx2f35/en9reHJscmxudHZudnh8goSFkpmVnJOblZ+fk4qPlpaWk5aeoJOMi5OcmY+De4KNkZSVk5qnsKi1v7uvtbCuuaujnpuZnqGeoJ+io6Wfmo+Pm5mThHqRdGpte3ZwfISQg4GGiImSh4qTkoyDh42Qk4+Tg4OIiYOGiYN8c3uCe3h5hXZ8dGhqZ2t4c3t6dnp8foOLlI2SlpOWmpeTkZSanpuVmZuamJSRlpealol6hJaVlpiboaqjq7extKyyq6espKKhnKGkpKCZm6WnmpWcnpOSkZWPhouBbWZ4e4SDgYqIkYCDkpGRk6OakpGQiJGYlpKTiIeLjIt7fH96dn6BeYB0dHt+cm1wdHZ2eXqGg399iIqRjpCQmqCempOUlZuXnJifoKmhm5qZnaWmopecpZ6Xl5+qn6iyv7G1trCsq6uqrKCmpaadoJuhoqCYnJ+fmpeeoqOenKCDhpeTg3p9hX5zgYiPoJqaub6soo+PjJGYmp+PkYd6am9ycoKKiYWKh3yCi4l9enp6fnt+iIuGgoCEi5KWk5acnKChnaCgoJminaesraqopKOnq62wqqqnnp+jqaurq7G/vb+0rKSkp6qqsK6spaSgmpqgn6CdoZ6koaOfoJiar6ydjoKEf4iLh4l/e4uVnJ+utKylk4+TlpyXkJWNg4R8g4WIh4mChZmLho6TlISCgH+BgoaHiomJi4yVkpKVl5Wbm6ufoqegoKGeqK2wrrG0s7OwqqSvr6msp6aos6+yvsm/xrmtr62vq6qvpqWmqaeko6Ofpa6sra6tsamfnaWuppuYi3yKiI+XmKCYlpuYmZ+stbCjnZ+dm5WOkpGLhZGHhImMj4mXmJCPjJickIyPj5KTkpGLipOQlJeXm5mdnaKkoZuuo6mkpqqpqa+vr66ys7Cus7Wyrainqauvs7a/wsK+v7Kws7OwqbK1sKeorq+qq6qrr7a6tLm1sq+qqaipoZ2Yl5SZqq2mpKWlpp6pp6ayw7Khn5+inpqQl6agnpSOjZOWlaShmZ2enJmYmJ2cnp6hoJ6cnp6fn6GhoKaipaGlp6WpqKmorKupq66xtbe7t7WwrK2rq6iprKyrr7SytbOwsa+srqqsr7Wrrq+uq62vrqyprK2ws7GpqKWop6ShpKKeoJ+fnpudoaCjoqKhpaasraSkqa+opJ+hoqisqqaenJyanJ2enp+hnJudnp6fnqGhoqKlpqSioZ+hoqOkoqKjpaaopaepqampqKqssrOyrqmtqaiqq6mmpqmprauusK2traqqra+vsK2srbCtrq6tra6urKinp6enpaSkpKGlpaKenp+dnJ6ioZ+jo6ajpaanq6mopqWlpKenpautqaKipKCnp6emqaWgoaChoqWlpKGlp6alo6KioqKjpqempaWnqKeoqqyrrKimq6qoqqutq6usrK2rqqaqraywr66trKyusLGzsbOzsrOxtLOxsK2srq2sq6unpaenqayqqquop6eopqKhpKenpKakoqWqqqahoaGlpqWopqeoqKysqqimpaqrrKqrq6GgpaampqWjpKWko6GhoqGjoqSlp6qtq6mqqKeoqKuqqqinp6enqqmoq6qoqKyrrq2usbOwsbCxsLK0t7e3t7a3trS1trSzsrGwrKutq6urrKuqqKqtr7CwrauqqKanqaenpqirq6qrqKmnpqelpaWlp6emrK6tqaioqKmnp6urpaWnp6OlpaiopqSkpKOkpKOio6WnqquqqamoqaepqaipqKmoqKqqq6qoqKinqqqpqautq6qrq66vsbCws7Gztba3trazs7CwrqqpqamoqaqrrKyrq6ytsa2rqqmqqKWmo6OlpKeop6ipp6mopaWjpqinqKiqqaqnqamqqqqrra2rqqiop6epqaalqKmnqKmpqaioqampqaimqKqqqaqqrKmqqqmpqKinqKeopqmoqKurra6rqaqqqqqtr7CurbGzsLCvr66rq6urq62wsLCtq6qsrrCsrauqrK6vrauqqaioqqypp6ekpqqqqamopqanqaqsq62vr62qqquqqqipqamoqamop6inpaenqaioqamoqqqqp6epqKWlpaalpqanqKenp6aoqaenp6eoqKiqrK2tq6qqqqqrrK2srq6rra2sq6uqq6yrrK2tqqmnpqWmp6epqKiqqqqsq6urrK2sqaenp6ampqWmpqanp6anpaemp6mrqaqqqayssLCys7S0srGxsbCwsLCvr6+vr66trK2ur66wrq+vsLCwrq2urq+wr6+vr66traytra6trq2traysq6ytra6trq+vraurqqurraysra2urq6vr7CwsbGwsbGxsbGxsbCwsK+vrq6urrCwsLCvrq6vrq6ura6ura2tra2urq+wsLA=",
+};
diff --git a/apps/site/src/scripts/aurora.shaders.ts b/apps/site/src/scripts/aurora.shaders.ts
new file mode 100644
index 0000000..70cfccd
--- /dev/null
+++ b/apps/site/src/scripts/aurora.shaders.ts
@@ -0,0 +1,364 @@
+// GLSL for the aurora worker. Units: kilometres and seconds. World frame:
+// x east, y up, z north, viewer at the origin.
+//
+// The aurora follows Lawlor & Genetti: a thin emissive sheet whose ground
+// track (the "footprint") is a 2D map, times a height profile. Each curtain's
+// track is a curve z(x), so the footprint pass writes one texture row per
+// curtain: its path, brightness along the arc and ray height. The sky pass
+// marches altitudes from the lower border up and measures the distance to
+// that path at each step.
+
+export const MAP_W = 2048;
+export const MAP_H = 4;
+export const CURTAINS = 2;
+
+export const COMMON = `
+precision highp float;
+#define MAP_W ${MAP_W}.0
+#define MAP_H ${MAP_H}.0
+#define CURTAINS ${CURTAINS}
+uniform float uT;
+uniform vec3 uSpring, uCyan, uPurple;
+// Ground shift per km of altitude along a field line (toward magnetic south).
+uniform vec2 uTilt;
+const float H0 = 100.0; // lower border, km
+const float RE = 6371.0; // Earth radius, km
+const float SIG = 8.0; // sheet half-thickness (gaussian sigma), km
+float tt;
+float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
+float noise(vec2 p) {
+ vec2 i = floor(p), f = fract(p);
+ f = f * f * (3.0 - 2.0 * f);
+ return mix(mix(hash(i), hash(i + vec2(1.0, 0.0)), f.x),
+ mix(hash(i + vec2(0.0, 1.0)), hash(i + 1.0), f.x), f.y);
+}
+// Per site, per curtain: base distance north, then the x range drawn
+// (centre, half-width), all km.
+uniform vec3 uC0, uC1;
+vec3 curtain(float k) { return k < 0.5 ? uC0 : uC1; }
+vec2 span(float k) { return curtain(k).yz; }
+// Ground distance north of the viewer: a base plus folds at three scales.
+float path(float x, float k) {
+ float s = 1.0 + 0.6 * k;
+ float z = curtain(k).x;
+ z += 90.0 * s * (noise(vec2(x / 420.0 + k * 7.0, tt * 0.0012 + k)) - 0.5);
+ z += 35.0 * s * (noise(vec2(x / 140.0 + k * 3.0, tt * 0.003 + 2.0 * k)) - 0.5);
+ z += 10.0 * (noise(vec2(x / 45.0 + k * 11.0, tt * 0.008)) - 0.5);
+ return z;
+}
+// Long bright stretches and dark gaps; a faint arc is always left.
+float lit(float x, float k) {
+ vec2 q = vec2(x / 260.0 - tt * 0.002, k * 9.0 + tt * 0.003);
+ float n = 0.62 * noise(q) + 0.31 * noise(q * 2.03 + 17.0);
+ return 0.08 + 0.92 * smoothstep(0.38, 0.62, n);
+}
+float knots(float x, float k) {
+ return 0.45 + 0.55 * noise(vec2(x / 55.0 + tt * 0.004, k * 5.0 + tt * 0.01));
+}
+// How rayed (rather than smooth) this stretch of the arc is.
+float rayed(float x, float k) {
+ return smoothstep(0.3, 0.7, noise(vec2(x / 180.0 + k * 4.0, tt * 0.003 + k)));
+}
+// Fine striations along the arc, drifting about 0.5 km/s.
+float rays(float x, float k, float c) {
+ float r = 0.6 * noise(vec2(x / 3.5 + tt * 0.15, k * 13.0 + tt * 0.02))
+ + 0.4 * noise(vec2(x / 1.6 - tt * 0.1, k * 17.0));
+ return mix(1.0, r * 1.4, c);
+}
+float rayHeight(float x, float k) {
+ return rayed(x, k) * (0.3 + 0.7 * noise(vec2(x / 90.0 + k, tt * 0.002)));
+}
+// Colour by altitude: bright spring-cyan at the border, cyan to blue above,
+// faint purple at the top.
+vec3 ramp(float h) {
+ vec3 blue = mix(uCyan, uPurple, 0.5);
+ vec3 c = mix(mix(uSpring, uCyan, 0.55), uCyan, smoothstep(100.0, 125.0, h));
+ c = mix(c, blue, smoothstep(125.0, 180.0, h));
+ return mix(c, uPurple, smoothstep(180.0, 260.0, h));
+}
+`;
+
+export const QUAD_VS = `
+attribute vec2 aPos;
+varying vec2 vUv;
+void main() { vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }
+`;
+
+// Rows 2k: path z as ±200 km around the base (two bytes), sqrt of fine
+// brightness, ray height.
+// Rows 2k+1: sqrt of coarse brightness (no rays), for far-away pixels.
+export const FOOT_FS = `${COMMON}
+varying vec2 vUv;
+void main() {
+ tt = uT;
+ float row = floor(vUv.y * MAP_H);
+ float k = floor(row / 2.0);
+ vec2 sp = span(k);
+ float x = sp.x + (vUv.x * 2.0 - 1.0) * sp.y;
+ float fade = smoothstep(0.0, 0.08 * sp.y, sp.y - abs(x - sp.x));
+ float zn = clamp((path(x, k) - curtain(k).x + 200.0) / 400.0, 0.0, 1.0);
+ float coarse = lit(x, k) * knots(x, k) * fade;
+ if (row - 2.0 * k < 0.5) {
+ float fine = coarse * rays(x, k, rayed(x, k));
+ gl_FragColor = vec4(floor(zn * 255.0) / 255.0, fract(zn * 255.0),
+ sqrt(min(fine / 1.5, 1.0)), rayHeight(x, k));
+ } else {
+ gl_FragColor = vec4(sqrt(coarse), 0.0, 0.0, 1.0);
+ }
+}
+`;
+
+export const skyFs = (steps: number, top: number) => `${COMMON}
+#define STEPS ${steps}
+uniform sampler2D uMap, uMoonTex;
+uniform vec3 uVoid, uTeal, uIce;
+uniform mat3 uCam;
+uniform vec2 uTan;
+uniform float uPxAng;
+uniform vec4 uMoon; // direction, angular radius
+uniform vec3 uMoonN, uSun;
+uniform float uMoonlight;
+uniform float uGlow; // city light near the horizon, 0..1
+uniform float uLights; // lit windows and streets, 0..1
+varying vec2 vUv;
+const float H1 = ${top}.0;
+const float PI = 3.14159265;
+
+// Path of curtain k at x: (z, dz/dx, fine brightness, ray height). Two
+// fetches and a manual lerp, since the two-byte z cannot be filtered.
+vec4 fetchPath(float x, float k) {
+ vec2 sp = span(k);
+ float u = clamp((x - sp.x + sp.y) / (2.0 * sp.y), 0.0, 1.0) * MAP_W - 0.5;
+ float i = floor(u), f = u - i;
+ float v = (2.0 * k + 0.5) / MAP_H;
+ vec4 a = texture2D(uMap, vec2((i + 0.5) / MAP_W, v));
+ vec4 b = texture2D(uMap, vec2((i + 1.5) / MAP_W, v));
+ float z0 = curtain(k).x - 200.0;
+ float za = z0 + 400.0 * (a.r + a.g / 255.0);
+ float zb = z0 + 400.0 * (b.r + b.g / 255.0);
+ float bf = mix(a.b, b.b, f);
+ return vec4(mix(za, zb, f), (zb - za) * MAP_W / (2.0 * sp.y), 1.5 * bf * bf,
+ mix(a.a, b.a, f));
+}
+float fetchCoarse(float x, float k) {
+ vec2 sp = span(k);
+ float c = texture2D(uMap, vec2((x - sp.x + sp.y) / (2.0 * sp.y),
+ (2.0 * k + 1.5) / MAP_H)).r;
+ return c * c;
+}
+// Sharp lower border, then an exponential tail whose height grows with g.
+float profile(float h, float g) {
+ return smoothstep(94.0, 106.0, h) * exp(-max(h - 106.0, 0.0) / (25.0 + 45.0 * g));
+}
+vec3 moon(vec3 rd) {
+ float cm = dot(rd, uMoon.xyz);
+ if (cm < cos(uMoon.w * 3.0) || uMoon.y < -0.02) return vec3(0.0);
+ vec3 o = (rd - uMoon.xyz * cm) / sin(uMoon.w);
+ float r = length(o);
+ float px = uPxAng / uMoon.w;
+ float cover = 1.0 - smoothstep(1.0 - px, 1.0 + px, r);
+ vec3 n = o - uMoon.xyz * sqrt(max(0.0, 1.0 - r * r));
+ vec3 north = normalize(uMoonN - uMoon.xyz * dot(uMoonN, uMoon.xyz));
+ vec3 west = cross(north, uMoon.xyz);
+ float lat = asin(clamp(dot(n, north), -1.0, 1.0));
+ float lon = atan(dot(n, west), dot(n, -uMoon.xyz));
+ float alb = texture2D(uMoonTex, vec2(0.5 + lon / (2.0 * PI), 0.5 - lat / PI)).r;
+ float day = max(dot(n, uSun), 0.0);
+ float halo = exp(-(1.0 - cm) / (uMoon.w * uMoon.w * 6.0)) * 0.06;
+ float full = 0.5 - 0.5 * dot(uSun, uMoon.xyz);
+ return uIce * (cover * alb * (2.2 * day + 0.03) + halo * full);
+}
+// Sky seen along rd (above the horizon): background, city glow, moonlight,
+// the aurora and the moon. Before tone mapping. j is the step jitter.
+vec3 skyColour(vec3 rd, float j) {
+ float c2 = dot(rd.xz, rd.xz);
+ vec3 acc = vec3(0.0);
+ for (int i = 0; i < STEPS; i++) {
+ float s = (float(i) + j) / float(STEPS);
+ float h = H0 - 6.0 + (H1 - H0 + 6.0) * s * s;
+ float dh = (H1 - H0 + 6.0) * (2.0 * s + 1.0 / float(STEPS)) / float(STEPS);
+ // Distance along the ray to altitude h over a curved Earth.
+ float t = min(2.0 * h / (rd.y + sqrt(rd.y * rd.y + 2.0 * c2 * h / RE)), 2500.0);
+ float dt = dh / max(rd.y + c2 * t / RE, 0.03);
+ vec2 g = rd.xz * t - uTilt * (h - H0);
+ vec2 dg = rd.xz * dt - uTilt * dh;
+ float pxKm = t * uPxAng;
+ float dens = 0.0, gh = 0.0;
+ for (int k = 0; k < CURTAINS; k++) {
+ float fk = float(k);
+ vec4 m = fetchPath(g.x, fk);
+ vec2 n = normalize(vec2(-m.y, 1.0));
+ float d = (g.y - m.x) * n.y;
+ // Widen the sheet by this step's travel across it, keeping its integral.
+ float dn = abs(dot(dg, n)) + pxKm;
+ float s2 = SIG * SIG + dn * dn / 12.0;
+ float bc = fetchCoarse(g.x, fk);
+ float b = mix(m.z, bc, smoothstep(1.5, 4.0, pxKm / 3.0));
+ dens += b * exp(-0.5 * d * d / s2) * SIG * inversesqrt(s2)
+ + bc * 0.1 * exp(-0.5 * d * d / (16.0 * SIG * SIG));
+ gh = max(gh, m.w);
+ }
+ acc += ramp(h) * dens * profile(h, gh) * dt;
+ }
+ float e = max(rd.y, 0.0);
+ float air = exp(-0.12 * (1.0 / (e + 0.04) - 1.0));
+ vec3 col = uVoid + uTeal * 0.05 * exp(-e * 8.0)
+ + uGlow * (uTeal * 0.09 * exp(-e * 9.0) + uIce * 0.035 * exp(-e * 22.0))
+ + uMoonlight * (uIce * 0.02 + uTeal * 0.05) * (0.5 + 0.5 * exp(-e * 3.0));
+ return col + (acc * 0.035 + moon(rd)) * air;
+}
+// City lights along the shore, by azimuth bin: on or off, and how bright.
+float shoreLight(float az) {
+ float cell = floor(az * 950.0);
+ float h = hash(vec2(cell, 3.7));
+ float f = fract(az * 950.0) - 0.5;
+ return step(1.0 - 0.16 * uLights, h) * fract(h * 41.3) * max(0.0, 1.0 - abs(f) * 2.4);
+}
+void main() {
+ vec2 ndc = vUv * 2.0 - 1.0;
+ vec3 rd = normalize(uCam * vec3(ndc * uTan, 1.0));
+ // Static per-pixel jitter: breaks banding without shimmer.
+ float j = fract(52.9829189 * fract(dot(gl_FragCoord.xy, vec2(0.06711056, 0.00583715))));
+ vec3 col;
+ if (rd.y >= 0.0) {
+ col = skyColour(rd, j);
+ } else {
+ // Water: the sky mirrored and broken by slow ripples that crowd toward
+ // the horizon; strongest at grazing angles (Fresnel).
+ float d = -rd.y;
+ float az = atan(rd.x, rd.z);
+ vec2 q = vec2(az * 700.0, 0.06 / d);
+ float w = noise(q + vec2(0.0, uT * 0.35)) + 0.5 * noise(q * 2.3 - vec2(0.0, uT * 0.25)) - 0.75;
+ vec3 rr = normalize(vec3(rd.x, d * max(0.15, 1.0 + 0.9 * w), rd.z));
+ float fresnel = 0.02 + 0.98 * pow(1.0 - d, 5.0);
+ col = uVoid * 0.5 + skyColour(rr, j) * fresnel * 0.85;
+ // City lights stretched into broken vertical streaks.
+ float streak = shoreLight(az + w * 0.0004) * exp(-d * 45.0)
+ * smoothstep(0.35, 0.75, noise(vec2(az * 900.0, d * 260.0 - uT * 0.6)));
+ col += uIce * streak * 0.35;
+ }
+ col = 1.0 - exp(-col * 1.7);
+ col += (hash(gl_FragCoord.xy) - 0.5) / 255.0;
+ gl_FragColor = vec4(col, 1.0);
+}
+`;
+
+const PROJECT = `
+uniform mat3 uCam;
+uniform vec2 uTan;
+// Camera space is (right, up, forward); clip w is the forward distance, so
+// anything behind the viewer is clipped.
+vec4 project(vec3 p) {
+ vec3 c = p * uCam;
+ return vec4(c.xy / uTan, 0.0, c.z);
+}
+`;
+
+// Electrons spiralling down field lines and flaring where they reach the
+// lower border. Each lands at the curtain's shape at its landing time, so it
+// stays put while the curtain drifts on.
+export const SPARK_VS = `${COMMON}${PROJECT}
+attribute vec4 aSeed;
+uniform float uPx;
+varying vec3 vCol;
+void main() {
+ float k = aSeed.z < 0.6 ? 0.0 : 1.0;
+ float life = 10.0 + 10.0 * fract(aSeed.z * 7.13);
+ float ph = fract(uT / life + aSeed.y);
+ vec2 sp = span(k);
+ float x0 = sp.x + (aSeed.x * 2.0 - 1.0) * sp.y * 0.9;
+ float lift = 1.0 + 12.0 * pow(fract(aSeed.w * 13.7), 2.0);
+ tt = uT + (0.4 - ph) * life;
+ float z0 = path(x0, k) + (fract(aSeed.w * 5.3) - 0.5) * 2.0 * SIG;
+ float bright = lit(x0, k) * knots(x0, k);
+ tt = uT;
+ float hit = H0 + lift;
+ float h = mix(420.0, hit, smoothstep(0.0, 0.4, ph));
+ float spin = uT * (2.0 + 3.0 * aSeed.w) + aSeed.y * 40.0;
+ vec2 g = vec2(x0, z0) + uTilt * (h - H0)
+ + 0.03 * (h - hit) * vec2(cos(spin), sin(spin));
+ vec4 c = project(vec3(g.x, h - dot(g, g) / (2.0 * RE), g.y));
+ float glow = smoothstep(0.45, 0.65, ph) * (1.0 - smoothstep(0.75, 1.0, ph));
+ vCol = mix(uCyan, ramp(hit), glow) * 0.3 * glow * bright;
+ gl_PointSize = 1.3 * clamp(250.0 / max(c.w, 1.0), 0.6, 2.0) * uPx;
+ gl_Position = c;
+}
+`;
+
+// Catalogue stars: J2000 directions turned into the sky of date. Fainter
+// near the horizon (extinction) and when the moon brightens the sky. They
+// twinkle; they never move except with the sky itself.
+export const STAR_VS = `
+precision highp float;
+${PROJECT}
+attribute vec3 aDir;
+attribute vec2 aMag;
+uniform mat3 uStars;
+uniform vec3 uIce, uCyan;
+uniform float uPx, uT, uLimit, uFade;
+varying vec3 vCol;
+void main() {
+ vec3 w = uStars * aDir;
+ float alt = w.y;
+ float m = aMag.x + 0.12 * (1.0 / (max(alt, 0.0) + 0.04) - 1.0);
+ float b = pow(2.512, -m);
+ float a = min(2.6 * sqrt(b), 1.6) * smoothstep(uLimit + 0.3, uLimit - 0.6, m) * uFade;
+ float seed = fract(sin(dot(aDir.xy, vec2(91.7, 37.3)) + aDir.z * 11.1) * 4375.85);
+ float amp = 0.1 + 0.3 * (1.0 - smoothstep(0.05, 0.5, alt));
+ float tw = 1.0 + amp * sin(uT * (1.1 + 2.0 * seed) + seed * 60.0)
+ * sin(uT * 0.37 + seed * 17.0);
+ vCol = mix(uIce, uCyan, 0.35 * smoothstep(0.4, -0.1, aMag.y)) * a * tw;
+ gl_PointSize = uPx * (1.6 + 1.4 * min(sqrt(b), 1.2));
+ gl_Position = alt > 0.0 ? project(w) : vec4(0.0, 0.0, 2.0, 1.0);
+}
+`;
+
+export const POINT_FS = `
+precision mediump float;
+varying vec3 vCol;
+void main() {
+ vec2 q = gl_PointCoord * 2.0 - 1.0;
+ float m = max(0.0, 1.0 - dot(q, q));
+ gl_FragColor = vec4(vCol * m * m, 1.0);
+}
+`;
+
+// The skyline as a strip of directions from the ridge down below the frame,
+// drawn last so it hides sky, stars and sparks.
+export const RIDGE_VS = `
+precision highp float;
+${PROJECT}
+attribute vec4 aRidge; // direction, haze 0..1 (negative on the bottom edge)
+varying float vHaze, vDown;
+varying vec3 vDir;
+void main() {
+ vDown = step(aRidge.w, -0.5);
+ vHaze = mix(aRidge.w, -1.0 - aRidge.w, vDown);
+ vDir = aRidge.xyz;
+ gl_Position = project(aRidge.xyz);
+}
+`;
+
+export const RIDGE_FS = `
+precision highp float;
+uniform vec3 uVoid, uTeal, uIce;
+uniform float uMoonlight, uLights;
+varying float vHaze, vDown;
+varying vec3 vDir;
+float hash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
+void main() {
+ // Distant ridges are paler at the top, fading within a degree or so.
+ float rim = vHaze * exp(-vDown * 60.0);
+ vec3 col = uVoid * 0.45 + (uTeal * 0.035 + uIce * 0.006) * rim
+ + uIce * 0.02 * uMoonlight;
+ // A scatter of lit windows and street lights on a city skyline.
+ vec3 d = normalize(vDir);
+ vec2 g = vec2(atan(d.x, d.z) * 950.0, asin(d.y) * 1100.0);
+ float h = hash(floor(g));
+ vec2 f = fract(g) - 0.5;
+ // Only past the first kilometre: the viewer's own surroundings stay dark.
+ float on = step(1.0 - 0.13 * uLights, h) * step(0.02, vDown) * step(0.003, vHaze);
+ col += uIce * on * exp(-dot(f, f) * 14.0) * (0.25 + 0.5 * fract(h * 37.0));
+ gl_FragColor = vec4(1.0 - exp(-col * 1.7), 1.0);
+}
+`;
diff --git a/apps/site/src/scripts/aurora.sites.ts b/apps/site/src/scripts/aurora.sites.ts
new file mode 100644
index 0000000..e807317
--- /dev/null
+++ b/apps/site/src/scripts/aurora.sites.ts
@@ -0,0 +1,150 @@
+// Places the sky can be seen from. The worker picks one per visit from the
+// browser's time zone (or at random) and the visitor can switch in the info
+// popover; the popover shows the same site's details. Plain data only: the page's own
+// script and scripts/build-skyline.mjs both import this.
+
+/** One aurora curtain: its ground distance north and the x range it spans. */
+export type Curtain = {
+ /** Typical distance north of the viewer, km. */
+ base: number;
+ /** Centre and half-width of the east–west range drawn, km. */
+ centre: number;
+ half: number;
+};
+
+export type Site = {
+ id: string;
+ /** Short place name, then the country. */
+ name: string;
+ country: string;
+ /** Where the viewer stands, in a few words. */
+ spot: string;
+ lat: number;
+ lon: number;
+ /** Eye height above the ground (above the sea for a site over water), metres. */
+ eye: number;
+ /** Camera heading from north toward east, degrees. */
+ heading: number;
+ /** Camera pitch and vertical field of view, degrees. The frame's bottom
+ * edge sits at pitch − fov / 2, just below the horizon. */
+ pitch: number;
+ fov: number;
+ /** Magnetic dip and declination, degrees (approximate, for ray tilt). */
+ dip: number;
+ decl: number;
+ /** The near and far curtain. Farther south, the aurora sits farther north. */
+ curtains: [Curtain, Curtain];
+ /** City light on the sky near the horizon, 0 (none) to 1 (a capital). */
+ glow: number;
+ /** Faintest star magnitude visible on a moonless night. */
+ limit: number;
+ /** What stands between the viewer and the sky, for the popover text. */
+ ground: string;
+ /** Water in front reflects the sky (the skyline script finds the shore). */
+ water: boolean;
+ /** Add OpenStreetMap building heights to the skyline. */
+ buildings: boolean;
+ /** Lit windows and streets on the skyline, 0 (none) to 1 (a capital). */
+ lights: number;
+ /** Credits for the data behind this site's skyline. */
+ credits: [string, string][];
+ /** Browser time zones that start a visit at this site. */
+ zones: string[];
+};
+
+const COPERNICUS: [string, string] = [
+ "Terrain",
+ "Copernicus DEM GLO-30 © DLR e.V. 2010–2014 and © Airbus Defence and Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA.",
+];
+const OSM: [string, string] = [
+ "Buildings",
+ "© OpenStreetMap contributors (ODbL).",
+];
+
+export const SITES: Site[] = [
+ {
+ id: "isafjordur",
+ name: "Ísafjörður",
+ country: "Iceland",
+ spot: "Sólgata, by the church",
+ lat: 66.075075,
+ lon: -23.124175,
+ eye: 2,
+ heading: 20,
+ pitch: 32,
+ fov: 70,
+ dip: 77,
+ decl: -11,
+ curtains: [
+ { base: 170, centre: 250, half: 650 },
+ { base: 420, centre: 500, half: 1400 },
+ ],
+ glow: 0.1,
+ limit: 5.5,
+ ground: "mountains",
+ water: false,
+ buildings: false,
+ lights: 0,
+ credits: [COPERNICUS],
+ zones: ["Atlantic/Reykjavik"],
+ },
+ {
+ id: "helsinki",
+ name: "Helsinki",
+ country: "Finland",
+ spot: "the main pier on Suomenlinna, across the water to the city",
+ lat: 60.1493,
+ lon: 24.9848,
+ eye: 3,
+ heading: -25,
+ pitch: 25,
+ fov: 60,
+ dip: 74,
+ decl: 11,
+ curtains: [
+ { base: 550, centre: -250, half: 1000 },
+ { base: 900, centre: -400, half: 1600 },
+ ],
+ glow: 1,
+ limit: 4.2,
+ ground: "skyline",
+ water: true,
+ buildings: true,
+ lights: 1,
+ credits: [COPERNICUS, OSM],
+ zones: ["Europe/Helsinki", "Europe/Mariehamn"],
+ },
+];
+
+/** Credits that hold for every site. */
+export const SKY_CREDITS: [string, string][] = [
+ ["Stars", "Yale Bright Star Catalogue, 5th revised edition (CDS V/50)."],
+ ["Moon", "NASA's Scientific Visualization Studio."],
+];
+
+const POINTS = [
+ "north",
+ "north-northeast",
+ "northeast",
+ "east-northeast",
+ "east",
+ "east-southeast",
+ "southeast",
+ "south-southeast",
+ "south",
+ "south-southwest",
+ "southwest",
+ "west-southwest",
+ "west",
+ "west-northwest",
+ "northwest",
+ "north-northwest",
+];
+
+/** Heading in degrees as a 16-point compass word, e.g. "north-northeast". */
+export const compass = (deg: number) =>
+ POINTS[Math.round((((deg % 360) + 360) % 360) / 22.5) % 16] ?? "north";
+
+/** Latitude and longitude as "66.0751° N, 23.1242° W". */
+export const coords = (lat: number, lon: number) =>
+ `${Math.abs(lat).toFixed(4)}° ${lat < 0 ? "S" : "N"}, ${Math.abs(lon).toFixed(4)}° ${lon < 0 ? "W" : "E"}`;
diff --git a/apps/site/src/scripts/aurora.sky.ts b/apps/site/src/scripts/aurora.sky.ts
new file mode 100644
index 0000000..17015e8
--- /dev/null
+++ b/apps/site/src/scripts/aurora.sky.ts
@@ -0,0 +1,244 @@
+// What the real sky looks like from a site right now: sidereal time,
+// precession, and the sun and moon. Vectors use the scene's world frame:
+// x east, y up, z north. Angles in radians unless named *Deg.
+// Moon and sun follow Meeus, "Astronomical Algorithms" (2nd ed.), ch. 25 and
+// 47, with the larger periodic terms only (moon good to about 0.05°).
+
+import type { Site } from "./aurora.sites";
+
+export type Mat3 = [
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+ number,
+];
+export type Vec3 = [number, number, number];
+
+const RAD = Math.PI / 180;
+const ARCSEC = RAD / 3600;
+
+const julian = (ms: number) => ms / 86400000 + 2440587.5;
+
+/** Row-major 3×3 product a·b. */
+function mul(a: Mat3, b: Mat3): Mat3 {
+ return Array.from({ length: 9 }, (_, k) => {
+ const i = Math.floor(k / 3) * 3;
+ const j = k % 3;
+ return a[i]! * b[j]! + a[i + 1]! * b[3 + j]! + a[i + 2]! * b[6 + j]!;
+ }) as Mat3;
+}
+
+function apply(m: Mat3, v: Vec3): Vec3 {
+ return [
+ m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
+ m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
+ m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
+ ];
+}
+
+/** Column-major copy for gl.uniformMatrix3fv. */
+export const columns = (m: Mat3) =>
+ new Float32Array([m[0], m[3], m[6], m[1], m[4], m[7], m[2], m[5], m[8]]);
+
+/** J2000 equatorial to mean equator of date (IAU 1976 precession). */
+function precession(t: number): Mat3 {
+ const zeta = (2306.2181 * t + 0.30188 * t * t + 0.017998 * t ** 3) * ARCSEC;
+ const z = (2306.2181 * t + 1.09468 * t * t + 0.018203 * t ** 3) * ARCSEC;
+ const th = (2004.3109 * t - 0.42665 * t * t - 0.041833 * t ** 3) * ARCSEC;
+ const [cz, sz] = [Math.cos(zeta), Math.sin(zeta)];
+ const [cZ, sZ] = [Math.cos(z), Math.sin(z)];
+ const [ct, st] = [Math.cos(th), Math.sin(th)];
+ return [
+ cz * cZ * ct - sz * sZ,
+ -sz * cZ * ct - cz * sZ,
+ -cZ * st,
+ cz * sZ * ct + sz * cZ,
+ -sz * sZ * ct + cz * cZ,
+ -sZ * st,
+ cz * st,
+ -sz * st,
+ ct,
+ ];
+}
+
+/** Equatorial of date to the world frame, for local sidereal time lst. */
+function horizon(lat: number, lst: number): Mat3 {
+ const [cl, sl] = [Math.cos(lst), Math.sin(lst)];
+ const [cp, sp] = [Math.cos(lat), Math.sin(lat)];
+ return [-sl, cl, 0, cp * cl, cp * sl, sp, -sp * cl, -sp * sl, cp];
+}
+
+const ecliptic = (lon: number, lat: number, eps: number): Vec3 => {
+ const [cb, sb] = [Math.cos(lat), Math.sin(lat)];
+ const [cl, sl] = [Math.cos(lon), Math.sin(lon)];
+ const [ce, se] = [Math.cos(eps), Math.sin(eps)];
+ return [cb * cl, cb * sl * ce - sb * se, cb * sl * se + sb * ce];
+};
+
+// Meeus table 47.A: D, M, M', F, longitude (1e-6°), distance (1e-3 km).
+const MOON_LR: number[][] = [
+ [0, 0, 1, 0, 6288774, -20905355],
+ [2, 0, -1, 0, 1274027, -3699111],
+ [2, 0, 0, 0, 658314, -2955968],
+ [0, 0, 2, 0, 213618, -569925],
+ [0, 1, 0, 0, -185116, 48888],
+ [0, 0, 0, 2, -114332, -3149],
+ [2, 0, -2, 0, 58793, 246158],
+ [2, -1, -1, 0, 57066, -152138],
+ [2, 0, 1, 0, 53322, -170733],
+ [2, -1, 0, 0, 45758, -204586],
+ [0, 1, -1, 0, -40923, -129620],
+ [1, 0, 0, 0, -34720, 108743],
+ [0, 1, 1, 0, -30383, 104755],
+ [2, 0, 0, -2, 15327, 10321],
+ [0, 0, 1, 2, -12528, 0],
+ [0, 0, 1, -2, 10980, 79661],
+ [4, 0, -1, 0, 10675, -34782],
+ [0, 0, 3, 0, 10034, -23210],
+ [4, 0, -2, 0, 8548, -21636],
+ [2, 1, -1, 0, -7888, 24208],
+ [2, 1, 0, 0, -6766, 30824],
+ [1, 0, -1, 0, -5163, -8379],
+ [1, 1, 0, 0, 4987, -16675],
+ [2, -1, 1, 0, 4036, -12831],
+ [2, 0, 2, 0, 3994, -10445],
+ [4, 0, 0, 0, 3861, -11650],
+ [2, 0, -3, 0, 3665, 14403],
+ [0, 1, -2, 0, -2689, -7003],
+ [2, 0, -1, 2, -2602, 0],
+ [2, -1, -2, 0, 2390, 10056],
+ [1, 0, 1, 0, -2348, 6322],
+ [2, -2, 0, 0, 2236, -9884],
+];
+// Meeus table 47.B: D, M, M', F, latitude (1e-6°).
+const MOON_B: number[][] = [
+ [0, 0, 0, 1, 5128122],
+ [0, 0, 1, 1, 280602],
+ [0, 0, 1, -1, 277693],
+ [2, 0, 0, -1, 173237],
+ [2, 0, -1, 1, 55413],
+ [2, 0, -1, -1, 46271],
+ [2, 0, 0, 1, 32573],
+ [0, 0, 2, 1, 17198],
+ [2, 0, 1, -1, 9266],
+ [0, 0, 2, -1, 8822],
+ [2, -1, 0, -1, 8216],
+ [2, 0, -2, -1, 4324],
+ [2, 0, 1, 1, 4200],
+ [2, 1, 0, -1, -3359],
+ [2, -1, -1, 1, 2463],
+ [2, -1, 0, 1, 2211],
+ [2, -1, -1, -1, 2065],
+];
+
+/** Geocentric ecliptic longitude, latitude (radians) and distance (km). */
+export function moonEcliptic(t: number): [number, number, number] {
+ const Lp = 218.3164477 + 481267.88123421 * t;
+ const D = (297.8501921 + 445267.1114034 * t) * RAD;
+ const M = (357.5291092 + 35999.0502909 * t) * RAD;
+ const Mp = (134.9633964 + 477198.8675055 * t) * RAD;
+ const F = (93.272095 + 483202.0175233 * t) * RAD;
+ const A1 = (119.75 + 131.849 * t) * RAD;
+ const A2 = (53.09 + 479264.29 * t) * RAD;
+ const A3 = (313.45 + 481266.484 * t) * RAD;
+ const E = 1 - 0.002516 * t;
+ let sl = 0;
+ let sr = 0;
+ let sb = 0;
+ for (const [d, m, mp, f, l, r] of MOON_LR) {
+ const arg = d! * D + m! * M + mp! * Mp + f! * F;
+ const e = E ** Math.abs(m!);
+ sl += l! * e * Math.sin(arg);
+ sr += r! * e * Math.cos(arg);
+ }
+ for (const [d, m, mp, f, b] of MOON_B) {
+ sb +=
+ b! * E ** Math.abs(m!) * Math.sin(d! * D + m! * M + mp! * Mp + f! * F);
+ }
+ const L = Lp * RAD;
+ sl += 3958 * Math.sin(A1) + 1962 * Math.sin(L - F) + 318 * Math.sin(A2);
+ sb +=
+ -2235 * Math.sin(L) +
+ 382 * Math.sin(A3) +
+ 175 * Math.sin(A1 - F) +
+ 175 * Math.sin(A1 + F) +
+ 127 * Math.sin(L - Mp) -
+ 115 * Math.sin(L + Mp);
+ return [(Lp + sl / 1e6) * RAD, (sb / 1e6) * RAD, 385000.56 + sr / 1000];
+}
+
+/** Geocentric ecliptic longitude of the sun (radians). */
+function sunLongitude(t: number): number {
+ const L0 = 280.46646 + 36000.76983 * t;
+ const M = (357.52911 + 35999.05029 * t) * RAD;
+ const C =
+ (1.914602 - 0.004817 * t) * Math.sin(M) +
+ (0.019993 - 0.000101 * t) * Math.sin(2 * M) +
+ 0.000289 * Math.sin(3 * M);
+ return (L0 + C) * RAD;
+}
+
+const normalize = (v: Vec3): Vec3 => {
+ const l = Math.hypot(v[0], v[1], v[2]);
+ return [v[0] / l, v[1] / l, v[2] / l];
+};
+
+export type Sky = {
+ /** J2000 equatorial to world, for the star catalogue. */
+ stars: Mat3;
+ /** Unit vector to the moon (topocentric) and its angular radius. */
+ moon: Vec3;
+ moonRadius: number;
+ /** Lunar north (ecliptic pole is within 1.5° of it). */
+ moonNorth: Vec3;
+ sun: Vec3;
+ /** 0..1: how much the moon lights the sky (phase and altitude). */
+ moonlight: number;
+};
+
+export function sky(site: Site, ms: number): Sky {
+ const jd = julian(ms);
+ const t = (jd - 2451545) / 36525;
+ const gmst =
+ (280.46061837 + 360.98564736629 * (jd - 2451545) + 0.000387933 * t * t) *
+ RAD;
+ const lst = gmst + site.lon * RAD;
+ const lat = site.lat * RAD;
+ const hor = horizon(lat, lst);
+ const eps = (23.4392911 - 0.0130042 * t) * RAD;
+
+ // Topocentric moon: subtract the observer's position from the geocentric one.
+ const [ml, mb, dist] = moonEcliptic(t);
+ const geo = ecliptic(ml, mb, eps).map((c) => c * dist) as Vec3;
+ const u = Math.atan(0.99664719 * Math.tan(lat));
+ const rc = 6378.14 * Math.cos(u);
+ const rs = 6378.14 * 0.99664719 * Math.sin(u);
+ const topo: Vec3 = [
+ geo[0] - rc * Math.cos(lst),
+ geo[1] - rc * Math.sin(lst),
+ geo[2] - rs,
+ ];
+ const range = Math.hypot(...topo);
+ const moon = normalize(apply(hor, topo));
+ const sun = normalize(apply(hor, ecliptic(sunLongitude(t), 0, eps)));
+ const moonNorth = normalize(apply(hor, [0, -Math.sin(eps), Math.cos(eps)]));
+
+ // Illuminated fraction from the sun–moon angle, faded by moon altitude.
+ const lit =
+ (1 - (sun[0] * moon[0] + sun[1] * moon[1] + sun[2] * moon[2])) / 2;
+ const alt = Math.asin(moon[1]) / RAD;
+ const up = Math.min(1, Math.max(0, (alt + 2) / 17));
+ return {
+ stars: mul(hor, precession(t)),
+ moon,
+ moonRadius: Math.asin(1737.4 / range),
+ moonNorth,
+ sun,
+ moonlight: lit ** 1.5 * up * up * (3 - 2 * up),
+ };
+}
diff --git a/apps/site/src/scripts/aurora.worker.ts b/apps/site/src/scripts/aurora.worker.ts
new file mode 100644
index 0000000..c28ee98
--- /dev/null
+++ b/apps/site/src/scripts/aurora.worker.ts
@@ -0,0 +1,567 @@
+// Aurora background: the real night sky over Ísafjörður, in a Web Worker on
+// an OffscreenCanvas so the page's main thread does no WebGL work at all.
+// Passes per frame:
+// 1. Footprint: each curtain's ground track and brightness into a small
+// texture (see aurora.shaders.ts).
+// 2. Sky: a fullscreen march through the aurora in perspective, plus the moon.
+// 3. Stars from the Bright Star Catalogue, then the sparks, added on top.
+// 4. The mountain skyline, drawn last so it hides everything behind it.
+// The page sends the theme token values, sizes and visibility; we answer "on"
+// once a frame is up and "off" if we give up.
+
+import {
+ FOOT_FS,
+ MAP_H,
+ MAP_W,
+ POINT_FS,
+ QUAD_VS,
+ RIDGE_FS,
+ RIDGE_VS,
+ SPARK_VS,
+ STAR_VS,
+ skyFs,
+} from "./aurora.shaders";
+import { getSystemTimeZone, getUnixNow } from "@northguild/gmt";
+import { MOON } from "./aurora.moon";
+import { SITES } from "./aurora.sites";
+import { columns, sky, type Sky } from "./aurora.sky";
+import { SKYLINE as HELSINKI } from "./skylines/helsinki";
+import { SKYLINE as ISAFJORDUR } from "./skylines/isafjordur";
+
+const FRAME_MS = 1000 / 30;
+const BASE_SCALE = 0.6;
+const MAX_EDGE = 1280;
+const SPARKS = 4000;
+const RAD = Math.PI / 180;
+
+// Wall-clock epoch milliseconds from Temporal (via gmt): the stars and moon
+// need the real time. Frame timing uses rAF stamps, not the wall clock.
+let wallMs = 0;
+const wall = () => (wallMs = getUnixNow() ?? wallMs);
+
+type Rgb = [number, number, number];
+type Skyline = typeof ISAFJORDUR;
+
+// Skylines by site id (see scripts/build-skyline.mjs).
+const SKYLINES: Record
= {
+ isafjordur: ISAFJORDUR,
+ helsinki: HELSINKI,
+};
+
+export type AuroraMessage =
+ | {
+ type: "init";
+ canvas: OffscreenCanvas;
+ width: number;
+ height: number;
+ colours: string[];
+ /** Index into SITES, or -1 to pick from the browser's time zone. */
+ site: number;
+ still: boolean;
+ hidden: boolean;
+ }
+ | { type: "site"; site: number }
+ | { type: "size"; width: number; height: number }
+ | { type: "still"; still: boolean }
+ | { type: "hidden"; hidden: boolean };
+
+/** To the page: the frame is up ("on"), we gave up ("off"), or the site drawn. */
+export type WorkerMessage = "on" | "off" | { site: number };
+
+const scope = self as unknown as {
+ onmessage: ((event: MessageEvent) => void) | null;
+ postMessage(message: WorkerMessage): void;
+};
+
+/** The site for this browser's time zone (via gmt), else one at random. */
+function siteForTimeZone(): number {
+ const zone = getSystemTimeZone();
+ const match = SITES.findIndex((s) => s.zones.includes(zone));
+ return match >= 0 ? match : Math.floor(Math.random() * SITES.length);
+}
+
+const nextFrame =
+ "requestAnimationFrame" in self
+ ? requestAnimationFrame
+ : (cb: FrameRequestCallback) =>
+ setTimeout(() => cb(performance.now()), FRAME_MS);
+const cancelFrame =
+ "cancelAnimationFrame" in self ? cancelAnimationFrame : clearTimeout;
+
+function parseColours(values: string[]): Rgb[] {
+ const ctx = new OffscreenCanvas(1, 1).getContext("2d", {
+ willReadFrequently: true,
+ });
+ return values.map((value) => {
+ if (!ctx) return [0, 0, 0];
+ ctx.clearRect(0, 0, 1, 1);
+ ctx.fillStyle = value;
+ ctx.fillRect(0, 0, 1, 1);
+ const [r = 0, g = 0, b = 0] = ctx.getImageData(0, 0, 1, 1).data;
+ return [r / 255, g / 255, b / 255];
+ });
+}
+
+function compile(
+ gl: WebGLRenderingContext,
+ vs: string,
+ fs: string,
+): WebGLProgram | null {
+ const program = gl.createProgram();
+ for (const [type, src] of [
+ [gl.VERTEX_SHADER, vs],
+ [gl.FRAGMENT_SHADER, fs],
+ ] as const) {
+ const shader = gl.createShader(type);
+ if (!shader) return null;
+ gl.shaderSource(shader, src);
+ gl.compileShader(shader);
+ gl.attachShader(program, shader);
+ }
+ gl.linkProgram(program);
+ if (gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
+ if (import.meta.env.DEV) {
+ for (const shader of gl.getAttachedShaders(program) ?? []) {
+ console.warn("aurora:", gl.getShaderInfoLog(shader));
+ }
+ }
+ return null;
+}
+
+const bytes = (b64: string) =>
+ Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
+
+/** Ridge strip: for each azimuth, the ridge direction, then the far shore
+ * over water or a direction far below the frame. */
+function ridgeMesh(skyline: Skyline): Float32Array {
+ const elev = new Int16Array(bytes(skyline.elev).buffer);
+ const dist = new Uint16Array(bytes(skyline.dist).buffer);
+ const base = new Int16Array(bytes(skyline.base).buffer);
+ const out = new Float32Array(elev.length * 8);
+ elev.forEach((e, i) => {
+ const az = (skyline.from + i * skyline.step) * RAD;
+ // 0 within 1 km of the viewer, rising to 1 at 31 km: paler, and lit if a city.
+ const haze = Math.min(1, Math.max(0, ((dist[i] ?? 0) / 100 - 1) / 30));
+ for (const [j, el] of [
+ [0, (e / 100) * RAD],
+ [1, ((base[i] ?? -4000) / 100) * RAD],
+ ] as const) {
+ out.set(
+ [
+ Math.sin(az) * Math.cos(el),
+ Math.sin(el),
+ Math.cos(az) * Math.cos(el),
+ j ? -1 - haze : haze,
+ ],
+ i * 8 + j * 4,
+ );
+ }
+ });
+ return out;
+}
+
+/** Catalogue records (see scripts/build-stars.mjs) to direction + magnitude. */
+function starData(buf: ArrayBuffer): Float32Array {
+ const view = new DataView(buf);
+ const n = Math.floor(buf.byteLength / 6);
+ const out = new Float32Array(n * 5);
+ for (let i = 0; i < n; i++) {
+ const ra = (view.getUint16(i * 6, true) / 65536) * 2 * Math.PI;
+ const dec = (view.getInt16(i * 6 + 2, true) / 32767) * (Math.PI / 2);
+ out.set(
+ [
+ Math.cos(dec) * Math.cos(ra),
+ Math.cos(dec) * Math.sin(ra),
+ Math.sin(dec),
+ view.getUint8(i * 6 + 4) / 20 - 2,
+ view.getInt8(i * 6 + 5) / 50,
+ ],
+ i * 5,
+ );
+ }
+ return out;
+}
+
+function start(init: Extract): void {
+ const { canvas } = init;
+ const gl = canvas.getContext("webgl", {
+ alpha: false,
+ antialias: false,
+ depth: false,
+ stencil: false,
+ powerPreference: "low-power",
+ });
+ if (!gl || gl.getParameter(gl.MAX_TEXTURE_SIZE) < MAP_W) return;
+ const foot = compile(gl, QUAD_VS, FOOT_FS);
+ let skyProgram = compile(gl, QUAD_VS, skyFs(20, 320));
+ const sparks = compile(gl, SPARK_VS, POINT_FS);
+ const stars = compile(gl, STAR_VS, POINT_FS);
+ const ridge = compile(gl, RIDGE_VS, RIDGE_FS);
+ if (!foot || !skyProgram || !sparks || !stars || !ridge) return;
+
+ const [voidC, spring, cyan, teal, purple, ice] = parseColours(init.colours);
+ const colours: Record = {
+ uVoid: voidC,
+ uSpring: spring,
+ uCyan: cyan,
+ uTeal: teal,
+ uPurple: purple,
+ uIce: ice,
+ };
+ // Footprint texture and framebuffer on unit 0.
+ const map = gl.createTexture();
+ gl.activeTexture(gl.TEXTURE0);
+ gl.bindTexture(gl.TEXTURE_2D, map);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.RGBA,
+ MAP_W,
+ MAP_H,
+ 0,
+ gl.RGBA,
+ gl.UNSIGNED_BYTE,
+ null,
+ );
+ const params = (filter: number, wrapS: number) => {
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, filter);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, filter);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, wrapS);
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
+ };
+ params(gl.NEAREST, gl.CLAMP_TO_EDGE);
+ const fbo = gl.createFramebuffer();
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.framebufferTexture2D(
+ gl.FRAMEBUFFER,
+ gl.COLOR_ATTACHMENT0,
+ gl.TEXTURE_2D,
+ map,
+ 0,
+ );
+ const complete =
+ gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ if (!complete) return;
+
+ // Moon albedo on unit 1 (power-of-two, so it may repeat in longitude).
+ gl.activeTexture(gl.TEXTURE1);
+ gl.bindTexture(gl.TEXTURE_2D, gl.createTexture());
+ gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1);
+ gl.texImage2D(
+ gl.TEXTURE_2D,
+ 0,
+ gl.LUMINANCE,
+ MOON.width,
+ MOON.height,
+ 0,
+ gl.LUMINANCE,
+ gl.UNSIGNED_BYTE,
+ bytes(MOON.data),
+ );
+ params(gl.LINEAR, gl.REPEAT);
+ gl.activeTexture(gl.TEXTURE0);
+
+ const buffer = (data: Float32Array) => {
+ const b = gl.createBuffer();
+ gl.bindBuffer(gl.ARRAY_BUFFER, b);
+ gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
+ return b;
+ };
+ const tri = buffer(new Float32Array([-1, -1, 3, -1, -1, 3]));
+ const seeds = buffer(Float32Array.from({ length: SPARKS * 4 }, Math.random));
+ const ridgeBuf = gl.createBuffer();
+ let ridgeCount = 0;
+ let starBuf: WebGLBuffer | null = null;
+ let starCount = 0;
+ let starsAt = 0;
+
+ // Uniforms that never change: colours and samplers.
+ const setup = (program: WebGLProgram) => {
+ gl.useProgram(program);
+ for (const [name, rgb] of Object.entries(colours)) {
+ const loc = gl.getUniformLocation(program, name);
+ if (loc && rgb) gl.uniform3fv(loc, rgb);
+ }
+ gl.uniform1i(gl.getUniformLocation(program, "uMap"), 0);
+ gl.uniform1i(gl.getUniformLocation(program, "uMoonTex"), 1);
+ };
+ for (const p of [foot, skyProgram, sparks, stars, ridge]) setup(p);
+ const at = (program: WebGLProgram, name: string) =>
+ gl.getUniformLocation(program, name);
+
+ const attrib = (
+ program: WebGLProgram,
+ name: string,
+ buf: WebGLBuffer | null,
+ size: number,
+ stride = 0,
+ offset = 0,
+ ) => {
+ const a = gl.getAttribLocation(program, name);
+ gl.bindBuffer(gl.ARRAY_BUFFER, buf);
+ gl.enableVertexAttribArray(a);
+ gl.vertexAttribPointer(a, size, gl.FLOAT, false, stride, offset);
+ return a;
+ };
+
+ // Everything that depends on where the viewer stands.
+ let site = SITES[0]!;
+ let tilt = [0, 0];
+ let cam = new Float32Array(9);
+ let tanY = 1;
+ // Camera basis as columns (right, up, forward), heading from north. On a
+ // portrait screen the page's cards cover the middle, so the view widens and
+ // tilts until the horizon sits about a third of the way down: the aurora then
+ // shows above the cards and the land or water below them.
+ const aim = () => {
+ const aspect = (width || 1) / (height || 1);
+ const portrait = Math.min(1, Math.max(0, (1 - aspect) / 0.45));
+ const fov = (site.fov + 15 * portrait) * RAD;
+ tanY = Math.tan(fov / 2);
+ const landscape =
+ -Math.tan(site.pitch * RAD) / Math.tan((site.fov / 2) * RAD);
+ const horizon = landscape + (0.3 - landscape) * portrait;
+ const pitch = Math.atan(-horizon * tanY);
+ const yaw = site.heading * RAD;
+ const fwd = [
+ Math.sin(yaw) * Math.cos(pitch),
+ Math.sin(pitch),
+ Math.cos(yaw) * Math.cos(pitch),
+ ] as const;
+ const right = [Math.cos(yaw), 0, -Math.sin(yaw)] as const;
+ cam = new Float32Array([
+ ...right,
+ fwd[1] * right[2] - fwd[2] * right[1],
+ fwd[2] * right[0] - fwd[0] * right[2],
+ fwd[0] * right[1] - fwd[1] * right[0],
+ ...fwd,
+ ]);
+ };
+ const place = (index: number) => {
+ site = SITES[index] ?? SITES[0]!;
+ // Field lines climb toward magnetic south at the site's dip.
+ const south = (180 + site.decl) * RAD;
+ const lean = 1 / Math.tan(site.dip * RAD);
+ tilt = [lean * Math.sin(south), lean * Math.cos(south)];
+ aim();
+ const skyline = SKYLINES[site.id];
+ const mesh = skyline ? ridgeMesh(skyline) : new Float32Array(0);
+ gl.bindBuffer(gl.ARRAY_BUFFER, ridgeBuf);
+ gl.bufferData(gl.ARRAY_BUFFER, mesh, gl.STATIC_DRAW);
+ ridgeCount = mesh.length / 4;
+ };
+ let width = init.width;
+ let height = init.height;
+ place(init.site < 0 ? siteForTimeZone() : init.site);
+ scope.postMessage({ site: SITES.indexOf(site) });
+
+ // Quality levels: 0 full, 1 lower resolution and fewer sky steps, 2 also
+ // half the sparks, 3 stopped.
+ let level = 0;
+ let sparkCount = SPARKS;
+ let still = init.still;
+ let hidden = init.hidden;
+ let t = 120;
+ let raf = 0;
+ let last = 0;
+ let samples = 0;
+ let total = 0;
+ let tuning = true;
+ let skyAt = wall();
+ let now: Sky = sky(site, skyAt);
+
+ const resize = () => {
+ const w = width || 1;
+ const h = height || 1;
+ const scale =
+ Math.min(BASE_SCALE, MAX_EDGE / Math.max(w, h)) * (level ? 0.7 : 1);
+ canvas.width = Math.round(w * scale);
+ canvas.height = Math.round(h * scale);
+ aim();
+ };
+
+ const quad = (program: WebGLProgram) => {
+ const a = attrib(program, "aPos", tri, 2);
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
+ gl.disableVertexAttribArray(a);
+ };
+
+ const draw = () => {
+ // The real sky turns slowly; refresh it once a second.
+ if (wall() - skyAt > 1000) {
+ skyAt = wallMs;
+ now = sky(site, skyAt);
+ }
+ const tan = [tanY * (canvas.width / canvas.height), tanY];
+ const px = Math.max(1, canvas.height / 540);
+ const view = (program: WebGLProgram) => {
+ gl.useProgram(program);
+ gl.uniformMatrix3fv(at(program, "uCam"), false, cam);
+ gl.uniform2fv(at(program, "uTan"), tan);
+ gl.uniform1f(at(program, "uT"), t);
+ gl.uniform1f(at(program, "uPx"), px);
+ gl.uniform1f(at(program, "uMoonlight"), now.moonlight);
+ gl.uniform2fv(at(program, "uTilt"), tilt);
+ gl.uniform1f(at(program, "uGlow"), site.glow);
+ gl.uniform1f(at(program, "uLights"), site.lights);
+ for (const [name, c] of [
+ ["uC0", site.curtains[0]],
+ ["uC1", site.curtains[1]],
+ ] as const) {
+ gl.uniform3f(at(program, name), c.base, c.centre, c.half);
+ }
+ };
+
+ // 1. Footprint.
+ gl.disable(gl.BLEND);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, fbo);
+ gl.viewport(0, 0, MAP_W, MAP_H);
+ view(foot);
+ quad(foot);
+ gl.bindFramebuffer(gl.FRAMEBUFFER, null);
+ gl.viewport(0, 0, canvas.width, canvas.height);
+
+ // 2. Sky and moon.
+ const sp = skyProgram;
+ if (!sp) return;
+ view(sp);
+ gl.uniform1f(at(sp, "uPxAng"), (2 * tanY) / canvas.height);
+ gl.uniform4f(at(sp, "uMoon"), ...now.moon, now.moonRadius);
+ gl.uniform3fv(at(sp, "uMoonN"), now.moonNorth);
+ gl.uniform3fv(at(sp, "uSun"), now.sun);
+ quad(sp);
+
+ // 3. Stars and sparks, added on top.
+ gl.enable(gl.BLEND);
+ gl.blendFunc(gl.ONE, gl.ONE);
+ if (starBuf && starCount) {
+ view(stars);
+ gl.uniformMatrix3fv(at(stars, "uStars"), false, columns(now.stars));
+ gl.uniform1f(at(stars, "uLimit"), site.limit - 2 * now.moonlight);
+ gl.uniform1f(
+ at(stars, "uFade"),
+ Math.min(1, (performance.now() - starsAt) / 1500),
+ );
+ const a = attrib(stars, "aDir", starBuf, 3, 20, 0);
+ const b = attrib(stars, "aMag", starBuf, 2, 20, 12);
+ gl.drawArrays(gl.POINTS, 0, starCount);
+ gl.disableVertexAttribArray(a);
+ gl.disableVertexAttribArray(b);
+ }
+ view(sparks);
+ const s = attrib(sparks, "aSeed", seeds, 4);
+ gl.drawArrays(gl.POINTS, 0, sparkCount);
+ gl.disableVertexAttribArray(s);
+
+ // 4. Mountains over everything.
+ gl.disable(gl.BLEND);
+ view(ridge);
+ const r = attrib(ridge, "aRidge", ridgeBuf, 4);
+ gl.drawArrays(gl.TRIANGLE_STRIP, 0, ridgeCount);
+ gl.disableVertexAttribArray(r);
+ };
+
+ const stop = () => {
+ cancelFrame(raf);
+ raf = 0;
+ };
+
+ const giveUp = () => {
+ stop();
+ level = 3;
+ scope.postMessage("off");
+ };
+
+ // If frames run long, step quality down; past the last level give up and
+ // leave the page's CSS gradient showing.
+ const adapt = (elapsed: number) => {
+ if (!tuning || elapsed > 250) return;
+ total += elapsed;
+ if (++samples < 45) return;
+ const slow = total / samples > 45;
+ samples = 0;
+ total = 0;
+ if (!slow) {
+ tuning = false;
+ } else if (++level === 1) {
+ const lighter = compile(gl, QUAD_VS, skyFs(12, 260));
+ if (lighter) {
+ setup(lighter);
+ skyProgram = lighter;
+ }
+ resize();
+ } else if (level === 2) {
+ sparkCount = SPARKS / 2;
+ } else {
+ giveUp();
+ }
+ };
+
+ const frame = (stamp: number) => {
+ raf = nextFrame(frame);
+ const elapsed = stamp - last;
+ if (elapsed < FRAME_MS - 2) return;
+ if (last) {
+ t += Math.min(elapsed, 100) / 1000;
+ adapt(elapsed);
+ }
+ last = stamp;
+ draw();
+ };
+
+ const run = () => {
+ if (raf || level > 2 || still || hidden) return;
+ last = 0;
+ raf = nextFrame(frame);
+ };
+
+ canvas.addEventListener("webglcontextlost", giveUp);
+ resize();
+ draw();
+ scope.postMessage("on");
+ run();
+
+ // Stars arrive after the first frame so they never delay it. Without them
+ // the aurora still runs.
+ fetch(new URL("/stars.bin", self.location.origin))
+ .then((res) => (res.ok ? res.arrayBuffer() : Promise.reject(res.status)))
+ .then((buf) => {
+ if (level > 2) return;
+ const data = starData(buf);
+ starBuf = buffer(data);
+ starCount = data.length / 5;
+ starsAt = still ? -Infinity : performance.now();
+ if (!raf) draw();
+ })
+ .catch(() => {});
+
+ scope.onmessage = ({ data }) => {
+ if (level > 2) return;
+ if (data.type === "site") {
+ place(data.site);
+ skyAt = wall();
+ now = sky(site, skyAt);
+ draw();
+ scope.postMessage("on");
+ } else if (data.type === "size") {
+ width = data.width;
+ height = data.height;
+ resize();
+ draw();
+ } else if (data.type === "still") {
+ still = data.still;
+ stop();
+ draw();
+ run();
+ } else if (data.type === "hidden") {
+ hidden = data.hidden;
+ if (hidden) stop();
+ else run();
+ }
+ };
+}
+
+scope.onmessage = ({ data }) => {
+ if (data.type === "init") start(data);
+};
diff --git a/apps/site/src/scripts/skylines/helsinki.ts b/apps/site/src/scripts/skylines/helsinki.ts
new file mode 100644
index 0000000..636c461
--- /dev/null
+++ b/apps/site/src/scripts/skylines/helsinki.ts
@@ -0,0 +1,12 @@
+// Generated by scripts/build-skyline.mjs. Do not edit.
+// Helsinki: 60.1493 N, 24.9848 E, eye 3 m above ground (3.0 m).
+// elev: Int16 little-endian, hundredths of a degree above the horizontal.
+// dist: Uint16 little-endian, tens of metres to the ridge.
+// base: Int16, hundredths of a degree: the far shore over water, or -40°.
+export const SKYLINE = {
+ from: -100,
+ step: 0.05,
+ elev: "DwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEPAQ8BDwEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABEAEQARABPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgE+AT4BPgFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFYASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAFEAUQBRAEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhASEBIQEhAWoAaQBpAGkAaQBpAGkAaQBpAGkAaQBpAGkAaQBpAGkAaQBpAGkAaQBpAGkAaQBlAGUAZQBlAGUAZQBlAGUAZQBlAGUAZQBlAGQAZABkAGQAZABkAGQAZABkAGQAZABkAGQAZABkAGQAZABkAGQAZABkAGIAYgBiAHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAugDiAMEAoADHAL4AlwBvAG8AbwBvAG8AbwBvAG8AbwBgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAF8AXwBfAF8AXwBfAF8AXwBfAF8AXwBfAF8AXwBbAFsAWwBbAFsAWwBbAFsAZQBfAF8AXwBfAF8AXwBtAG0AbQBtAG0AbQBtAG0AbQBtAG0AbQBtAG0AbQBtAG0AbQBtAGAAYABgAGAAYABgAGAAYABtAG0AbQBtAG0AbQBtAG0AbgBuAG4AbgBuAG4AbgBtAG0AbQBtAG0AbQBtAG0AbQBtAG0AbQBuAG4AbgBuAG4AbgBuAG4AbgBuAG4AfgCDAKkAiQCJAIkAiQCJAG4AXwBfAF8AXwBfAF8AXwBfAF8AXwBfAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABbAFsAWwBbAFsAWwBbAFsAWwBbAFsAWwBbAFsAWwBbAFsAWwBbAFkAVQBVAFUAVQBVAFQAVABUAFQAVABUAFQARwBHAEcARwBHAEsAVwBXAFcAVwBXAFcAbQBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBjAGMAYwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBXAFcAVwBTAFMAUwBTAFMAUwBTAFMAUwBOAE4ASABIAE4AVwBfAFoAUQBIAEgASABIAEIATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAEUARQBFAEUARQBFAEUARQBUAFQARQBDAEMAQwBDAEsASwBLAEsASwBLAEsASwBLAEsASwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDADwAPAA8AEcARwBHAEcARwBHAEcARwBHAEcARwBHAEcARwBHAEgARwBGAEIAQABAAEAAQABAAEAAQABAAEAAQAA8ADwAPABfADwAPAA8ADwASQBWAGAAVABNAE0AcwB+AIEAlQCAAHwAbwBVAFUAYQBsAF0ATABKAEkAOwA7ADsAOwA7ADsAOwA7ADsAOwBAADsAOwA7ADsAOwA7ADsAOwA7ADsAMwAzAC0ALQAzADMAOAA4ADgAOAA4ADgAOAA4ADgAOAA4AEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEQARABEAEQARABEAEQARABEAEQARABEAEQASgBKAEoASgBVAF0AcQBbAFMASgBLAEoASgBKAEoASgBKAEoASgBKAEoASgBKAEoATgBOAE4ATgBOAE4ATgBOAE4ATgBOAE4ATgBOAE0ATQBNAE0ATQBNAE0AVgBWAFYAVgBWAFYAVgBWAFYAVgBWAFAAUABQAFAATQBNAE0ATQBNAE0AUwBTAFMAUwBtAFMAUwBTAFEAUQBRAFEAgwBRAGwAZgBmAGYAZgBYAFgAWABYAFgAWABYAFgAYABgAGAAYABgAFIAUgBSAFIAUgBSAFIAUgBSAFIAUgBSAFIAWABYAFgAWABYAFgAWABYAFgAWABYAFgAeACCAIYAggBfAF8AXwBfAF8AXwBfAF8AXwBfAF8AXwBfAFIAUgBSAFIAUgBSAFIAUgBSAFIAUgBSAFIAUgBSAEcARwBLAEsASwBLAEsASwBLAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAE0ATQBNAEkASQBKAEoASgBQAFAAUABQAFAAUABpAGkAaQBpAGkAaQBpAGkAaQBpAFAAUABLAEsASwBCAEIAQQBBAEEAQQBBAEIAQgBCAEcARwBcAFwAXABcAFwAXABcAFwAXABcAGIAYgBpAGkAYgBiAGIAYgBhAGEAYQBhAGEAYQBhAGEAYQBhAEQARgBJAEkASQBJAEkASQBJAFsAWwBbAFsAWwBbAFsAWwBbAFEAUQBRAFEAUQBRAFEAUQBRAEUARQBFAEUARQBFAEYARgBGAEYARgBGAEYARgBGAEMAQQBBAEEAQQBBAEEAQQBBADwAPABAAEAAQABAAEAAQABAAEYARgBGAEYARgBGAEYAQgBCAEIARQBFAEUARQBFAEUARQBFAEAAQABAADYAQwBDAEMAQwBDAEMAQwA2ADYANgA2ADUANQA1ADYANgA2ADYANgA2ADYANgA2ADsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBLAEsASwBCAEIAQgBCAEIAQgBCAEIAOgA6AEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAWABYAJAAWABYAFgAOQA5ADkAOQA5ADkAOQA5AOMA4wDjAOMAOQA5ADkAOQA5ADkAOQAyADIAMgAzADMAMwAzADEAMQA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+ACsAKwArACsAKwArACsAKwArAE4ATgBOAE4ATgBOAE4ATgBOAE4AOAAsACwALAAsACwALAAsAD0APQA9AD0APQA9AD0APQA9ADYANgBBAEEAQQBBAEEAQQBEAEQARABEAEQARABEAEMAQwBDAEMAQwBDAEIAQgBCAEIAQgAzADMAKQCDAIMAgwCDAIMAgwCDAIMAkgCSAJIAkgCSAJIAkgCSAJIALQAtAC0AegB6AKEAoQChAKEAoQChAKEAKQArACkAKQC0ALQAtAC0ALQAtAC0AIsAiwCLAC4ALgAsACwALAAsAH4ApQClAKUApQClAKUApQClAH0ANgAsADEAKwA0ADgANAA0ADgAKwArADMAMwA0ADcANwA8ADwAPAA3ADcAOAA4ADoASAA4ADgAOAA4ADgAOAA4ADgAOAA4ADYANgA2ADoAOgA6ADoAOQA2ADYANQAqACoALwAvAC8ALwAuAC4ALgAuADkAOQA5ADkAOQA5ADkANwA3ACgAKAAoADgAOAArADAAMAAwACoAKgAqACoAOQAtADgAOAA4ADgAOAA4ADgAMwAzADMAMwAzADMALQAtAC0ALQArACsAKwArADAAMAAwACsAKwAsACwALAAsACwALAAsACwAKwAsACwALAArACsAKwArACsAKgAnACcAJwAnACcAJwAkACQAJAAfACQAJwAtAC0ALQAtACcAJwAnACcAIgAiAB8AHwAcAB0AHQAdAB0AHQAdAB0AHQAdAB0AHQAfAB8AHwApACkAHwAfAB8AHQAdACYAJgAmACYAHAAmACgAKAAoABsAHAAcABoAGgAaAB0AHQAdABwAHAAaABoAGgAaACEAIQAhABsAGwAbABsAGwAbACIAJQAlACUAIQAhACEAHwAhACQAJAAkACIAIAAZACQAJAAkACQAJAAkACMAGwAbABsAGgApACkAGwAcABwAHAAcABwAHQAdAB0AHQAdACAAIAAgACAAIAAgAB4AHgAeAB4AJQAlACUAJQAkACQAJAAkAB4AHgAeAB0AJAAkAB8AIgAiACIAIgAdAB4AHwAfAB8AHwAfAB8AGQAZABkAFwAeAB4AHgAcABwAHAAYABYAFgAWABUAGwAZABkAGQAXABYAFAAcABwAFwAXABoAGgAXABgAGQAZABkAGAAbABsAGwAbACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAoACgAKAAgACQAJAAkACYAJgArACsAKwArACsAKwArACsAKwAuAC4ALgAuAC4ALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8ALwArACsAKwArACsALAAsACwALAAsACwALAAsACwALAAsACwALQAtAC0ALQAtAC0ALQAnACwALAAsACwALAAsAC8ALwAvAC8ALwAvAC8ALwAuAC4ALgAuAC4ALgAuAC4ALgAuAAEBAQEuAC4ALgAwADAAMAAwADAAMAAuAC4ALgAuAC4ALQAtAC0ALgAuAC4ALgAuAC8ALwAxADEALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8AMQAyADIAMgAxAC4ALgAxADEAMQAxADEALgAuACgALAAvAC8ALwAvAC8ALwAvAC0ALQAsACwALAAsACkAJwAdAB0AJwAnACcAJwAnACIAIgAmACYAJgAmACYAJAAiACUAJQAlACUAKABhAGEAYQBhAGEAYQBhAGEAYQBhAGEAYQBhAGEAYQBhAGEAYQBhAGEAYQAnACcAJgAoACkAKgArACwALQAvADAAMQAyADMANAA1ADYANwA3ADgAOAA6ADsAPAA9AD4APQA9AD0APQA9AD0APgA+AD8APwA/AEAAQAA/AD4APgA9ADwAOwBPAE8ATwCFAIUAhQCFAIUAhQCFAIUAhQCFAIUAhQCFAIUAhQCFAIUAhQCFADAAMAAwAC8ALwAuAC0ALQA4ADgAOAA4ADgAOAAqACoAKgA7ADsAOwA7ADsAOAA4ACUAJQAoAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAIEAgQCBAHoAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbABsAGwAbAA9AD0ALAAsAC0ALQAuAC8ALwAwADAAMAAxADEAMgAyADMAMwAzADMAMwAyADIAMgAxADEAMQAxADEAMQAxADIAMgAyADIAMgAyADEAMQAwADAAMAAvAC8ALwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzACsAKwAwADAAMAAwADAAMAAwADAAMAAwADAAMAAwADAAMAAuAC4ALgAuAC4ALgAuAC4ALgAuAC4ALgA/AD8APwA/AD8APwA/AD8APwBGAEYARgBGAEYARgBGAEYARgBGAEYAOQA5ADkAOQA5ADkAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADoAOgA6ADoAOgA6ADoAOgA6ADoASwBLAEsASwBLAE4ATgBOAE4ATgBOAE4ATgBOAE4ASABIAEgASABIAEgASABIAEgASABIAEgATQBNAE0ATQBNAE0ATQBNAE0ATQBNAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAPAA8ADwAPAA8ADwAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADkAOQA5ADkAOQA5ADkAOQA5ADkAOAA4ADgAOAA8ADwAPAA8ADwAPAA8ADwAPAA8ADwAPAA8ADgAMgAyADIAMgAyADIAMgA1ADUANQA2ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAQABAAEAAQABAAEAAQABAAEAAPwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA9AD0APQBBAEEAQQBBAEEAQQBBAEEAQQBBAEEAQQA=",
+ dist: "GAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAYABgAGAAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABUAFQAVABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwAXABcAFwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwATAAgBCQEJAQkBCQEJAQkBCQEJAQkBCQEJAQkBCQEJAQkBCQEJAQkBCQEJAQkBCQH7APsA+wD7APsA+wD7APsA+wD7APsA+wD1AOUA5QDlAOUA5QDlAOUA5QDlAOUA5QDlAOUA5QDlAOUA5QDlAOUA5QDlAOIA4gDiAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAA4ADgAOAAAgECAQIBBAEEAQQBBAHdAN0A3QDdAN0A3QDdAN0A3QDgAOAA4ADgAOAA4ADgAOAA4ADgAOAA8wDzAN4A3gDeAN4A3gDeAN4A3gDeAN4A3gDeAN4A3gCrAKsAqwCrAKsAqwCrAKsAegHuAO4A7gDuAO4A7gD7APsA+wD7APsA+wD7APsA+wD7APsA+wD7APsA+wD7APsA+wD7ALoAugC6ALoAugC6ALoAugDRANEA0QDRANEA0QDRANEA4gDiAOIA4gDiAOIA4gDRANEA0QDRANEA0QDRANEA0QDRANEA0QDTANMA0wDTANMA0wDTANMA0wDTANMASAFHAUgBGAEYARgBGAEYAdMAsQDPAM8AzwDPAM8AzwDPAM8AzwDPACEBIQEhASEBIQEhASEBDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgH2APYA9gD2APYA9gD2APYA9gD2APYA9gD2APYA9gD2APYA9gD2AD0B+AD4APgA+AD4APkA+QD5APkA+QD5APkADwEPAQ8BDwEPAT8B9gD2APYA9gD2APYAswH2APYA9gD2APYA9gD2APYA9gD2APYA9gD2APYA9gD2APYA9gD8APwA/AD8APwA/QCYAZgBmAH9AP0A/QD9AP0A/QD9AP0A/QD9AP0A/QD9AP0A/QAtAS0BLQEtAS0BLQEtAS0BLQE1ATUBBAEEAVABUAFQAVABUAEEAQQBBAEEAS4BCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIAQgBCAEIASMBIwEjASMBIwEjASMBIwE1ATUBIwE0ATQBNAE0ATYBNgE2ATYBNgE2ATYBNgE2ATYBNgExATEBMQExATEBMQExATEBMQExATEBMQExATEBMQExATEBMQExATEBMQExATEBMQExAToBOgE6ATgBOAE4ATgBOAE4ATgBOAE4ATgBOAE4ATgBOAE4AS0BLQEtAS0BPQE9AT0BPQE9AT0BPQE9AT0BPQFJAUkBSQEMAkkBSQFJASgBJwEnASgBJwEmASYBJwEnAScBKAEnAScBJwEnAScBKAEoASgBJgEmASUBNAE0ATQBNAE0ATQBNAE0ATQBNAGBAjQBNAE0ATQBNAE0ATQBNAE0ATQBKwErAR0BHQEcARwBIgEiASIBIgEiASIBIgEiASIBIgEiASABIAEgASABIAEgASABIAEgASABIAEwATABMAEwATABMAEwATABMAEwATABMAEwATABMAEwARUBFQEVARUBFQEVARUBFQEVARUBFQEVARUBDwEPAQ8BDwH+AP4A/gD+AP4ADwH+AA8BDwEPAQ8BDwEPASABIAEgASABIAEgASAB/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8ANoA2gDaANoA2gDaANoA/QD9AP0A/QD9AP0A/QD9AP0A/QD9APcA9wD3APcAHAEcARwBHAEcARwBSgFKAUoBSgHpAUoBSgFKAUsB5wDnAOcA6gHnAPgBvwK/Ar8CvwLiAOIA4gDiAOIA4gDiAOIAWQJZAlkCWQJZAuUA5QDlAOUA5QDlAOUA5QDlAOUA5QDlAOUA5ADkAOQA5ADkAOQA5ADkAOQA5ADkAOQAsQGyAbIBsgHoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOcA5wDnAOcA5wDnAOcA5wDnAOcA5wDnAOcA5wDnAOAA4ADrAOsA6wDrAOsA6wDrAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAOgA6ADoAMYAxgDCAMIAwgDhAOEA4QDhAOEA4QBsAWwBbAFsAWwBbAFsAWwBbAFsAeEA4QBDAkMCQwJkAWQBqwGrAasBqwGrAaUBpQGlAa0BrQFkAWQBZAFkAWQBZAFkAWQBZAFkAW0BbQFuAW4BbQFtAW0BbQFiAWIBYgFiAWIBYgFiAWIBYgFiAbMBoQG1AbUBtQG1AbUBtQG1AWABYAFgAWABYAFgAWABYAFgAWkBaQFpAWkBaQFpAWkBaQFpAcEAowGjAaMBowGjAaUBpQGlAaUBpQGlAaUBpQGlAawBqAGoAagBqAGoATQCNAI0AsMBwwGbAZsBmwGbAZsBmwGbAZ0BnQGdAZ0BnQGdAZ0BpAGkAaQBnQGdAZ0BnQGdAZ0BnQGdAZoBmgGaAb8BqQGpAakBqQGpAakBqQG/Ab8BvwG/AZ0BnQGdAbgBuAG4AbgBuAG4AbgBuAG4AcUBzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADMAMwAzADHAMcAxwDHAMcAxwDHAMcAuQG5AckAyQDJAMkAyQDJAMkAyQDJAMkAyQDJAMkAyQDJAMkAyQDJAMkAyQDJAMkATAFMAZEBTAFMAUwBgwGDAYMBgwGDAYcBhwGHAYQBhAGEAYQBhwGHAYcBhwGHAYcBhwGBAYEBgQGHAYcBhwGHAaUBpQFeAV4BXgFeAV4BXgFeAV4BXgFeAV4BXgFeAV4BXgFeAV4BXgFeAV4BXgFeAWABYAFgAWABYAFgAWABYAFgAUkBSQFJAUkBSQFJAUkBSQFJAUkBYgFdAV0BXQFdAV0BXQFdAWkBaQFpAWkBaQFpAWkBaQFpAbkBuQFeAV4BXgFeAV4BXgG+Ab4BvgG+Ab4BvgG+AWYBZgFmAWYBZgFmAW4BbgFuAW4BbgHGAcYBZwGhAaEBoQGhAaEBoQGhAaEBqgGqAaoBqgGqAaoBqgGqAaoBFgEWARYBqgGqAasBqwGrAasBqwGrAasBjgEaAY4BjgGpAakBqQGpAakBqQGpAagBqAGoASQBJAEoAQ8BDwEPAaUBpgGmAaYBpgGmAaYBpgGmAaUBJwEKARMBIgEOASgBDgEOASgBIgEiAQoBCgETAasBqwElASUBJQGrAasBIgEiAQ8B1AMiASIBIgEiASIBIgEmASYBJgEmARgBGAEYARABEAEQARABFAElASUBGwEHAQcBGwEbARsBGwG5AbkBuQG5ARIBEgESARIBEgESARIBswGzARYBFgEWAREBEQElARoBGgEaASUBJQElASUBEgESAREBEQERAREBEQERAREBDwEPAQ8BDwEPAQ8BDQENAQ0BDQEUARQBFAEUAQ8BDwEPARQBFAEZARkBGQEZARkBGQEZARkBFAEUARQBFAEUARQBFAEUARQBFgEYARgBGAEYARgBGAF3A3cDdwNhAXADdQNiAWEBYQFhAW0DbQNlAWUBZgFmAWgBaAFrA6ADoAOgA6ADoAOIA4gDiAOIA4gDiAOcA5wDnANTAVMBzAPMA8wDjwOPA8YDxgPGA8wDkAO8A8IDwgPCA98D3APZA8MDwwPDA9oD2gPaA9YD1gPDA8MDwwPDA14EXgReBLcDtwO3A7cDtwO3A7AD3APcA9wD2APYA9gD4gPTA7IDsgOyA6wDzgOVAWUBZQFlAWUBZQFlAakDkwGTAZMBZwFjAWMBZwHIA8gDZQFlAWUBawFlAWUBZQFlAZMBkwGTAZMBkwGTAWcBZwFnAWcBlQGVAZUBlQGSAZIBkgGSAWcBZwFnAWcBmAGYAZQBlgGWAZYBlgGPAZoBlwGPAY8BjwGPAY8BagEhAiECaAGMAYwBjAEgAiECIQIkAogBiAGIAYsBIAIiAh8CHwIZAh8CnAFmAWYBiQGJAWQBZAGQAYoBZQFlAWUBigGQAZABkAGQAZgBmAGYAZgBmAGYAZgBmAGYAZgBmAGYAZgBmAGYAZgBmAGYAZgBmAGYAZgBmAGQAaIBogGiAagBqAGdAZ0BnQGdAZ0BnQGdAZ0BnQGfAZ8BnwGfAZ8BpwGnAacBpwGnAacBpwGnAacBpwGnAacBpwGnAacBpwGnAacBpwGdAZ0BnQGdAZkBmwGeAZ4BngGeAZ4BngGeAZ4BngGaAZoBogGiAaIBogGiAaIBogG0AbkBuQG5AbkBuQG5AbMBswGzAbMBswGzAbMBswG9Ab0BvQG9Ab0BvQG9Ab0BvQG9ASUBJQG9Ab0BvQGtAa0BrQGtAa0BrQGnAacBpwGnAacBtAG0AbQBsAGwAbABsAGwAbMBswG1AbUBswGzAbMBswGzAbMBswGzAbMBswGzAbMBqgGtAa0BrQGqAbcBtwGqAaoBqgGqAaoBpQGlAaEBowGtAa0BrQGtAa0BrQGtAa8BrwGtAa0BrQGtAa8BhwKvAd8ChAKEAoQChAKEArcBtwGCAoICggKCAoIChgK+AXMCcwJzAnMCRAI9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQBoAmgCPgA+AD4AKAI+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APwA/AD8APwA/AD8APwA/AD8APwA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgBDAEMAQwA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD8APwA/AD8APwA/AD8APwBhAmECYQJhAmECYQJCAkICQgI/Aj8CPwI/Aj8CVAJUAj4APgBbAj4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+AD4APgA+ADwAPwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD8APwA/AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQA9AD0APQAcARwBNwE3ATcBOAE4ATgBOAE4ATgBOAE4AToBOgE6AToBOgE6AToBOgEvAS8BLwEvAS8BLwEuAS4BLgEuAS4BLgEuAS4BLgEuAS4BLgEvAS8BLwEvATQBNAEgASABIAEgASABIAEgASABIAEgASABIAEgAS4BLwEdAR0BHQEdAR0BHQEdAR0BHQEdAR0BHQEdAR0BHQEbARsBGwEbARsBGwEbARsBGwEbARsBGwEWARYBFgEWARYBFgEWARYBFgEUARQBFAEUARQBFAEUARQBFAEUARQBFAEUARQBFAEUARQBDgEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQoBCgEKAQoBCgEKAQoBCgEKAQABAAEAAQABAAEAAQABAAEAAQABDQENAQ0BDQENAQYBBgEGAQYBBgEGAQYBBgEGAQYBGAEYARgBGAEYARgBGAEYARgBGAEYARgBEQERAREBEQERAREBEQERAREBEQERARcBFwEXARcBFwEXARcBFwEXARcBFwEXARcBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAQwBDAEMAZIBkgGSAZIBkgGSAZIBkgGSAZIBjwGPAY8BjwFkAWQBZAFkAWQBZAFkAWQBZAFkAWQBZAFkAW4BpgGmAaYBpgGmAaYBpgGGAYYBhgFpAaUBpQGlAaUBpQGlAaUBpQGlAaUBpQGlAW8BbwFvAW8BbwFvAW8BbAFsAWwBbAFsAWwBbAFsAWwBYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFgAWABYAFbAVsBWwFcAVwBXAFqAWoBagFqAWoBagFmAWYBZgE=",
+ base: "rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+Y/5j/mP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+g/6D/oP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/6j/qP+o/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/67/rv+u/7j/uP+4/7j/uP+8/7z/vP+8/7z/vP+8/7z/vP+8/7z/vP+8/7z/vP+8/7z/vP+8//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//T/9P/0//T/9P/0//T/9P/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//T/9P/0//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//X/9f/1//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/7v/u/+7/7v/u//f/9v/2/+7/7v/u/+7/7v/u/+7/7v/u/+7/7v/v/+//7//v/+//7//v/+//8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/y//L/8v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/1//X/9f/1//X/9f/1//X/9f/1//X/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/2//b/9v/3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/8//z//P/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6//r/+v/6/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/h/+H/4f/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4v/i/+L/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/5//n/+f/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/+P/4//j/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9//3//f/9/8=",
+};
diff --git a/apps/site/src/scripts/skylines/isafjordur.ts b/apps/site/src/scripts/skylines/isafjordur.ts
new file mode 100644
index 0000000..16bb1a0
--- /dev/null
+++ b/apps/site/src/scripts/skylines/isafjordur.ts
@@ -0,0 +1,12 @@
+// Generated by scripts/build-skyline.mjs. Do not edit.
+// Ísafjörður: 66.075075 N, -23.124175 E, eye 2 m above ground (4.3 m).
+// elev: Int16 little-endian, hundredths of a degree above the horizontal.
+// dist: Uint16 little-endian, tens of metres to the ridge.
+// base: Int16, hundredths of a degree: the far shore over water, or -40°.
+export const SKYLINE = {
+ from: -80,
+ step: 0.2,
+ elev: "dwd4B3oHeweAB4wHmAekB7EHvgfKB9YH4wfwB/0HCggXCCQIMgg9CEkIVQhhCG0IdQh8CIMIiQiOCI8IjgiMCIsIiwiLCIwIigiJCIgIhwiGCIcIhgiGCIYIhwiJCIsIjQiNCIwIiwiLCIwIjQiMCIwIiwiLCIoIiQiHCIYIhQiFCIUIhgiICIoIjgiRCJMIkgiSCJQIlgiXCJcIlwiXCJcIlQiUCJMIlAiVCJgImwidCJ0ImwidCKAIpQinCKcIpgimCKcIqQisCK4IrgivCLAIsgizCLIIsgiwCLAIsQi0CLgIuQi3CLYItgi4CLoIvgi9CLoIuAi3CLYItwi3CLcIsgiuCK0IrAipCKkIqgirCK8IrwisCKkIqAioCKoIqwirCKcIpAihCJ8IngidCJkIlQiTCJEIjgiMCIoIigiJCIkIhwiFCIQIgwiECIUIhwiKCI4IkQiXCJwIngikCKkIqgisCK4IsAiyCLYItgixCKsIpgiiCJ4ImQiWCJEIjgiLCIUIggh9CHoIeAh0CHAIaghlCF8IXAhZCFYIVQhSCFEITAhJCEUIPwg5CDQIMAgtCCsIJAgjCCEIIQghCCAIHggfCB0IGwgbCBoIFggSCA4IDAgLCAsIBggDCAEIAAj/BwAIAggFCAcIAgj9B/kH+Af4B/gH8wfvB+0H6wfrB+kH5wflB+QH4gffB94H3gffB9wH2AfUB9MH0wfSB9AHzQfKB8UHwwfBB8AHwAe8B7QHrwerB6gHpgeeB5gHlAePB4wHiAeEB38HeAdzB2wHYgdVB0gHOwcuByEHFQcLBwcHBQcBB/0G+Qb3BvcG9wb5BvoG/Ab9Bv0G+gb4BvYG9AbxBu4G6wbqBucG4gbdBtgGzQbCBrYGqQaaBooGewZpBlgGUAZHBj8GOAYxBikGIQYcBhgGGAYbBh4GHgYfBh8GHQYZBhUGEQYLBgUG/wX0BeQF1QXDBagFjQV6BW0FZAVXBUsFQAU0BSkFIAUZBREFBQX6BO0E0ATCBKMEiwRwBFsEVgRPBEkEQQQ3BC0EHQQLBPcD2QO8A6oDnAONA3gDZwNXA0cDOAMpAx0DEAMBA/MC5QLYAssCuAKnApYChQJzAmECVAJDAjACHwIMAvwB6wHaAcgBuAGmAZcBiAF5AWsBXQFQAUEBNgEsASMBGQEQAQYB/ADzAOkA4ADXAM8AxgC9ALUArQClAJ0AlQCOAIYAfgB3AG4AZQBcAFIASQBAADwAPAA/AEUASQBKAEoASgBLAEwATQBOAE4ATgBOAE4ATQBLAEoARABGAEgASABGAD8AQABCAEIAQwBEAEYARwBIAEgASABIAEkASgBLAEkASABHAEUAQQBCAFEAZQBqAG4AdwB6AH8AggCEAIQAhACEAIMAhACEAIQAhQCFAIcAigCMAI0AkACRAJIAkQCSAJIAkwCUAJUAlwCXAJcAlgCWAJYAlQCVAJQAlwCbAJ0AnwCgAKMApACmAKgAqQCrAKwArwCwALEAsgCzALQAtQC2ALcAuAC5ALoAvAC9AL8AwADCAMIAwwDEAMUAxwDJAMoAywDLAMsAzADMAMwAzgDPANAA0QDTANQA1QDWANcA2ADZANkA2gDaANkA2QDYANcA1wDWANYA1QDVANUA1QDWANYA1wDYANgA2wDdAN8A4ADfAOAA4ADhAOEA4QDiAOIA4gDiAOAA3wDfAN4A3QDdANwA2wDYANkA2QDcANsA2wDeAN4A3QDcANoA3ADeAN8A4ADgAN8A3wDdANkA1wDVANQA0gDSANQA1gDWANIAzwDPAM4AzADMAM4AzwDQANMA1QDXANoA3QDdANwA2wDaANgA1wDWANQA0wDSAM4AyADEAMAAvgC8ALoAuQC0ALUAtAC0ALQAtACzALMAswCyALMAtAC0ALMArwCtAKoApwCnAKUApACpALMAvQDHANIA3ADmAPAA+gADAQ0BFwEfAScBLwE2AT8BRwFQAVoBZAFtAXcBgAGKAZQBnwGqAbYBwgHOAdkB5AHvAfgBAgILAhQCHgIpAjgCSAJbAm4CfwKNAp0CqgK3AsUCywLOAtECzgLNAswCzALUAuAC7AL2AgADCwMVAyADKgM0AzwDQgNIA1ADVwNgA2sDdgOCA4UDigOPA48DjQOLA4sDlgOjA68DuwPOA+ID9gMEBA4EGQQjBCsENAQ9BEAEQwRGBEgESQRKBEwETgRQBFQEVwRbBF8EYgRoBHEEewSFBJQEogStBLkExATMBNME2wTgBOQE6ATrBOoE6gTpBOcE6QTtBPIE9gT7BAIFBQUIBQwFEAUQBRMFFwUXBRkFHgUjBSgFLAUzBTgFOQU7BT0FPQU9BT0FPQU7BTgFNgU0BTEFLwUvBS8FMgU3BTwFPQU/BUIFRQVGBUYFRwVKBUsFSgVLBUwFTQVMBU4FTwVOBUwFSgVIBUcFRgVFBUIFQAVABUIFRgVGBUYFSgVQBVMFUQVQBVEFVAVXBVkFXAVhBWYFaQVrBW4FcgV2BXoFfgWDBYcFjAWRBZcFngWiBaYFqwWvBa8FrgWwBbYFuAW3BbkFvAXBBcMFxQXIBc0F0gXTBdUF1QXWBdcF2gXcBd8F4gXkBeUF5wXpBegF5wXpBesF6gXuBfQF+gUBBgUGDgYXBh0GIgYnBi4GMwY2Bg==",
+ dist: "zADMAMwAzACuAK4ArgCuAK4ArgCuAK4ArgCwALAAsACwALAArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4AsACwALAAsACwALAAsACuAK4ArgCwALAAsACwALAAsACwALAAsACwAK4AsACwALAAsACwALAAsACwALAAsACwALAAsACwALAAsACwALAAsACwAK4ArgCwALAAsACwALAAsACwALAArgCuALAAsACwALAAsACuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK0ArQCtAK4ArgCuAK4ArQCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCuAK4ArgCwALAAsACwALAAsACwALAAsACwALAAsACuAK4ArgCuAK4ArQCtAK0ArQCrAKsAqwCrAKoAqgCrAKsAqwCrAKsAqwCrAKsArQCtAK0ArgCuAK4AsACwALAAsACwALAAsACwALAAsQCxALEAsQCzALMAswC0ALQAtAC0ALQAtAC2ALYAtwC3ALcAtwC3ALcAtwC2ALYAtwC3ALcAtwC3ALcAtwC5ALkAuQC5ALkAuQC6ALoAuQC5ALkAuQC5ALkAugC6ALoAugC6ALoAugC8ALwAvAC8ALwAvAC8ALwAvAC8ALwAvAC8AL0AvQC9AL0AvQC9AL0AvwC/AL8AvwC/AMAAwADAAMAAwADCAMIAwgDCAMMAwwDFAMUAxQDFAMYAxgDGAMYAxgDGAMYAxgDFAMUAxQDFAMUAxQDGAMYAyADJAMkAyQDJAMkAywDLAMsAywDLAMwAzADOAM4AzgDMAMwAzADMAMwAzADMAMwAywDLANQA1QDXANcA2ADYANsA4ADjAOQA5gDmAOcA6QDpAOkA6gDsAOwA7ADsAOwA7ADtAO0A7QDwAPAA8wD2APwA/AD8AP4A/wD8APwA+QD5APkA+QD5APsA8ADvAO8ACAEIARABEQERAREBEQERAREBEQERAREBEQERARoBGgEaARoBFwEXARcBHQEXARcBFAEUARQBFAEUARQBFAERAREBEQERAREBGgEaARoBFgEUAQsBCwEKAQsBEQENAfAA8ADwAPAA8ADwAO8A1QDVANUA1QDVANUA1QDVANUA1QDDAMMAwgDCAMAAwADAAMAAwADCAMIAwgDCAMIAwgDCAMMAxQDFAKQLpAvHC9YL4AvgC+UL5QtHDDgMOAwtDC0MKAwjDCMMIwweDB4MGQwVDQ8NDw0EDeMNEw4ZDhMOEw4ZDhkOGQ4ZDhkOKw4lDjcORA5KDkQOPQ43DjEOKw6GDTsHLAcsBwoH7wbtBtgGzAbBBrsGtQa4BskGyQbPBtIGzAbqBoUGgwZ9BngGdQZyBm0GbQZqBmQGYgZfBlkGVwZXBlcGVAZRBlQGVAZXBhkHHAcfBxwHJgcyBywHLAcpBywHLwcvBy8HLwcpBykHKQcpBywHLAcvBy8HLwcvBzIHMgcvBywHHwccBxwHLwcsBzIHSgdEB0EHRAdEB0EHQQdEB04HgAeAB3oHdgdgB10HWgdXB1oHWgdXB1oHVAdUB1QHUQdRB1QHVwdXB1cHSgdHB0EHQQdBB0QHRAc+BzUHEwcQBw0HCgcWBxYHGQcZB3YHegd9B4AHgAeAB20HWgdUB0oHRAdBB0EHQQdEB0QHSgdOB1EHmgedB6cHqgetBz4HOAc1BzUHNQc1BzgHOwc7B0EHRwdOB04HXQdRB04HQQdBB0EHRAcpBy8HMgcvBy8HLAc+BzsHOwc+B0EHRAdHB0oHTgdUB1cHWgddB2MHZwdqB2oHZwdjB10HWgdOB0oHRwc+B/oH+gf6B/oH9wfzB/AH6QfiB8sHwQe6B7cHsQenB60HtwfVB9UH4gdJAUcBRwFGAUYBRgFEAUQBRAFDAUMBQwFBAUEBQQE+AT4BPgE+AT4BPgE7ATsBOAE4ATcBNwE3ATUBNQE1ATUBMgEyATIBMQExAS4BLgEsASsBKQEpASkBKQEmASYBJgElASUBJQElASUBJQEjASMBIwEdAR0BHAEcARwBHAEcARwBHAEcARwBHAEaARoBGgEaARoBGgEaARoBGQEXARcBFwEXARYBEQERAREBEAEOAQ4BDgEOAQ4BDgEOAQ4BDgEOAQ4BDgENAQ0BDQENAQ0BDQEOAQ4BDQENAQ0BDQEIAQgBCAEHAQUBBQEFAQUBBQEFAQUBBQEFAQUBBQEFAQUBBQEFAQQBBAECAQIBAgECAQEBAQEBAQEB/wD/AP4A/gD+APwA/AD7APsA+wD5APkA+QD5APkA+AD4APgA+AD4APgA+QD5APkA+AD4APgA9gD2APYA9QD1APUA9QDzAPMA8gDyAPIA8ADwAPAA8ADvAO8A7wDvAO8A7wDvAPAA8ADwAPAA8ADvAO8A7wDvAO8A7QDtAO0A7QDtAOwA7ADsAOwA7ADqAOoA6gDqAOoA6QDpAOkA6QDpAOkA6QDpAOkA5wDnAOcA5wDnAOcA5wDmAOYA5gDmAOYA5gDmAOYA5gDmAOQA5ADkAOQA5ADkAOMA4wDjAOMA4wDjAOMA4wDjAOMA4wDkAOQA5ADjAOMA4wDjAOMA4ADgAOAA4ADgAOAA3gDeAA==",
+ base: "YPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8GDwYPBg8A==",
+};
diff --git a/package.json b/package.json
index 5afe967..9f6b013 100644
--- a/package.json
+++ b/package.json
@@ -3,6 +3,7 @@
"private": true,
"type": "module",
"scripts": {
+ "dev": "pnpm --filter @northguild/site dev",
"build": "pnpm -r build",
"check": "pnpm -r check",
"lint": "oxlint",
@@ -11,6 +12,9 @@
},
"devDependencies": {
"@changesets/cli": "^3.0.2",
+ "@northguild/gmt-oxlint": "^1.2.1",
+ "geotiff": "3.0.5",
+ "jpeg-js": "^0.4.4",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0"
},
diff --git a/packages/theme/primitives.css b/packages/theme/primitives.css
index 2b29b55..3776c19 100644
--- a/packages/theme/primitives.css
+++ b/packages/theme/primitives.css
@@ -50,6 +50,8 @@
position: absolute;
inset: 0;
border-radius: inherit;
+ /* Follow the bevel too, or the hairline draws round corners outside it. */
+ corner-shape: inherit;
padding: 1px;
background: linear-gradient(
135deg,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 771ae48..443f4a3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -11,6 +11,15 @@ importers:
'@changesets/cli':
specifier: ^3.0.2
version: 3.0.3
+ '@northguild/gmt-oxlint':
+ specifier: ^1.2.1
+ version: 1.2.1(oxlint@1.89.0)
+ geotiff:
+ specifier: 3.0.5
+ version: 3.0.5
+ jpeg-js:
+ specifier: ^0.4.4
+ version: 0.4.4
oxfmt:
specifier: ^0.63.0
version: 0.63.0
@@ -20,6 +29,9 @@ importers:
apps/site:
dependencies:
+ '@northguild/gmt':
+ specifier: ^1.19.0
+ version: 1.19.0
'@northguild/theme':
specifier: workspace:*
version: link:../../packages/theme
@@ -1018,6 +1030,10 @@ packages:
'@jridgewell/trace-mapping@0.3.9':
resolution: {integrity: sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==}
+ '@js-temporal/polyfill@0.5.1':
+ resolution: {integrity: sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==}
+ engines: {node: '>=12'}
+
'@manypkg/find-root@3.1.0':
resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==}
engines: {node: '>=20.0.0'}
@@ -1044,6 +1060,15 @@ packages:
'@emnapi/core': ^1.7.1 || ^2.0.0-alpha.4
'@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.4
+ '@northguild/gmt-oxlint@1.2.1':
+ resolution: {integrity: sha512-ot1NndXxCaGikCYqe3r4D+nFM3twd1WMwylfj8xfWLCCWSRYgIFXmQ2yLpCcjf53KWxS660jmaIqWndXQh5lQA==}
+ peerDependencies:
+ oxlint: '>=1.0.0'
+
+ '@northguild/gmt@1.19.0':
+ resolution: {integrity: sha512-RWOyb9G4z57t1wF2j7TrHivHW/MHFQHI8Ub63tWUIwhzBhf968gh84wvvBOrzc5GI36GkZbVJxOPj6Rj+ymSDA==}
+ engines: {node: '>=22.16.0'}
+
'@oslojs/encoding@1.1.0':
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
@@ -1294,6 +1319,9 @@ packages:
cpu: [x64]
os: [win32]
+ '@petamoriken/float16@3.9.3':
+ resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==}
+
'@pnpm/deps.graph-sequencer@1100.0.1':
resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==}
engines: {node: '>=22.13'}
@@ -1773,6 +1801,10 @@ packages:
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
os: [darwin]
+ geotiff@3.0.5:
+ resolution: {integrity: sha512-OWcL9S9+yDZ6iAlXMt32T1iwUApJM8UiD47xbm6ZP1h33d10fqkPs14EG/ttT5EnefpZSx3G15iDFC5FxUNUwA==}
+ engines: {node: '>=10.19'}
+
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -1832,10 +1864,16 @@ packages:
jju@1.4.0:
resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==}
+ jpeg-js@0.4.4:
+ resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==}
+
js-yaml@4.3.2:
resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==}
hasBin: true
+ jsbi@4.3.2:
+ resolution: {integrity: sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==}
+
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@@ -1852,6 +1890,9 @@ packages:
launch-editor@2.14.2:
resolution: {integrity: sha512-NHJEb30MGBCkmZXLZwz25t37QR4aY03ZzPYUJXOJaOa92uWwExI9SBQUuIznBYc1xkD7cJNGX7oc3Nl3sq2kQQ==}
+ lerc@3.0.0:
+ resolution: {integrity: sha512-Rm4J/WaHhRa93nCN2mwWDZFoRVF18G1f47C+kvQWyHGEZxFpTUi73p7lMVSAndyxGt6lJ2/CFbOcf9ra5p8aww==}
+
lightningcss-android-arm64@1.33.0:
resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==}
engines: {node: '>= 12.0.0'}
@@ -2057,6 +2098,12 @@ packages:
package-manager-detector@1.9.0:
resolution: {integrity: sha512-zAMfbta9nPA8AKkA4WdmNAT8R8s6Dk0/xwPsqNnRidMFcLo1YkLuPDTgs2f64XdzS8vbLNnoJ+uUIkgbz7oRHA==}
+ pako@2.2.0:
+ resolution: {integrity: sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==}
+
+ parse-headers@2.0.6:
+ resolution: {integrity: sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A==}
+
path-browserify@1.0.1:
resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==}
@@ -2100,6 +2147,10 @@ packages:
property-information@7.2.0:
resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==}
+ quick-lru@6.1.2:
+ resolution: {integrity: sha512-AAFUA5O1d83pIHEhJwWCq/RQcRukCkn/NSm2QsTEMle5f2hP0ChI2+3Xb051PZCkLryI/Ir1MVKviT2FIloaTQ==}
+ engines: {node: '>=12'}
+
radix3@1.1.2:
resolution: {integrity: sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA==}
@@ -2517,6 +2568,9 @@ packages:
vscode-uri@3.2.0:
resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==}
+ web-worker@1.5.0:
+ resolution: {integrity: sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==}
+
workerd@1.20260825.1:
resolution: {integrity: sha512-ccS6TEaaRxgONKawiYGnFYUVGfr2pmN1b7mrNtw0ADVhFaYsEIVoCHnQ4UjhM9EJDzuaNgAWFY82nzVrjWpuOA==}
engines: {node: '>=16'}
@@ -2548,6 +2602,9 @@ packages:
utf-8-validate:
optional: true
+ xml-utils@1.10.2:
+ resolution: {integrity: sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==}
+
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
@@ -2590,6 +2647,9 @@ packages:
zod@4.6.7:
resolution: {integrity: sha512-g8W2D2kU6a8untc053fX1uVBKzM7yqAxW/3zwk83+6RyF2+SKOuV+c0wBDhUwzV1PaGapmZH+rmcUzPf+3CGwg==}
+ zstddec@0.2.0:
+ resolution: {integrity: sha512-oyPnDa1X5c13+Y7mA/FDMNJrn4S8UNBe0KCqtDmor40Re7ALrPN6npFwyYVRRh+PqozZQdeg23QtbcamZnG5rA==}
+
zwitch@2.0.4:
resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==}
@@ -3348,6 +3408,10 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.6.0
+ '@js-temporal/polyfill@0.5.1':
+ dependencies:
+ jsbi: 4.3.2
+
'@manypkg/find-root@3.1.0':
dependencies:
'@manypkg/tools': 2.1.2
@@ -3383,6 +3447,14 @@ snapshots:
'@tybys/wasm-util': 0.10.4
optional: true
+ '@northguild/gmt-oxlint@1.2.1(oxlint@1.89.0)':
+ dependencies:
+ oxlint: 1.89.0
+
+ '@northguild/gmt@1.19.0':
+ dependencies:
+ '@js-temporal/polyfill': 0.5.1
+
'@oslojs/encoding@1.1.0': {}
'@oxc-project/types@0.154.0': {}
@@ -3501,6 +3573,8 @@ snapshots:
'@oxlint/binding-win32-x64-msvc@1.89.0':
optional: true
+ '@petamoriken/float16@3.9.3': {}
+
'@pnpm/deps.graph-sequencer@1100.0.1': {}
'@poppinss/colors@4.1.6':
@@ -4046,6 +4120,17 @@ snapshots:
fsevents@2.3.3:
optional: true
+ geotiff@3.0.5:
+ dependencies:
+ '@petamoriken/float16': 3.9.3
+ lerc: 3.0.0
+ pako: 2.2.0
+ parse-headers: 2.0.6
+ quick-lru: 6.1.2
+ web-worker: 1.5.0
+ xml-utils: 1.10.2
+ zstddec: 0.2.0
+
get-caller-file@2.0.5: {}
get-east-asian-width@1.7.0: {}
@@ -4106,10 +4191,14 @@ snapshots:
jju@1.4.0: {}
+ jpeg-js@0.4.4: {}
+
js-yaml@4.3.2:
dependencies:
argparse: 2.0.1
+ jsbi@4.3.2: {}
+
json-schema-traverse@1.0.0: {}
jsonc-parser@2.3.1: {}
@@ -4123,6 +4212,8 @@ snapshots:
picocolors: 1.1.1
shell-quote: 1.12.0
+ lerc@3.0.0: {}
+
lightningcss-android-arm64@1.33.0:
optional: true
@@ -4330,6 +4421,10 @@ snapshots:
package-manager-detector@1.9.0: {}
+ pako@2.2.0: {}
+
+ parse-headers@2.0.6: {}
+
path-browserify@1.0.1: {}
path-to-regexp@6.3.0: {}
@@ -4358,6 +4453,8 @@ snapshots:
property-information@7.2.0: {}
+ quick-lru@6.1.2: {}
+
radix3@1.1.2: {}
readdirp@4.1.2: {}
@@ -4775,6 +4872,8 @@ snapshots:
vscode-uri@3.2.0: {}
+ web-worker@1.5.0: {}
+
workerd@1.20260825.1:
optionalDependencies:
'@cloudflare/workerd-darwin-64': 1.20260825.1
@@ -4807,6 +4906,8 @@ snapshots:
ws@8.21.0: {}
+ xml-utils@1.10.2: {}
+
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {}
@@ -4858,4 +4959,6 @@ snapshots:
zod@4.6.7: {}
+ zstddec@0.2.0: {}
+
zwitch@2.0.4: {}
diff --git a/scripts/build-moon.mjs b/scripts/build-moon.mjs
new file mode 100644
index 0000000..d57774e
--- /dev/null
+++ b/scripts/build-moon.mjs
@@ -0,0 +1,56 @@
+// Bakes a small lunar albedo map for the aurora's moon from NASA SVS's CGI
+// Moon Kit (LRO colour map, longitude 0 at the centre, east to the right).
+// Writes apps/site/src/scripts/aurora.moon.ts: 128×64 greyscale, base64.
+//
+// Run: node scripts/build-moon.mjs
+// Moon: NASA's Scientific Visualization Studio.
+
+import { writeFileSync } from "node:fs";
+import jpeg from "jpeg-js";
+
+const SOURCE =
+ "https://svs.gsfc.nasa.gov/vis/a000000/a004700/a004720/lroc_color_poles_1k.jpg";
+const W = 128;
+const H = 64;
+const OUT = new URL("../apps/site/src/scripts/aurora.moon.ts", import.meta.url);
+
+const res = await fetch(SOURCE);
+if (!res.ok) throw new Error(`moon map: HTTP ${res.status}`);
+const img = jpeg.decode(Buffer.from(await res.arrayBuffer()), {
+ useTArray: true,
+});
+
+// Box-filter down to W×H luminance.
+const out = new Uint8Array(W * H);
+const sx = img.width / W;
+const sy = img.height / H;
+for (let y = 0; y < H; y++) {
+ for (let x = 0; x < W; x++) {
+ let sum = 0;
+ let n = 0;
+ for (let j = Math.floor(y * sy); j < Math.floor((y + 1) * sy); j++) {
+ for (let i = Math.floor(x * sx); i < Math.floor((x + 1) * sx); i++) {
+ const o = (j * img.width + i) * 4;
+ sum +=
+ 0.2126 * img.data[o] +
+ 0.7152 * img.data[o + 1] +
+ 0.0722 * img.data[o + 2];
+ n++;
+ }
+ }
+ out[y * W + x] = Math.round(sum / n);
+ }
+}
+
+writeFileSync(
+ OUT,
+ `// Generated by scripts/build-moon.mjs from NASA SVS's CGI Moon Kit. Do not edit.
+// ${W}×${H} greyscale albedo, equirectangular, longitude 0 at the centre.
+export const MOON = {
+ width: ${W},
+ height: ${H},
+ data: "${Buffer.from(out).toString("base64")}",
+};
+`,
+);
+console.log(`${W}×${H}, ${out.length} bytes`);
diff --git a/scripts/build-skyline.mjs b/scripts/build-skyline.mjs
new file mode 100644
index 0000000..ad34a36
--- /dev/null
+++ b/scripts/build-skyline.mjs
@@ -0,0 +1,382 @@
+// Builds the skyline seen from each site in aurora.sites.ts. For each azimuth
+// it marches outward over the Copernicus DEM GLO-30 surface model (terrain,
+// trees and buildings), optionally adds OpenStreetMap building heights, and
+// keeps the highest elevation angle, allowing for Earth curvature and standard
+// refraction. Where the site looks over water it also records the far shore,
+// so the page can draw the water below it.
+// Writes apps/site/src/scripts/skylines/.ts.
+//
+// Run: node scripts/build-skyline.mjs [site id ...] (default: all sites)
+// Terrain: Copernicus DEM GLO-30 © DLR e.V. 2010–2014 and © Airbus Defence and
+// Space GmbH 2014–2018, provided under COPERNICUS by the European Union and ESA.
+// Buildings: © OpenStreetMap contributors (ODbL).
+
+import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fromFile } from "geotiff";
+import { SITES } from "../apps/site/src/scripts/aurora.sites.ts";
+
+// Degrees either side of the camera heading, and the azimuth step. City
+// sites are sampled finer so a dome a few tenths of a degree wide keeps its
+// shape; their wider views need less span.
+const spanFor = (site) => (site.buildings ? 75 : 100);
+const stepFor = (site) => (site.buildings ? 0.05 : 0.2);
+const FAR = 80_000;
+const BUILDINGS_FAR = 12_000; // OSM buildings beyond this are below a pixel
+const LAND = 1.5; // metres above sea level that count as land, not water
+const NO_SHORE = -40; // degrees: the strip runs below the frame
+const R_EARTH = 6_371_000;
+const R_EFF = (R_EARTH * 7) / 6; // standard refraction
+const OUT = new URL("../apps/site/src/scripts/skylines/", import.meta.url);
+const DEG = 180 / Math.PI;
+
+const cache = join(tmpdir(), "northguild-dem");
+mkdirSync(cache, { recursive: true });
+mkdirSync(OUT, { recursive: true });
+
+const tileName = (lat, lon) =>
+ `${lat < 0 ? "S" : "N"}${String(Math.abs(lat)).padStart(2, "0")}_00_` +
+ `${lon < 0 ? "W" : "E"}${String(Math.abs(lon)).padStart(3, "0")}_00`;
+
+async function load(tile) {
+ const name = `Copernicus_DSM_COG_10_${tile}_DEM`;
+ const file = join(cache, `${name}.tif`);
+ if (!existsSync(file)) {
+ const res = await fetch(
+ `https://copernicus-dem-30m.s3.amazonaws.com/${name}/${name}.tif`,
+ );
+ // Open sea has no tile: treat it as sea level.
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error(`${tile}: HTTP ${res.status}`);
+ writeFileSync(file, Buffer.from(await res.arrayBuffer()));
+ }
+ const image = await (await fromFile(file)).getImage();
+ const [west, south, east, north] = image.getBoundingBox();
+ const [data] = await image.readRasters();
+ return {
+ west,
+ south,
+ east,
+ north,
+ width: image.getWidth(),
+ height: image.getHeight(),
+ data,
+ };
+}
+
+const OVERPASS = [
+ "https://overpass-api.de/api/interpreter",
+ "https://overpass.kumi.systems/api/interpreter",
+];
+
+async function overpass(query) {
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const url = OVERPASS[attempt % OVERPASS.length];
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "User-Agent": "northguild-skyline/1.0" },
+ body: new URLSearchParams({ data: query }),
+ });
+ const text = await res.text();
+ if (res.ok && text.startsWith("{")) return text;
+ console.warn(`overpass ${url}: HTTP ${res.status}, retrying`);
+ } catch (error) {
+ console.warn(`overpass ${url}: ${error.message}, retrying`);
+ }
+ await new Promise((done) => setTimeout(done, 5000 * 2 ** attempt));
+ }
+ throw new Error("overpass: no server answered");
+}
+
+const num = (v) => Number.parseFloat(v);
+
+/**
+ * OSM buildings and building parts (Simple 3D Buildings) with height, roof
+ * height and roof shape, cached per bounding box.
+ */
+async function osmBuildings([south, west, north, east]) {
+ const file = join(cache, `osm3d-${south}-${west}-${north}-${east}.json`);
+ if (!existsSync(file)) {
+ const box = `(${south},${west},${north},${east})`;
+ writeFileSync(
+ file,
+ await overpass(
+ `[out:json][timeout:180];(way["building"]${box};way["building:part"]${box};);out tags geom;`,
+ ),
+ );
+ }
+ const { elements } = JSON.parse(readFileSync(file, "utf8"));
+ return elements
+ .filter((e) => e.geometry?.length > 2)
+ .map((e) => {
+ const t = e.tags ?? {};
+ const levels = num(t["building:levels"]);
+ const roofLevels = num(t["roof:levels"]);
+ const roofFromLevels = Number.isFinite(roofLevels) ? roofLevels * 3.2 : 0;
+ // In Simple 3D Buildings `height` is to the top of the roof.
+ let height = num(t.height);
+ if (!Number.isFinite(height)) {
+ height = Number.isFinite(levels)
+ ? levels * 3.2 + 1 + roofFromLevels
+ : 10;
+ }
+ let roof = num(t["roof:height"]);
+ if (!Number.isFinite(roof)) roof = roofFromLevels;
+ return {
+ part: "building:part" in t,
+ height,
+ roof: Math.min(Math.max(roof, 0), height),
+ shape: t["roof:shape"] ?? "flat",
+ points: e.geometry,
+ };
+ });
+}
+
+/** Roof profile across its width, u from -1 to 1: 1 at the full height. */
+function roofProfile(shape, u) {
+ const a = Math.min(1, Math.abs(u));
+ switch (shape) {
+ case "dome":
+ case "round":
+ return Math.sqrt(1 - a * a);
+ case "onion":
+ return 0.8 * Math.sqrt(1 - a * a) + 0.2 * Math.max(0, 1 - 4 * a);
+ case "cone":
+ case "pyramidal":
+ return 1 - a;
+ case "flat":
+ return 1;
+ default:
+ // Gabled, hipped and the rest: the side seen depends on orientation.
+ return 0.6;
+ }
+}
+
+/** Whether point [x, y] is inside polygon pts (local metres). */
+function inside([x, y], pts) {
+ let hit = false;
+ for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) {
+ const [xi, yi] = pts[i];
+ const [xj, yj] = pts[j];
+ if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) {
+ hit = !hit;
+ }
+ }
+ return hit;
+}
+
+async function build(site) {
+ const SPAN = spanFor(site);
+ const AZ_STEP = stepFor(site);
+ const latRad = site.lat / DEG;
+ const dLat = (FAR / R_EARTH) * DEG;
+ const dLon = dLat / Math.cos(latRad);
+ const names = [];
+ for (
+ let la = Math.floor(site.lat - dLat);
+ la <= Math.floor(site.lat + dLat);
+ la++
+ ) {
+ for (
+ let lo = Math.floor(site.lon - dLon);
+ lo <= Math.floor(site.lon + dLon);
+ lo++
+ ) {
+ names.push(tileName(la, lo));
+ }
+ }
+ const tiles = (await Promise.all(names.map(load))).filter(Boolean);
+
+ const height = (lat, lon) => {
+ for (const t of tiles) {
+ if (lon < t.west || lon >= t.east || lat <= t.south || lat > t.north) {
+ continue;
+ }
+ const x = ((lon - t.west) / (t.east - t.west)) * t.width - 0.5;
+ const y = ((t.north - lat) / (t.north - t.south)) * t.height - 0.5;
+ const x0 = Math.max(0, Math.min(t.width - 2, Math.floor(x)));
+ const y0 = Math.max(0, Math.min(t.height - 2, Math.floor(y)));
+ const fx = Math.min(1, Math.max(0, x - x0));
+ const fy = Math.min(1, Math.max(0, y - y0));
+ const at = (i, j) => t.data[(y0 + j) * t.width + x0 + i];
+ const top = at(0, 0) * (1 - fx) + at(1, 0) * fx;
+ const bottom = at(0, 1) * (1 - fx) + at(1, 1) * fx;
+ return Math.max(0, top * (1 - fy) + bottom * fy);
+ }
+ return 0;
+ };
+
+ // Local metres east (x) and north (y) of the viewer.
+ const toLocal = (lat, lon) => [
+ ((lon - site.lon) / DEG) * R_EARTH * Math.cos(latRad),
+ ((lat - site.lat) / DEG) * R_EARTH,
+ ];
+
+ const from = site.heading - SPAN;
+ const count = Math.round((2 * SPAN) / AZ_STEP) + 1;
+ // On a pier the eye height is above the sea; the model there may show a building.
+ const h0 = site.water ? site.eye : height(site.lat, site.lon) + site.eye;
+ const angleTo = (top, r) => Math.atan2(top - h0 - (r * r) / (2 * R_EFF), r);
+ // Over water the viewer's own pier may be in the model; start just past it.
+ const near = site.water ? 15 : 300;
+
+ const best = new Float64Array(count).fill(-Infinity);
+ const bestR = new Float64Array(count).fill(FAR);
+ const shore = new Float64Array(count).fill(NO_SHORE / DEG);
+
+ for (let i = 0; i < count; i++) {
+ const az = (from + i * AZ_STEP) / DEG;
+ let landAt = 0;
+ // Step grows with distance: 15 m near, about 130 m far.
+ for (let r = near; r <= FAR; r += Math.max(15, r / 600)) {
+ const lat = site.lat + (r * Math.cos(az) * DEG) / R_EARTH;
+ const lon =
+ site.lon + (r * Math.sin(az) * DEG) / (R_EARTH * Math.cos(latRad));
+ const z = height(lat, lon);
+ if (!landAt && z > LAND) landAt = r;
+ const angle = angleTo(z, r);
+ if (angle > best[i]) {
+ best[i] = angle;
+ bestR[i] = r;
+ }
+ }
+ // Over water the strip stops at the first land, however near: the
+ // island's own shore curves down toward the frame.
+ if (site.water && landAt) shore[i] = angleTo(0, landAt);
+ }
+
+ if (site.buildings) {
+ const box = [
+ site.lat - 0.11,
+ site.lon - 0.22,
+ site.lat + 0.11,
+ site.lon + 0.22,
+ ].map((v) => Math.round(v * 1000) / 1000);
+ const all = (await osmBuildings(box)).map((b) => ({
+ ...b,
+ local: b.points.map((p) => toLocal(p.lat, p.lon)),
+ }));
+ const parts = all.filter((b) => b.part);
+ // Where a building is modelled in parts, draw the parts, not its outline.
+ const centres = parts.map(({ local }) => [
+ local.reduce((sum, [x]) => sum + x, 0) / local.length,
+ local.reduce((sum, [, y]) => sum + y, 0) / local.length,
+ ]);
+ const GRID = 200;
+ const cell = ([x, y]) => `${Math.floor(x / GRID)},${Math.floor(y / GRID)}`;
+ const byCell = new Map();
+ centres.forEach((c) => {
+ const key = cell(c);
+ byCell.set(key, [...(byCell.get(key) ?? []), c]);
+ });
+ const hasParts = (local) => {
+ const xs = local.map(([x]) => x);
+ const ys = local.map(([, y]) => y);
+ for (
+ let gx = Math.floor(Math.min(...xs) / GRID);
+ gx <= Math.floor(Math.max(...xs) / GRID);
+ gx++
+ ) {
+ for (
+ let gy = Math.floor(Math.min(...ys) / GRID);
+ gy <= Math.floor(Math.max(...ys) / GRID);
+ gy++
+ ) {
+ for (const c of byCell.get(`${gx},${gy}`) ?? [])
+ if (inside(c, local)) return true;
+ }
+ }
+ return false;
+ };
+ const shapes = [
+ ...parts,
+ ...all.filter((b) => !b.part && !hasParts(b.local)),
+ ];
+ let used = 0;
+ for (const { height: tall, roof, shape, points, local } of shapes) {
+ const r = Math.min(...local.map(([x, y]) => Math.hypot(x, y)));
+ if (r < 50 || r > BUILDINGS_FAR) continue;
+ // Ground under the building: the lowest surface-model value at its corners.
+ const ground = Math.min(...points.map((p) => height(p.lat, p.lon)));
+ const azs = local.map(([x, y]) => Math.atan2(x, y) * DEG);
+ const lo = Math.min(...azs);
+ const hi = Math.max(...azs);
+ if (hi - lo > 90) continue; // straddles the bearing behind the viewer
+ const mid = (lo + hi) / 2;
+ const half = Math.max((hi - lo) / 2, 1e-6);
+ const first = Math.max(0, Math.ceil((lo - from) / AZ_STEP));
+ const last = Math.min(count - 1, Math.floor((hi - from) / AZ_STEP));
+ // A part narrower than one step still marks its nearest bin.
+ const bins =
+ first <= last
+ ? [first, last]
+ : [
+ Math.round((mid - from) / AZ_STEP),
+ Math.round((mid - from) / AZ_STEP),
+ ];
+ for (
+ let i = Math.max(0, bins[0]);
+ i <= Math.min(count - 1, bins[1]);
+ i++
+ ) {
+ const u = (from + i * AZ_STEP - mid) / half;
+ const angle = angleTo(
+ ground + tall - roof + roof * roofProfile(shape, u),
+ r,
+ );
+ if (angle > best[i]) {
+ best[i] = angle;
+ bestR[i] = r;
+ }
+ }
+ used++;
+ }
+ console.log(
+ `${site.id}: ${used} OSM buildings and parts within ${BUILDINGS_FAR / 1000} km (${parts.length} parts)`,
+ );
+ }
+
+ const elev = Int16Array.from(best, (a) => Math.round(a * DEG * 100));
+ const dist = Uint16Array.from(bestR, (r) =>
+ Math.min(65535, Math.round(r / 10)),
+ );
+ const base = Int16Array.from(shore, (a) => Math.round(a * DEG * 100));
+
+ const b64 = (a) => Buffer.from(a.buffer).toString("base64");
+ writeFileSync(
+ new URL(`${site.id}.ts`, OUT),
+ `// Generated by scripts/build-skyline.mjs. Do not edit.
+// ${site.name}: ${site.lat} N, ${site.lon} E, eye ${site.eye} m above ground (${h0.toFixed(1)} m).
+// elev: Int16 little-endian, hundredths of a degree above the horizontal.
+// dist: Uint16 little-endian, tens of metres to the ridge.
+// base: Int16, hundredths of a degree: the far shore over water, or ${NO_SHORE}°.
+export const SKYLINE = {
+ from: ${from},
+ step: ${AZ_STEP},
+ elev: "${b64(elev)}",
+ dist: "${b64(dist)}",
+ base: "${b64(base)}",
+};
+`,
+ );
+
+ const deg = Array.from(elev, (v) => v / 100);
+ console.log(
+ `${site.id}: ground ${h0.toFixed(1)} m, ${tiles.length} tiles, ${count} azimuths, ` +
+ `elevation ${Math.min(...deg)}° to ${Math.max(...deg)}°`,
+ );
+ for (let az = from; az <= from + 2 * SPAN; az += 10) {
+ const i = Math.round((az - from) / AZ_STEP);
+ console.log(
+ ` ${String(az).padStart(4)}° ${deg[i].toFixed(2).padStart(6)}° ` +
+ `${(dist[i] / 100).toFixed(1).padStart(5)} km shore ${(base[i] / 100).toFixed(2)}°`,
+ );
+ }
+}
+
+const wanted = process.argv.slice(2);
+for (const site of SITES) {
+ if (!wanted.length || wanted.includes(site.id)) await build(site);
+}
diff --git a/scripts/build-stars.mjs b/scripts/build-stars.mjs
new file mode 100644
index 0000000..5dc3794
--- /dev/null
+++ b/scripts/build-stars.mjs
@@ -0,0 +1,58 @@
+// Builds apps/site/public/stars.bin from the Yale Bright Star Catalogue,
+// 5th revised edition (Hoffleit & Warren 1991, CDS V/50): naked-eye stars
+// (V <= 5.5), J2000 positions. Precession to the current date happens at
+// runtime in the aurora worker.
+//
+// Run: node scripts/build-stars.mjs
+// Record, 6 bytes little-endian: ra uint16 (0..65536 = 0..360°),
+// dec int16 (±32767 = ±90°), vmag uint8 ((V + 2) × 20), b-v int8 (× 50).
+
+import { writeFileSync } from "node:fs";
+import { gunzipSync } from "node:zlib";
+
+const SOURCE = "http://tdc-www.harvard.edu/catalogs/bsc5.dat.gz";
+const LIMIT = 5.5;
+const OUT = new URL("../apps/site/public/stars.bin", import.meta.url);
+
+const res = await fetch(SOURCE);
+if (!res.ok) throw new Error(`catalogue: HTTP ${res.status}`);
+const text = gunzipSync(Buffer.from(await res.arrayBuffer())).toString(
+ "latin1",
+);
+
+// Byte ranges from the catalogue ReadMe (1-based, inclusive).
+const field = (line, from, to) => line.slice(from - 1, to).trim();
+
+const stars = [];
+for (const line of text.split("\n")) {
+ const raH = field(line, 76, 77);
+ const vmag = field(line, 103, 107);
+ // Blank coordinates are entries removed from the catalogue (novae, clusters).
+ if (!raH || !vmag) continue;
+ const v = Number(vmag);
+ if (v > LIMIT) continue;
+ const ra =
+ (Number(raH) +
+ Number(field(line, 78, 79)) / 60 +
+ Number(field(line, 80, 83)) / 3600) *
+ 15;
+ const sign = field(line, 84, 84) === "-" ? -1 : 1;
+ const dec =
+ sign *
+ (Number(field(line, 85, 86)) +
+ Number(field(line, 87, 88)) / 60 +
+ Number(field(line, 89, 90)) / 3600);
+ const bv = Number(field(line, 110, 114) || "0.6");
+ stars.push({ ra, dec, v, bv });
+}
+
+const buf = Buffer.alloc(stars.length * 6);
+stars.forEach(({ ra, dec, v, bv }, i) => {
+ const o = i * 6;
+ buf.writeUInt16LE(Math.round((ra / 360) * 65536) % 65536, o);
+ buf.writeInt16LE(Math.round((dec / 90) * 32767), o + 2);
+ buf.writeUInt8(Math.max(0, Math.min(255, Math.round((v + 2) * 20))), o + 4);
+ buf.writeInt8(Math.max(-127, Math.min(127, Math.round(bv * 50))), o + 5);
+});
+writeFileSync(OUT, buf);
+console.log(`${stars.length} stars, ${buf.length} bytes`);