From 37abc85acff0913748bc8b76673e9b3a31c4c384 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Sun, 5 Apr 2026 18:09:59 +0100 Subject: [PATCH 01/15] Add tests to Github Workflow --- .github/workflows/ci.yaml | 21 ++++++++ Cargo.lock | 101 ++++++++++++++++++++++++++++++++++++++ crates/tasks/Cargo.toml | 2 +- 3 files changed, 123 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 56d57f7..e8c8d18 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -18,6 +18,27 @@ concurrency: cancel-in-progress: true jobs: + build-test: + name: Build and Test 🔬 + strategy: + fail-fast: false + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@master + with: + toolchain: 1.94.1 + - name: Install Linux system dependencies + run: | + sudo apt-get update -y + sudo apt-get install libgdal-dev gdal-bin + - uses: Swatinem/rust-cache@v2 + - name: Build + run: cargo build --verbose --all + - name: Run tests + run: cargo test + lints: name: "Lints 💅" runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 8fdc42d..c82b6bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -420,6 +420,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "bindgen" +version = "0.71.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f58bf3d7db68cfbac37cfc485a8d711e87e064c3d0fe0435b92f7a407f9d6b3" +dependencies = [ + "bitflags", + "cexpr", + "clang-sys", + "itertools", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex", + "syn", +] + [[package]] name = "bitflags" version = "2.9.4" @@ -525,6 +545,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", +] + [[package]] name = "cfg-if" version = "1.0.3" @@ -572,6 +601,17 @@ dependencies = [ "inout", ] +[[package]] +name = "clang-sys" +version = "1.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.5.47" @@ -1275,6 +1315,7 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "febef67dc08a956a9ecb04de2b40dbd15ad56be49421aad9ae0cdcbe9a24166c" dependencies = [ + "bindgen", "pkg-config", "semver", ] @@ -1395,6 +1436,12 @@ version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + [[package]] name = "group" version = "0.13.0" @@ -2011,6 +2058,16 @@ dependencies = [ "rand 0.9.2", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link 0.2.1", +] + [[package]] name = "libm" version = "0.2.15" @@ -2119,6 +2176,12 @@ dependencies = [ "unicase", ] +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -2152,6 +2215,16 @@ dependencies = [ "libc", ] +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + [[package]] name = "nu-ansi-term" version = "0.50.1" @@ -2505,6 +2578,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff24dfcda44452b9816fff4cd4227e1bb73ff5a2f1bc1105aa92fb8565ce44d2" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -2672,6 +2755,18 @@ dependencies = [ "bitflags", ] +[[package]] +name = "regex" +version = "1.11.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + [[package]] name = "regex-automata" version = "0.4.10" @@ -2858,6 +2953,12 @@ version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56f7d92ca342cea22a06f2121d944b4fd82af56988c270852495420f961d4ace" +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 6023dd9..85c07af 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -14,7 +14,7 @@ bytemuck = { version = "1.23.2", features = ["derive"] } clap = { version = "4.5.47", features = ["derive"] } color-eyre = "0.6.5" futures = "0.3.31" -gdal = "0.18.0" +gdal = { version = "0.18.0", features = ["bindgen"] } geo = "0.31.0" geojson = { version = "0.24.2", features = ["geo-types"] } h3o = "0.9.4" From 8d650dfbb3a9c28bfd120879443b80ba2c786d9e Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Tue, 17 Mar 2026 21:35:23 +0000 Subject: [PATCH 02/15] Scripts for Galiano Island --- ctl.sh | 2 + scripts/common.bash | 13 ++++ scripts/galiano.bash | 70 +++++++++++++++++ scripts/make_tiff_sample.py | 31 ++++++++ website/src/renderViewsheds.ts | 136 +++++++++++++++++++++++++++++++++ 5 files changed, 252 insertions(+) create mode 100644 scripts/galiano.bash create mode 100644 scripts/make_tiff_sample.py create mode 100644 website/src/renderViewsheds.ts diff --git a/ctl.sh b/ctl.sh index edf26cc..064c7b9 100755 --- a/ctl.sh +++ b/ctl.sh @@ -1,5 +1,7 @@ #!/bin/bash +set -e + export PROJECT_ROOT PROJECT_ROOT=$(dirname "$(readlink -f "$0")") diff --git a/scripts/common.bash b/scripts/common.bash index 0cb7654..08bdd79 100644 --- a/scripts/common.bash +++ b/scripts/common.bash @@ -63,3 +63,16 @@ function get_tiff_latitude { local input=$1 gdalinfo -json "$input" | jq '.geoTransform[3]' } + +function get_extent { + local input=$1 + + gdalinfo \ + -json "$input" | + jq -r ' + .cornerCoordinates.lowerLeft[0], + .cornerCoordinates.lowerLeft[1], + .cornerCoordinates.upperRight[0], + .cornerCoordinates.upperRight[1]' | + xargs +} diff --git a/scripts/galiano.bash b/scripts/galiano.bash new file mode 100644 index 0000000..45ed12a --- /dev/null +++ b/scripts/galiano.bash @@ -0,0 +1,70 @@ +# CRS: NAD83(CSRS) / UTM zone 10N (already metric and equally spaced pixels) +# Center: 467048.260, 5420616.975 (123d26'59.83"W, 48d56'15.37"N) +# Pixels: 87616x78341 (target for factor of 48 87648x87648) +# Resolution: 0.25m (25cm) +# Extent: 456096.26 5410824.35 478000.26 5430409.6 + +# Convert the Galiano DSM to: +# * a square +# * a sauare with a width of factor 48 +# * the given resolution +# +# Usage: `./ctl.sh galiano_prepare scratch/Galiano/dsm.tif scratch/Galiano/dsm-1.0.tiff 1.0` +function galiano_prepare { + local input=$1 + local output=$2 + local resolution=$3 + + read -ra extent < <(_galiano_extent_padded) + + gdalwarp \ + -overwrite \ + -te "${extent[0]}" "${extent[1]}" "${extent[2]}" "${extent[3]}" \ + -tr "$resolution" "$resolution" \ + -r bilinear \ + -dstnodata 0 \ + -co COMPRESS=ZSTD \ + -co ZSTD_LEVEL=9 \ + "$input" \ + "$output" + +} + +function _galiano_extent_padded { + width=$(gdalinfo -json "$input" | jq '.size[0]') + width_outer=$(echo "$width * 1.414" | bc) # Make the circle _outside_ the DEM + target_pixels=$(next_factor "$width_outer") + extend_meters=$(echo "$target_pixels * 0.25 * 1.5" | bc) + read -r xcentre ycentre <<<"$( + gdalinfo \ + -json "$input" | jq -r '.cornerCoordinates.center | join(" ")' + )" + xmin=$(echo "$xcentre - $extend_meters" | bc) + ymin=$(echo "$ycentre - $extend_meters" | bc) + xmax=$(echo "$xcentre + $extend_meters" | bc) + ymax=$(echo "$ycentre + $extend_meters" | bc) + + echo "$xmin $ymin $xmax $ymax" +} + +function next_factor { + local width=$1 + local factor=48 + echo "((($width + $factor - 1) / $factor) * $factor)" | bc +} + +function pad_to_factor { + local input=$1 + temp=$(dirname "$input")/tmp.tiff + + width=$(gdalinfo -json "$input" | jq '.size[0]') + next_by_factor=$(next_factor "$width") + gdal_translate \ + -srcwin 0 0 "$next_by_factor" "$next_by_factor" \ + -a_nodata 0 \ + -co COMPRESS=ZSTD \ + -co ZSTD_LEVEL=9 \ + "$input" "$temp" + + mv "$temp" "$input" +} diff --git a/scripts/make_tiff_sample.py b/scripts/make_tiff_sample.py new file mode 100644 index 0000000..2c01d45 --- /dev/null +++ b/scripts/make_tiff_sample.py @@ -0,0 +1,31 @@ +# /// script +# dependencies = [ +# "rasterio", +# "numpy", +# ] +# /// + +# Helper script to make small sample GeoTiffs for testing etc. + +import numpy as np +import rasterio +from rasterio.transform import from_origin + +data = np.arange(100).reshape((10, 10)).astype("uint16") + +transform = from_origin(0, 10, 1, 1) + +with rasterio.open( + "sample_10x10.tif", + "w", + driver="GTiff", + height=10, + width=10, + count=1, + dtype=data.dtype, + crs="EPSG:4326", + transform=transform, +) as dst: + dst.write(data, 1) + +print("Created sample_10x10.tif successfully.") diff --git a/website/src/renderViewsheds.ts b/website/src/renderViewsheds.ts new file mode 100644 index 0000000..6686be6 --- /dev/null +++ b/website/src/renderViewsheds.ts @@ -0,0 +1,136 @@ +import { type GeoJSONFeature, type GeoJSONSource, LngLat } from 'maplibre-gl'; +import proj4 from 'proj4'; +import { navigate } from 'svelte5-router'; +import { getLongestLine } from './getLongestLine.ts'; +import { state } from './state.svelte.ts'; +import { + aeqdProjectionString, + computeBBox, + disablePointer, + toRadians, +} from './utils.ts'; + +export function setupViewsheds() { + state.map?.addSource('viewshed', { + type: 'geojson', + data: { + type: 'FeatureCollection', + features: [], + }, + }); + + state.map?.addLayer({ + id: 'viewshed-fill', + type: 'fill', + source: 'viewshed', + paint: { + 'fill-color': '#ff0000', + 'fill-outline-color': '#ffffff', + 'fill-opacity': 0.8, + }, + }); + + state.map?.on('click', async (event) => { + if (!state.map) { + return; + } + + render(event.lngLat); + }); +} + +export async function render(lngLat: LngLat) { + const bytes = await getViewshedData(lngLat); + const segments = parseViewshedBytes(bytes); + + if (import.meta.env.DEV) { + console.log(segments); + } + + // longest_line.angle = longest_line.angle + ANGLE_SHIFT; + // + // const θ = toRadians(longest_line.angle); + // const dx = longest_line.distance * Math.cos(θ); + // const dy = longest_line.distance * Math.sin(θ); + // const rotatedClockwiseAEQD = rotate(dx, dy, -0.5); + // const rotatedAntiAEQD = rotate(dx, dy, +0.5); + // + // const aeqd = aeqdProjectionString(lngLat.lng, lngLat.lat); + // const unrotated = proj4(aeqd, proj4.WGS84, [dx, dy]); + // longest_line.from = lngLat; + // longest_line.to = new LngLat(unrotated[0], unrotated[1]); + // state.longestLine = longest_line; + // + // const rotatedClockwiseLonLat = proj4(aeqd, proj4.WGS84, rotatedClockwiseAEQD); + // const rotatedAntiLonLat = proj4( + // aeqd, + // '+proj=longlat +datum=WGS84 +no_defs', + // rotatedAntiAEQD, + // ); + // const viewCoordinates = [ + // lngLat.toArray(), + // rotatedClockwiseLonLat, + // rotatedAntiLonLat, + // lngLat.toArray(), + // ]; + // + // const longestLineGeoJSON = { + // type: 'Feature', + // geometry: { + // type: 'Polygon', + // coordinates: [viewCoordinates], + // }, + // properties: {}, + // } as GeoJSONFeature; + // + // const source = state.map?.getSource('longest-line') as GeoJSONSource; + // + // source?.setData(longestLineGeoJSON); + // + // state.map?.fitBounds(computeBBox(viewCoordinates), { + // padding: 100, + // duration: 1000, + // }); + // state.isFlying = true; + // disablePointer(); +} + +async function getViewshedData(lngLat: LngLat) { + const response = await fetch( + `http://localhost:3333/viewshed/${lngLat.lng},${lngLat.lat}`, + ); + return await response.bytes(); +} + +function parseViewshedBytes(data) { + const buffer = data.buffer || data; + const view = new DataView(buffer); + let offset = 0; + + const angleCount = view.getUint16(offset, false); + console.log(angleCount); + offset += 2; + + const out = []; + + for (let i = 0; i < angleCount; i++) { + const byteLength = view.getUint16(offset, false); + offset += 2; + + const values = []; + const numElements = byteLength / 2; + + for (let j = 0; j < numElements; j++) { + values.push(view.getUint16(offset, false)); + offset += 2; + } + + out.push(values); + } + + if (offset !== buffer.byteLength) { + console.error('Viewshed unpacker did not reach end of data'); + } + + return out; +} From 48e6b80496427d46e9293384787372a24291dffb Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Sun, 5 Apr 2026 00:04:25 +0100 Subject: [PATCH 03/15] Viewshed API server --- Cargo.lock | 104 ++++++++++++++++-- Cargo.toml | 8 +- crates/server/Cargo.toml | 11 +- crates/server/fixtures/sample_16x16.db | Bin 0 -> 57344 bytes crates/server/src/app.rs | 141 +++++++++++++++++++++++++ crates/server/src/config.rs | 9 ++ crates/server/src/main.rs | 58 +++++++--- crates/server/src/metadata.rs | 21 ++++ crates/server/src/test.rs | 93 ++++++++++++++++ crates/server/src/utils.rs | 31 ++++++ crates/tasks/Cargo.toml | 6 +- crates/tasks/src/lib.rs | 8 ++ crates/tasks/src/main.rs | 7 +- crates/tasks/src/projector.rs | 38 ++++++- scripts/make_tiff_sample.py | 37 +++++-- 15 files changed, 533 insertions(+), 39 deletions(-) create mode 100644 crates/server/fixtures/sample_16x16.db create mode 100644 crates/server/src/app.rs create mode 100644 crates/server/src/config.rs create mode 100644 crates/server/src/metadata.rs create mode 100644 crates/server/src/test.rs create mode 100644 crates/server/src/utils.rs create mode 100644 crates/tasks/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index c82b6bd..d9fad74 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -74,6 +74,21 @@ dependencies = [ "memchr", ] +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + [[package]] name = "allocator-api2" version = "0.2.21" @@ -254,6 +269,18 @@ dependencies = [ "password-hash", ] +[[package]] +name = "async-compression" +version = "0.4.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0f9ee0f6e02ffd7ad5816e9464499fba7b3effd01123b515c41d1697c43dad1" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + [[package]] name = "async-ssh2-tokio" version = "0.12.0" @@ -486,6 +513,27 @@ dependencies = [ "cipher", ] +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + [[package]] name = "bumpalo" version = "3.19.0" @@ -520,9 +568,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cbc" @@ -694,6 +742,24 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +[[package]] +name = "compression-codecs" +version = "0.4.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb7b51a7d9c967fc26773061ba86150f19c50c0d65c887cb1fbe295fd16619b7" +dependencies = [ + "brotli", + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -3081,9 +3147,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.224" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aaeb1e94f53b16384af593c71e20b095e958dab1d26939c1b70645c5cfbcc0b" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -3091,18 +3157,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.224" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32f39390fa6346e24defbcdd3d9544ba8a19985d0af74df8501fbfe9a64341ab" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.224" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87ff78ab5e8561c9a675bfc1785cb07ae721f0ee53329a595cefd8c04c2ac4e0" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -3111,15 +3177,15 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.145" +version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "402a6f66d8c709116cf22f558eab210f5a50187f702eb4d7e5ef38d9a7f1c79c" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ "itoa", "memchr", - "ryu", "serde", "serde_core", + "zmij", ] [[package]] @@ -3150,9 +3216,18 @@ name = "server" version = "0.1.0" dependencies = [ "axum", + "bytes", + "clap", + "color-eyre", + "serde", + "serde_json", + "sqlx", + "tasks", "tokio", "tower", "tower-http", + "tracing", + "tracing-subscriber", ] [[package]] @@ -3793,6 +3868,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9cf146f99d442e8e68e585f5d798ccd3cad9a7835b917e09728880a862706456" dependencies = [ + "async-compression", "bitflags", "bytes", "futures-core", @@ -4677,3 +4753,9 @@ dependencies = [ "quote", "syn", ] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml index bbca4ea..8c95c44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,15 @@ [workspace] resolver = "2" -members = [ "crates/server", +members = [ + "crates/server", "crates/tasks", ] +[workspace.dependencies] +clap = { version = "4.5.47", features = ["derive"] } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "ansi"] } + [patch.crates-io] # Just waiting for any release after v0.54.6 and for `async-ssh2-tokio` to update from 0.12.0. russh = { git = "https://github.com/Eugeny/russh", rev = "258c6bbb2877fd8d5295e2bd406fac334890083c" } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 2382f63..fdf1609 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -5,9 +5,18 @@ edition = "2024" [dependencies] axum = "0.8.4" +bytes = "1.11.1" +clap.workspace = true +color-eyre = "0.6.5" +serde = "1.0.228" +serde_json = "1.0.149" +sqlx = "0.8.6" +tasks = { path = "../tasks", version = "0.1.0" } +tracing = "0.1.41" +tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "ansi"] } tokio = { version = "1.47.1", features = ["rt-multi-thread"] } tower = "0.5.2" -tower-http = { version = "0.6.6", features = ["fs", "trace", "cors"] } +tower-http = { version = "0.6.6", features = ["fs", "trace", "cors", "compression-gzip", "compression-br"] } [lints] workspace = true diff --git a/crates/server/fixtures/sample_16x16.db b/crates/server/fixtures/sample_16x16.db new file mode 100644 index 0000000000000000000000000000000000000000..b2c7a54fe62a3fcd6d53a1d36ad22e03b6b9e589 GIT binary patch literal 57344 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 literal 0 HcmV?d00001 diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs new file mode 100644 index 0000000..a6bdd6d --- /dev/null +++ b/crates/server/src/app.rs @@ -0,0 +1,141 @@ +//! The main app code. + +use color_eyre::Result; + +use axum::{ + Router, + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + routing, +}; + +/// All state needed for the app's lifetime. +#[derive(Clone)] +struct AppState { + /// Pool of connections to the DB. + pool: sqlx::Pool, + /// Metadata about the underlying DEM that generated the viewsheds + metadata: crate::metadata::MetaData, +} + +/// Setup the DB +pub async fn db(config: crate::config::Config) -> Result> { + let options = sqlx::sqlite::SqliteConnectOptions::new().filename(config.db_path); + let pool = sqlx::sqlite::SqlitePool::connect_with(options).await?; + + Ok(pool) +} + +/// Server routes. +pub async fn router(pool: sqlx::Pool) -> Result { + let metadata = load_metadata(&pool).await?; + let state = AppState { pool, metadata }; + + let router = Router::new() + .route("/", routing::get(|| async { "hello" })) + .route("/viewshed/{coordinate}", routing::get(get_viewshed)) + .with_state(state) + .layer( + tower_http::compression::CompressionLayer::new() + .gzip(true) + .br(true), + ); + + Ok(router) +} + +/// Load the metadata for the DEM that generated the viewsheds. +async fn load_metadata(pool: &sqlx::Pool) -> Result { + let (json,): (String,) = sqlx::query_as("SELECT json FROM metadata") + .fetch_one(pool) + .await?; + + let metadata: crate::metadata::MetaData = serde_json::from_str(&json)?; + tracing::info!("Loaded metadata: {metadata:?}"); + Ok(metadata) +} + +/// Return the polar segments for an entire viewshed. +async fn get_viewshed( + State(state): State, + Path(coordinate): Path, +) -> impl IntoResponse { + let Ok(lonlat) = tasks::projector::LonLatCoord::parse(&coordinate) else { + return (StatusCode::BAD_REQUEST, "Couldn't parse coordinate").into_response(); + }; + + let Ok(dem_id) = crate::utils::latlon_to_dem_id(&state.metadata, lonlat) else { + return ( + StatusCode::BAD_REQUEST, + "Couldn't get a DEM ID for the provided coordinate", + ) + .into_response(); + }; + + let result: std::result::Result,)>, sqlx::Error> = sqlx::query_as( + " + SELECT MIN(visible_segments) AS visible_segments + FROM polar_segments + WHERE dem_id = ?1 + GROUP BY angle_id + ORDER BY angle_id ASC; + ", + ) + .bind(dem_id) + .fetch_all(&state.pool) + .await; + + let Ok(rows) = result else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Couldn't fetch viewshed data from DB", + ) + .into_response(); + }; + + if rows.is_empty() { + let msg = format!("No viewshed found. Using DEM ID: {dem_id}"); + return (StatusCode::NOT_FOUND, msg).into_response(); + } + + if rows.len() > 360 { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "More than 360 rows found", + ) + .into_response(); + } + + // Framing: `u16` count, then for each blob `u16` length + bytes + let mut payload = Vec::new(); + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "The number of angles must always fit in `u16`" + )] + let angle_count = rows.len() as u16; + payload.extend(angle_count.to_be_bytes()); + + for blob in &rows { + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "The number of segments for a given angle must fit in `u16`" + )] + payload.extend((blob.0.len() as u16).to_be_bytes()); + payload.extend(blob.0.clone()); + } + + let body = bytes::Bytes::from(payload); + tracing::debug!( + "Viewshed request for DEM ID {dem_id} found {} bytes", + body.len() + ); + ( + StatusCode::OK, + [("content-type", "application/octet-stream")], + body, + ) + .into_response() +} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs new file mode 100644 index 0000000..1571068 --- /dev/null +++ b/crates/server/src/config.rs @@ -0,0 +1,9 @@ +//! Config for the CLI + +/// Config arguments. +#[derive(clap::Parser, Debug, Clone, Default)] +pub struct Config { + /// Path to the viewshed database + #[arg(long, value_name = "Database path")] + pub db_path: std::path::PathBuf, +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index d20dc05..117bc2a 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -1,16 +1,48 @@ -//! serves the website +//! An HTTP server to return viewshed data. +#![expect( + clippy::big_endian_bytes, + reason = "It's how we pack the viewhsed blobs" +)] + +use clap::Parser as _; +use color_eyre::Result; +use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _}; + +mod app; +mod config; +mod metadata; +mod utils; + +#[cfg(test)] +mod test; + +/// Entrypoint #[tokio::main] -async fn main() { - let serve_index = tower_http::services::ServeDir::new("./assets"); - - let app = axum::Router::new() - .fallback_service(serve_index) - .layer(tower::ServiceBuilder::new().layer(tower_http::trace::TraceLayer::new_for_http())); - - // run our app with hyper, listening globally on port 3000 - #[expect(clippy::unwrap_used, reason="we need to crash the server if it can't start")] - let listener = tokio::net::TcpListener::bind("0.0.0.0:3333").await.unwrap(); - #[expect(clippy::unwrap_used, reason="we need to crash the server if it can't start")] - axum::serve(listener, app).await.unwrap(); +async fn main() -> Result<()> { + setup_logging()?; + let config = crate::config::Config::parse(); + let pool = app::db(config.clone()).await?; + let router = app::router(pool).await?; + + let address = "0.0.0.0:3333"; + + tracing::info!("Starting server on: {address}"); + + let listener = tokio::net::TcpListener::bind(address).await?; + axum::serve(listener, router).await?; + + Ok(()) +} + +/// Setup logging. +fn setup_logging() -> Result<()> { + let filters = tracing_subscriber::EnvFilter::builder() + .with_default_directive("server=info".parse()?) + .from_env_lossy(); + let filter_layer = tracing_subscriber::fmt::layer().with_filter(filters); + let tracing_setup = tracing_subscriber::registry().with(filter_layer); + tracing_setup.init(); + + Ok(()) } diff --git a/crates/server/src/metadata.rs b/crates/server/src/metadata.rs new file mode 100644 index 0000000..0127181 --- /dev/null +++ b/crates/server/src/metadata.rs @@ -0,0 +1,21 @@ +//! Struct for storing essential data about the underlying DEM for which viewsheds are created. + +// TODO: Make a public lib in the CacheTVS repo. +/// Metadata about the viewshed data. +#[derive(serde::Serialize, serde::Deserialize, Default, Debug, Clone)] +pub struct MetaData { + /// The width of the 2D grid of elevation data. The algorithm requires that the grid be square, + /// so there is no need for a height field. + pub width: u32, + /// The diameter in meters each point of the data covers. + pub scale: f32, + /// The maximum line of sight (in meters) that was used to calculate the ring data. It is needed + /// to instantiate the `DEM` struct and therefore reconstruct the bands of sight used to create + /// the ring data. + pub max_line_of_sight: u32, + /// The number of items reserved to place ring DEM IDs in. + pub reserved_ring_size: usize, + /// The lat/lon coordinates for the centre of the 2D DEM grid. Used for accurately converting + /// between degree and metric coordinate systems. + pub centre: tasks::projector::LonLatCoord, +} diff --git a/crates/server/src/test.rs b/crates/server/src/test.rs new file mode 100644 index 0000000..6ccc20b --- /dev/null +++ b/crates/server/src/test.rs @@ -0,0 +1,93 @@ +#[cfg(test)] +mod tests { + #![expect(clippy::indexing_slicing, reason = "These are just tests")] + + use axum::{ + body::{Body, to_bytes}, + http::{Request, StatusCode}, + }; + use tower::ServiceExt as _; // for `oneshot` + + use std::convert::TryInto as _; + use std::io::Read as _; + + fn read_u16_be(cursor: &mut std::io::Cursor<&[u8]>) -> u16 { + let mut buffer = [0u8; 2]; + cursor.read_exact(&mut buffer).unwrap(); + u16::from_be_bytes(buffer) + } + + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::integer_division, + clippy::integer_division_remainder_used, + reason = "These are just for tests" + )] + fn parse_payload_cursor(data: &[u8]) -> Vec> { + let mut cursor = std::io::Cursor::new(data); + let angle_count = read_u16_be(&mut cursor) as usize; + let mut out = Vec::with_capacity(angle_count); + + for _ in 0..angle_count { + let byte_length = read_u16_be(&mut cursor) as usize; + + let mut values = Vec::with_capacity(byte_length / 2); + let mut buf = vec![0u8; byte_length]; + cursor.read_exact(&mut buf).unwrap(); + for chunk in buf.chunks_exact(2) { + values.push(u16::from_be_bytes(chunk.try_into().unwrap())); + } + out.push(values); + } + + assert!(cursor.position() as usize == data.len()); + out + } + + async fn app() -> axum::Router { + let config = crate::config::Config { + db_path: "./fixtures/sample_16x16.db".into(), + }; + + let pool = crate::app::db(config).await.unwrap(); + crate::app::router(pool).await.unwrap() + } + + #[tokio::test] + async fn hello() { + let response = app() + .await + .oneshot(Request::get("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), 100).await.unwrap(); + + assert_eq!(body, "hello"); + } + + #[tokio::test] + async fn unpacking_viewshed() { + let response = app() + .await + .oneshot( + Request::get("/viewshed/-3.1229856,51.4897910") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK); + + let body = to_bytes(response.into_body(), 10000).await.unwrap(); + + let segments = parse_payload_cursor(&body); + + assert_eq!(segments[0], vec![0, 4]); + assert_eq!(segments[300], vec![0, 2, 3, 1]); + } +} diff --git a/crates/server/src/utils.rs b/crates/server/src/utils.rs new file mode 100644 index 0000000..364f360 --- /dev/null +++ b/crates/server/src/utils.rs @@ -0,0 +1,31 @@ +//! Helper code + +use color_eyre::Result; + +/// Convert a lat/lon to a DEM coordinate. +pub fn latlon_to_dem_id( + metadata: &crate::metadata::MetaData, + latlon: tasks::projector::LonLatCoord, +) -> Result { + let width_f64 = f64::from(metadata.width - 1); + let scale = f64::from(metadata.scale); + let coord_metric = tasks::projector::Convert { + base: metadata.centre, + } + .to_meters(latlon)?; + let offset = (width_f64 * scale) / 2.0f64; + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "The coordinates should always fit in `u32`" + )] + let (x, y) = { + ( + ((coord_metric.x + offset) / scale) as u32, + ((-coord_metric.y + offset) / scale) as u32, + ) + }; + let dem_id = (y * metadata.width) + x; + Ok(dem_id) +} diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 85c07af..731d4e6 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -11,7 +11,7 @@ async-trait = "0.1.89" async-ssh2-tokio = "0.12.0" axum = { version = "0.8", features = ["json", "tokio", "query"] } bytemuck = { version = "1.23.2", features = ["derive"] } -clap = { version = "4.5.47", features = ["derive"] } +clap.workspace = true color-eyre = "0.6.5" futures = "0.3.31" gdal = { version = "0.18.0", features = ["bindgen"] } @@ -25,8 +25,8 @@ serde_json = "1.0.143" srtm_reader = "0.5.1" sqlx = { version = "0.8", features = [ "runtime-tokio" ] } tokio = { version = "1.48.0", features = ["full", "tracing"] } -tracing = "0.1.41" -tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "ansi"] } +tracing.workspace = true +tracing-subscriber.workspace = true tower-http = { version = "0.6.7", features = ["fs", "normalize-path"] } strip-ansi-escapes = "0.2.1" diff --git a/crates/tasks/src/lib.rs b/crates/tasks/src/lib.rs new file mode 100644 index 0000000..35df9a7 --- /dev/null +++ b/crates/tasks/src/lib.rs @@ -0,0 +1,8 @@ +//! Code we want to share amongst internal crates. + +#![allow( + clippy::exhaustive_structs, + reason = "This lib is mostly for our internal use" +)] + +pub mod projector; diff --git a/crates/tasks/src/main.rs b/crates/tasks/src/main.rs index a48181e..ace012a 100644 --- a/crates/tasks/src/main.rs +++ b/crates/tasks/src/main.rs @@ -45,10 +45,15 @@ mod atlas { mod config; mod max_subtile; mod packer; -mod projector; mod stitch; mod tile; +#[expect( + clippy::exhaustive_structs, + reason = "This lib is mostly for our internal use" +)] +pub mod projector; + use clap::Parser as _; use color_eyre::Result; use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _}; diff --git a/crates/tasks/src/projector.rs b/crates/tasks/src/projector.rs index 6ca30d6..1a9e3fe 100644 --- a/crates/tasks/src/projector.rs +++ b/crates/tasks/src/projector.rs @@ -1,19 +1,37 @@ //! Project coordinates between different systems. -use color_eyre::Result; +use color_eyre::{Result, eyre::ContextCompat as _}; /// The radius of the planet in kilometers. pub const EARTH_RADIUS: f32 = 6371.0; -/// A latitude/longtitude coordinate. +/// A longtitude/latitude coordinate. #[derive(Debug, Clone, Copy, serde::Serialize, serde::Deserialize, PartialEq, Default)] pub struct LonLatCoord(pub geo::Coord); -#[expect(clippy::unreachable, reason = "This is just a 2D coordinate.")] +impl LonLatCoord { + /// Parse coordinates from a string. + /// + /// # Errors + /// On parsing errors + #[inline] + pub fn parse(coordinates: &str) -> Result { + let mut parts = coordinates.split(','); + let lon_str = parts.next().context("missing longtitude")?.trim(); + let lat_str = parts.next().context("missing latitude")?.trim(); + + Ok(Self(geo::Coord { + x: lon_str.parse()?, + y: lat_str.parse()?, + })) + } +} + impl rstar::Point for LonLatCoord { type Scalar = f64; const DIMENSIONS: usize = 2; + #[inline] fn generate(mut generator: impl FnMut(usize) -> Self::Scalar) -> Self { Self(geo::coord! { x: generator(0), @@ -21,18 +39,22 @@ impl rstar::Point for LonLatCoord { }) } + #[inline] fn nth(&self, index: usize) -> Self::Scalar { match index { 0 => self.0.x, 1 => self.0.y, + #[expect(clippy::unreachable, reason = "This is just a 2D coordinate.")] _ => unreachable!(), } } + #[inline] fn nth_mut(&mut self, index: usize) -> &mut Self::Scalar { match index { 0 => &mut self.0.x, 1 => &mut self.0.y, + #[expect(clippy::unreachable, reason = "This is just a 2D coordinate.")] _ => unreachable!(), } } @@ -60,7 +82,11 @@ impl Convert { Ok(proj4rs::Proj::from_proj_string(&string)?) } + #[inline] /// Convert from degrees to the AEQD metric projection. + /// + /// # Errors + /// When projection conversion fails. pub fn to_meters(&self, source: LonLatCoord) -> Result { let mut converted = (source.0.x.to_radians(), source.0.y.to_radians(), 0.0f64); proj4rs::transform::transform( @@ -72,7 +98,11 @@ impl Convert { Ok(geo::coord! { x: converted.0, y: converted.1 }) } + #[inline] /// Convert from the AEQD metric projection to degrees. + /// + /// # Errors + /// When projection conversion fails. pub fn to_degrees(&self, source: geo::Coord) -> Result { let mut converted = (source.x, source.y, 0.0f64); proj4rs::transform::transform( @@ -86,6 +116,8 @@ impl Convert { )) } + #[inline] + #[must_use] /// Calculate the width of a degree in meters at the given latitude. pub fn meters_per_degree(latitude: f64) -> f32 { #[expect( diff --git a/scripts/make_tiff_sample.py b/scripts/make_tiff_sample.py index 2c01d45..422bb6f 100644 --- a/scripts/make_tiff_sample.py +++ b/scripts/make_tiff_sample.py @@ -11,16 +11,36 @@ import rasterio from rasterio.transform import from_origin -data = np.arange(100).reshape((10, 10)).astype("uint16") +# fmt: off +x = 15 +dem = [ + 0,0,0,0, 0,0,0,0, 0,0,0,1, + 0,1,1,1, 1,1,1,1, 0,1,0,0, + 0,1,3,3, 2,2,2,3, 4,3,0,0, + 0,1,3,4, 3,3,3,3, 3,2,1,0, -transform = from_origin(0, 10, 1, 1) + 0,1,3,4, 5,5,5,4, 3,2,1,0, + 0,1,3,4, 4,0,6,5, 4,2,1,0, + 0,1,1,2, 5,9,x,7, 5,2,1,0, + 0,1,1,4, 4,4,4,6, 3,2,1,0, + + 0,0,3,4, 4,4,5,4, 0,0,0,0, + 0,3,3,4, 3,2,3,3, 0,0,0,0, + 0,0,3,4, 2,2,1,2, 0,0,0,0, + 3,0,2,3, 1,0,1,0, 0,0,0,0, +] +# fmt: on + +data = np.array(dem, dtype="uint16").reshape(12, 12) + +transform = from_origin(0, 12, 1, 1) with rasterio.open( - "sample_10x10.tif", + "sample_16x16.tiff", "w", driver="GTiff", - height=10, - width=10, + height=12, + width=12, count=1, dtype=data.dtype, crs="EPSG:4326", @@ -28,4 +48,9 @@ ) as dst: dst.write(data, 1) -print("Created sample_10x10.tif successfully.") +print("Created sample_16x16.tiff successfully.") + +# gdal_edit.py \ +# -a_srs "+proj=aeqd +lat_0=51.4898 +lon_0=-3.123 +units=m" \ +# -a_ullr -6 6 6 -6 \ +# sample_16x16.tiff From 78134c474db76cbc1f293114273ea6755d5602cf Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Tue, 7 Apr 2026 17:02:34 +0100 Subject: [PATCH 04/15] website: First attempt at using viewshed API --- website/package.json | 1 + website/pnpm-lock.yaml | 3 + website/src/HeatmapLayer.ts | 27 ++++---- website/src/Home.svelte | 27 ++++---- website/src/config.ts | 62 ++++++++++++++++++ website/src/polarSegments.ts | 83 ++++++++++++++++++++++++ website/src/renderLongestLine.ts | 13 +--- website/src/renderViewsheds.ts | 104 +++++++------------------------ website/src/styles/index.scss | 2 +- website/src/utils.ts | 30 +++++++++ 10 files changed, 233 insertions(+), 119 deletions(-) create mode 100644 website/src/config.ts create mode 100644 website/src/polarSegments.ts diff --git a/website/package.json b/website/package.json index 97c94d0..ed319f5 100644 --- a/website/package.json +++ b/website/package.json @@ -27,6 +27,7 @@ "@rsbuild/core": "^1.6.0", "@rsbuild/plugin-sass": "^1.4.0", "@rsbuild/plugin-svelte": "^1.0.11", + "@types/geojson": "^7946.0.16", "pmtiles": "^4.3.0", "prettier": "^3.6.2", "sass": "^1.97.3", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 29696fe..2fcb072 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -45,6 +45,9 @@ importers: '@rsbuild/plugin-svelte': specifier: ^1.0.11 version: 1.0.11(@rsbuild/core@1.6.7)(postcss@8.5.6)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3) + '@types/geojson': + specifier: ^7946.0.16 + version: 7946.0.16 pmtiles: specifier: ^4.3.0 version: 4.3.0 diff --git a/website/src/HeatmapLayer.ts b/website/src/HeatmapLayer.ts index 75d6e05..f254abe 100644 --- a/website/src/HeatmapLayer.ts +++ b/website/src/HeatmapLayer.ts @@ -3,13 +3,14 @@ import { LngLatBounds, type Map as MapLibre, } from 'maplibre-gl'; +import { getConfig } from './config.ts'; import fragment from './fragment.glsl?raw'; import { state as sharedState } from './state.svelte.ts'; import { getParentTile, + getPMTilesSource, isTileIntersectingBounds, Log, - PMTILES_SERVER, packFloatToU8s, tileKey, } from './utils'; @@ -49,13 +50,11 @@ type HeatmapState = } | undefined; -const config: { tileSize: number } = { +const tilingConfig: { tileSize: number } = { tileSize: 256, }; -// The average surface area visibile from a point far out at sea, where it can only see sea. -// This is used to fill regions for which there is no elevation data. -const AVERAGE_SURFACE_VISIBILITY = 700000.0; +const config = getConfig(); let fillerTile: TileGL; @@ -66,11 +65,7 @@ function initialise() { return; } - const params = new URLSearchParams(self.location.search); - let source = params.get('pmtiles'); - if (!source) { - source = PMTILES_SERVER; - } + const source = getPMTilesSource(); heatmapState.worker.postMessage({ type: 'init', source }); heatmapState.worker.onmessage = onWorkerMessage; @@ -322,7 +317,7 @@ const HeatmapLayer: CustomLayerInterface = { ); gl.uniform1f( heatmapState.uniforms.uAverageSurfaceVisibility, - AVERAGE_SURFACE_VISIBILITY, + config.heatmap.averageVisibility, ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); @@ -367,8 +362,8 @@ function makeTile( heatmapState.gl.TEXTURE_2D, 0, heatmapState.gl.RGBA8UI, - config.tileSize, - config.tileSize, + tilingConfig.tileSize, + tilingConfig.tileSize, 0, heatmapState.gl.RGBA_INTEGER, heatmapState.gl.UNSIGNED_BYTE, @@ -384,12 +379,12 @@ function makeTile( } function makeFillerTile() { - const data = new Uint8Array(config.tileSize ** 2 * 4); - data.set(packFloatToU8s(AVERAGE_SURFACE_VISIBILITY)); + const data = new Uint8Array(tilingConfig.tileSize ** 2 * 4); + data.set(packFloatToU8s(config.heatmap.averageVisibility)); const tile = makeTile( 'filler', - AVERAGE_SURFACE_VISIBILITY, + config.heatmap.averageVisibility, new LngLatBounds(), data, ); diff --git a/website/src/Home.svelte b/website/src/Home.svelte index 0e311e8..ab85e93 100644 --- a/website/src/Home.svelte +++ b/website/src/Home.svelte @@ -19,6 +19,7 @@ import ClickEffect, { initClickEffect } from './ClickEffect.svelte'; import CollapsableModal from './components/CollapsableModal.svelte'; import LayerToggle from './components/LayerToggle.svelte'; + import { getConfig, getViewViewRegion } from './config.ts'; import { HeatmapLayer } from './HeatmapLayer.ts'; import heatmap_layer from './images/heatmap_layer.png'; import mountain_peak from './images/mountain_peak.png'; @@ -26,6 +27,7 @@ import Layout from './Layout.svelte'; import map_vector from './map_vector.styles.json'; import { render, setupLongestLines } from './renderLongestLine.ts'; + import { setupViewsheds } from './renderViewsheds.ts'; import Slider from './Slider.svelte'; import { state } from './state.svelte.ts'; import TopLines from './TopLines.svelte'; @@ -36,10 +38,7 @@ } from './worldLines.ts'; let { longest } = $props(); - const minZoom = 1.6; - const maxZoom = 15; - const startingZoom = 2.0; - const startingCentre = new LngLat(-5.0, 25.0); + const config = getConfig(); function addHeatmapLayer() { // 'mountain_peaks' is used here to mean, mountain peaks and every other layer after it. @@ -74,8 +73,8 @@ const viewportHeight = state.map?.getContainer().clientHeight; if (viewportHeight === undefined) { return { - center: startingCentre, - zoom: startingZoom, + center: config.map.startingCentre, + zoom: config.map.startingZoom, }; } @@ -94,7 +93,7 @@ return { center: new LngLat(lngLat.lng, latitude), - zoom: Math.max(minZoom, Math.min(maxZoom, zoom)), + zoom: Math.max(config.map.minZoom, Math.min(config.map.maxZoom, zoom)), }; } @@ -110,8 +109,8 @@ onMount(async () => { state.map = new MapLibre({ container: 'map', - zoom: startingZoom, - center: startingCentre, + zoom: config.map.startingZoom, + center: config.map.startingCentre, style: map_vector as StyleSpecification, transformConstrain, }); @@ -136,7 +135,12 @@ if (longest === '') { addHeatmapLayer(); } - setupLongestLines(longest); + if (getViewViewRegion() === 'world') { + setupLongestLines(longest); + } + if (getViewViewRegion() === 'galiano') { + setupViewsheds(); + } await updateTopLongestLines(); }); @@ -170,7 +174,8 @@
- { state.isSearchOpen = false; diff --git a/website/src/config.ts b/website/src/config.ts new file mode 100644 index 0000000..eadf2ee --- /dev/null +++ b/website/src/config.ts @@ -0,0 +1,62 @@ +import { LngLat } from 'maplibre-gl'; +import { getPMTilesSource, getSubdomain } from './utils'; + +type Config = { + map: { + minZoom: number; + maxZoom: number; + startingZoom: number; + startingCentre: LngLat; + }; + heatmap: { + // The average surface area visibile from a point far out at sea, where it can only see sea. + // This is used to fill regions for which there is no elevation data. + averageVisibility: number; + }; +}; + +export const worldConfig: Config = { + map: { + minZoom: 1.6, + maxZoom: 16, + startingZoom: 2.0, + startingCentre: new LngLat(-5.0, 25.0), + }, + heatmap: { + averageVisibility: 700000.0, + }, +}; + +export const galianoConfig: Config = { + map: { + minZoom: 11, + maxZoom: 16, + startingZoom: 11.0, + startingCentre: new LngLat(-123.445503, 48.934705), + }, + heatmap: { + averageVisibility: 900000.0, + }, +}; + +export function getViewViewRegion() { + const galianoSignature = 'galiano'; + if ( + getSubdomain()?.includes(galianoSignature) || + getPMTilesSource()?.includes(galianoSignature) + ) { + console.log('Using Galiano config'); + return 'galiano'; + } + + return 'world'; +} + +export function getConfig() { + switch (getViewViewRegion()) { + case 'galiano': + return galianoConfig; + default: + return worldConfig; + } +} diff --git a/website/src/polarSegments.ts b/website/src/polarSegments.ts new file mode 100644 index 0000000..2aceb28 --- /dev/null +++ b/website/src/polarSegments.ts @@ -0,0 +1,83 @@ +import type { Feature, FeatureCollection, Polygon } from 'geojson'; +import type { LngLat } from 'maplibre-gl'; +import proj4 from 'proj4'; +import { ANGLE_SHIFT, aeqdProjectionString, rotate, toRadians } from './utils'; + +export type PolarSegments = { angleID: number; pairs: number[] }; + +export class Viewshed { + centre: LngLat; + polar_segments: PolarSegments[]; + scale: number = 100; // TODO: make configurable + + constructor(centre: LngLat, polar_segments: PolarSegments[]) { + this.centre = centre; + this.polar_segments = polar_segments; + } + + create() { + const features = []; + for (const polar_segment of this.polar_segments) { + for (let i = 0; i < polar_segment.pairs.length; i += 2) { + const start = polar_segment.pairs[i] * this.scale; + const end = start + polar_segment.pairs[i + 1] * this.scale; + const latLonStart = this.polarDistanceToPair( + polar_segment.angleID, + start, + ); + const latLonEnd = this.polarDistanceToPair(polar_segment.angleID, end); + const polygon: Feature = { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + latLonStart[0], + latLonStart[1], + latLonEnd[1], + latLonEnd[0], + latLonStart[0], + ], + ], + }, + properties: {}, + }; + + features.push(polygon); + } + } + + const collection: FeatureCollection = { + type: 'FeatureCollection', + features: features, + }; + + return collection; + } + + polarDistanceToPair(angleID: number, distance: number) { + // TODO: Don't assume that the kernel is computing 360 angles. + const angle = angleID + ANGLE_SHIFT; + + const θ = toRadians(angle); + const dx = distance * Math.cos(θ); + const dy = distance * Math.sin(θ); + const rotatedClockwiseAEQD = rotate(dx, dy, -0.5); + const rotatedAntiAEQD = rotate(dx, dy, +0.5); + + const aeqd = aeqdProjectionString(this.centre.lng, this.centre.lat); + + const rotatedClockwiseLonLat = proj4( + aeqd, + proj4.WGS84, + rotatedClockwiseAEQD, + ); + const rotatedAntiLonLat = proj4( + aeqd, + '+proj=longlat +datum=WGS84 +no_defs', + rotatedAntiAEQD, + ); + + return [rotatedClockwiseLonLat, rotatedAntiLonLat]; + } +} diff --git a/website/src/renderLongestLine.ts b/website/src/renderLongestLine.ts index 3e07d82..1875aa3 100644 --- a/website/src/renderLongestLine.ts +++ b/website/src/renderLongestLine.ts @@ -4,15 +4,14 @@ import { navigate } from 'svelte5-router'; import { getLongestLine } from './getLongestLine.ts'; import { state } from './state.svelte.ts'; import { + ANGLE_SHIFT, aeqdProjectionString, computeBBox, disablePointer, + rotate, toRadians, } from './utils.ts'; -// Inherited from the TVS algorithm. It's to counter unfavourable floating point rounding. -const ANGLE_SHIFT = 0.0001; - export function setupLongestLines(coordFromURL: string | undefined) { state.map?.addSource('longest-line', { type: 'geojson', @@ -118,14 +117,6 @@ export async function render(lngLat: LngLat) { disablePointer(); } -// Rotate a coordinate around the origin. -function rotate(x: number, y: number, degrees: number) { - const θ = degrees * (Math.PI / 180); - const cos = Math.cos(θ); - const sin = Math.sin(θ); - return [x * cos - y * sin, x * sin + y * cos]; -} - export function longestLineURL(lon: number, lat: number) { return `/longest/${lon}_${lat}${window.location.search}`; } diff --git a/website/src/renderViewsheds.ts b/website/src/renderViewsheds.ts index 6686be6..3be82cd 100644 --- a/website/src/renderViewsheds.ts +++ b/website/src/renderViewsheds.ts @@ -1,14 +1,6 @@ -import { type GeoJSONFeature, type GeoJSONSource, LngLat } from 'maplibre-gl'; -import proj4 from 'proj4'; -import { navigate } from 'svelte5-router'; -import { getLongestLine } from './getLongestLine.ts'; +import type { GeoJSONSource, LngLat } from 'maplibre-gl'; +import { type PolarSegments, Viewshed } from './polarSegments.ts'; import { state } from './state.svelte.ts'; -import { - aeqdProjectionString, - computeBBox, - disablePointer, - toRadians, -} from './utils.ts'; export function setupViewsheds() { state.map?.addSource('viewshed', { @@ -24,9 +16,9 @@ export function setupViewsheds() { type: 'fill', source: 'viewshed', paint: { - 'fill-color': '#ff0000', - 'fill-outline-color': '#ffffff', - 'fill-opacity': 0.8, + 'fill-color': '#00ff00', + 'fill-outline-color': '#00ff00', + 'fill-opacity': 0.3, }, }); @@ -43,93 +35,45 @@ export async function render(lngLat: LngLat) { const bytes = await getViewshedData(lngLat); const segments = parseViewshedBytes(bytes); - if (import.meta.env.DEV) { - console.log(segments); - } + const builder = new Viewshed(lngLat, segments); + const viewshed = builder.create(); + + const source = state.map?.getSource('viewshed') as GeoJSONSource; - // longest_line.angle = longest_line.angle + ANGLE_SHIFT; - // - // const θ = toRadians(longest_line.angle); - // const dx = longest_line.distance * Math.cos(θ); - // const dy = longest_line.distance * Math.sin(θ); - // const rotatedClockwiseAEQD = rotate(dx, dy, -0.5); - // const rotatedAntiAEQD = rotate(dx, dy, +0.5); - // - // const aeqd = aeqdProjectionString(lngLat.lng, lngLat.lat); - // const unrotated = proj4(aeqd, proj4.WGS84, [dx, dy]); - // longest_line.from = lngLat; - // longest_line.to = new LngLat(unrotated[0], unrotated[1]); - // state.longestLine = longest_line; - // - // const rotatedClockwiseLonLat = proj4(aeqd, proj4.WGS84, rotatedClockwiseAEQD); - // const rotatedAntiLonLat = proj4( - // aeqd, - // '+proj=longlat +datum=WGS84 +no_defs', - // rotatedAntiAEQD, - // ); - // const viewCoordinates = [ - // lngLat.toArray(), - // rotatedClockwiseLonLat, - // rotatedAntiLonLat, - // lngLat.toArray(), - // ]; - // - // const longestLineGeoJSON = { - // type: 'Feature', - // geometry: { - // type: 'Polygon', - // coordinates: [viewCoordinates], - // }, - // properties: {}, - // } as GeoJSONFeature; - // - // const source = state.map?.getSource('longest-line') as GeoJSONSource; - // - // source?.setData(longestLineGeoJSON); - // - // state.map?.fitBounds(computeBBox(viewCoordinates), { - // padding: 100, - // duration: 1000, - // }); - // state.isFlying = true; - // disablePointer(); + if (viewshed) { + source?.setData(viewshed); + } } async function getViewshedData(lngLat: LngLat) { const response = await fetch( - `http://localhost:3333/viewshed/${lngLat.lng},${lngLat.lat}`, + `https://api.alltheviews.world/viewshed/${lngLat.lng},${lngLat.lat}`, ); return await response.bytes(); } -function parseViewshedBytes(data) { - const buffer = data.buffer || data; +function parseViewshedBytes(data: Uint8Array): PolarSegments[] { + const buffer = data.buffer; const view = new DataView(buffer); let offset = 0; + const out: PolarSegments[] = []; - const angleCount = view.getUint16(offset, false); - console.log(angleCount); - offset += 2; - - const out = []; + while (offset < buffer.byteLength) { + const angleID = view.getUint16(offset, false); + offset += 2; - for (let i = 0; i < angleCount; i++) { - const byteLength = view.getUint16(offset, false); + const segmentsLength = view.getUint16(offset, false); offset += 2; - const values = []; - const numElements = byteLength / 2; + const pairs = []; + const numElements = segmentsLength / 2; for (let j = 0; j < numElements; j++) { - values.push(view.getUint16(offset, false)); + pairs.push(view.getUint16(offset, false)); offset += 2; } - out.push(values); - } - - if (offset !== buffer.byteLength) { - console.error('Viewshed unpacker did not reach end of data'); + out.push({ angleID, pairs: pairs }); } return out; diff --git a/website/src/styles/index.scss b/website/src/styles/index.scss index 4c03016..4eb5928 100644 --- a/website/src/styles/index.scss +++ b/website/src/styles/index.scss @@ -61,4 +61,4 @@ a { line-height: 1.2; padding: 2px 4px; } -} \ No newline at end of file +} diff --git a/website/src/utils.ts b/website/src/utils.ts index 05cd4c6..50f2542 100644 --- a/website/src/utils.ts +++ b/website/src/utils.ts @@ -1,5 +1,6 @@ import nprogress from 'accessible-nprogress'; import { LngLat, LngLatBounds } from 'maplibre-gl'; +import { getViewViewRegion } from './config'; export const VERSION = 'ryan-fullworld-raw'; export const CDN_BUCKET = 'https://cdn.alltheviews.world'; @@ -14,6 +15,9 @@ export const CACHE_BUSTER = '?buster=19:26-20/01/2026'; export const EARTH_RADIUS = 6371_000.0; +// Inherited from the TVS algorithm. It's to counter unfavourable floating point rounding. +export const ANGLE_SHIFT = 0.0001; + export const Log = { // biome-ignore lint/suspicious/noExplicitAny: needed for debugging. debug: (...data: any[]) => { @@ -148,3 +152,29 @@ export function enablePointer() { if (!root) return; root.classList.remove('disable-pointer'); } + +// Rotate a coordinate around the origin. +export function rotate(x: number, y: number, degrees: number) { + const θ = degrees * (Math.PI / 180); + const cos = Math.cos(θ); + const sin = Math.sin(θ); + return [x * cos - y * sin, x * sin + y * cos]; +} + +export function getSubdomain() { + const parts = window.location.hostname.split('.'); + return parts.length > 2 ? parts[0] : null; +} + +export function getPMTilesSource() { + const params = new URLSearchParams(self.location.search); + const source = params.get('pmtiles'); + if (!source) { + if (getViewViewRegion() === 'galiano') { + return 'https://pmtiles.alltheviews.world/runs/galiano/pmtiles/galiano'; + } + return PMTILES_SERVER; + } else { + return source; + } +} From 2be2dd9a3978742838329397547b6c06cbbf41ef Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Wed, 8 Apr 2026 16:34:55 +0100 Subject: [PATCH 05/15] galiano: removed most UI elements ready for viewshed ones And a fair bit of refactoring --- scripts/mass_edit_map_json.py | 14 + website/src/HeatmapLayer.ts | 13 +- website/src/Home.svelte | 289 +++--------------- website/src/Layout.svelte | 4 +- website/src/LinesDump.svelte | 4 +- website/src/Packing.svelte | 2 +- website/src/Worker.ts | 2 +- website/src/components/LayerToggle.svelte | 2 +- website/src/components/LayerToggles.svelte | 62 ++++ website/src/components/SearchBox.svelte | 3 +- website/src/config.ts | 11 +- website/src/{ => lib}/getLongestLine.ts | 0 website/src/lib/mapConstrains.ts | 66 ++++ website/src/{ => lib}/polarSegments.ts | 5 +- website/src/{ => lib}/renderLongestLine.ts | 2 +- website/src/{ => lib}/renderViewsheds.ts | 15 +- website/src/{ => lib}/utils.ts | 20 +- website/src/{ => lib}/worldLines.ts | 2 +- website/src/map_vector.styles.json | 278 ++++++++++++----- website/src/modals/Acknowledgements.svelte | 26 ++ website/src/modals/CurrentLine.svelte | 33 ++ website/src/modals/TopLines.svelte | 51 ++++ .../TopLinesContent.svelte} | 4 +- website/src/modals/Welcome.svelte | 38 +++ website/src/state.svelte.ts | 58 ++-- website/src/styles/index.scss | 6 + 26 files changed, 619 insertions(+), 391 deletions(-) create mode 100644 scripts/mass_edit_map_json.py create mode 100644 website/src/components/LayerToggles.svelte rename website/src/{ => lib}/getLongestLine.ts (100%) create mode 100644 website/src/lib/mapConstrains.ts rename website/src/{ => lib}/polarSegments.ts (94%) rename website/src/{ => lib}/renderLongestLine.ts (98%) rename website/src/{ => lib}/renderViewsheds.ts (80%) rename website/src/{ => lib}/utils.ts (89%) rename website/src/{ => lib}/worldLines.ts (98%) create mode 100644 website/src/modals/Acknowledgements.svelte create mode 100644 website/src/modals/CurrentLine.svelte create mode 100644 website/src/modals/TopLines.svelte rename website/src/{TopLines.svelte => modals/TopLinesContent.svelte} (95%) create mode 100644 website/src/modals/Welcome.svelte diff --git a/scripts/mass_edit_map_json.py b/scripts/mass_edit_map_json.py new file mode 100644 index 0000000..197701c --- /dev/null +++ b/scripts/mass_edit_map_json.py @@ -0,0 +1,14 @@ +import json + +file = "website/src/map_vector.styles.json" + +with open(file, "r") as f: + data = json.load(f) + +for layer in data.get("layers", []): + if "layout" not in layer: + layer["layout"] = {} + layer["layout"]["visibility"] = "none" + +with open(file, "w") as f: + json.dump(data, f, indent=2) diff --git a/website/src/HeatmapLayer.ts b/website/src/HeatmapLayer.ts index f254abe..d7471d6 100644 --- a/website/src/HeatmapLayer.ts +++ b/website/src/HeatmapLayer.ts @@ -3,9 +3,7 @@ import { LngLatBounds, type Map as MapLibre, } from 'maplibre-gl'; -import { getConfig } from './config.ts'; import fragment from './fragment.glsl?raw'; -import { state as sharedState } from './state.svelte.ts'; import { getParentTile, getPMTilesSource, @@ -13,7 +11,8 @@ import { Log, packFloatToU8s, tileKey, -} from './utils'; +} from './lib/utils'; +import { state as sharedState, state } from './state.svelte.ts'; import vertex from './vertex.glsl?raw'; import type { WorkerEvent } from './Worker'; @@ -54,8 +53,6 @@ const tilingConfig: { tileSize: number } = { tileSize: 256, }; -const config = getConfig(); - let fillerTile: TileGL; let heatmapState: HeatmapState; @@ -317,7 +314,7 @@ const HeatmapLayer: CustomLayerInterface = { ); gl.uniform1f( heatmapState.uniforms.uAverageSurfaceVisibility, - config.heatmap.averageVisibility, + state.config.heatmap.averageVisibility, ); gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4); @@ -380,11 +377,11 @@ function makeTile( function makeFillerTile() { const data = new Uint8Array(tilingConfig.tileSize ** 2 * 4); - data.set(packFloatToU8s(config.heatmap.averageVisibility)); + data.set(packFloatToU8s(state.config.heatmap.averageVisibility)); const tile = makeTile( 'filler', - config.heatmap.averageVisibility, + state.config.heatmap.averageVisibility, new LngLatBounds(), data, ); diff --git a/website/src/Home.svelte b/website/src/Home.svelte index ab85e93..e31b1a6 100644 --- a/website/src/Home.svelte +++ b/website/src/Home.svelte @@ -1,41 +1,30 @@ + +
+ { + state.map?.setLayoutProperty( + HeatmapLayer.id, + "visibility", + isToggled ? "visible" : "none", + ); + }} + /> + setVectorVisibility(state, isToggled)} + isToggled={state.config.project === "world"} + /> + { + state.map?.setLayoutProperty( + "mountain_peaks", + "visibility", + isToggled ? "visible" : "none", + ); + }} + isToggled={state.config.project === "world"} + /> +
+ + diff --git a/website/src/components/SearchBox.svelte b/website/src/components/SearchBox.svelte index cc82cd8..812b495 100644 --- a/website/src/components/SearchBox.svelte +++ b/website/src/components/SearchBox.svelte @@ -2,8 +2,8 @@ import { Search } from '@lucide/svelte'; import { MapboxSearchBox } from '@mapbox/search-js-web'; import { onMount } from 'svelte'; + import { disablePointer } from '../lib/utils.ts'; import { state } from '../state.svelte.ts'; - import { disablePointer } from '../utils.ts'; onMount(() => { const searchBox = new MapboxSearchBox(); @@ -114,4 +114,3 @@ } } - diff --git a/website/src/config.ts b/website/src/config.ts index eadf2ee..61422da 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -1,7 +1,10 @@ import { LngLat } from 'maplibre-gl'; -import { getPMTilesSource, getSubdomain } from './utils'; +import { getPMTilesSource, getSubdomain } from './lib/utils'; + +export type ViewViewProject = 'world' | 'galiano'; type Config = { + project: ViewViewProject; map: { minZoom: number; maxZoom: number; @@ -16,6 +19,7 @@ type Config = { }; export const worldConfig: Config = { + project: 'world', map: { minZoom: 1.6, maxZoom: 16, @@ -28,6 +32,7 @@ export const worldConfig: Config = { }; export const galianoConfig: Config = { + project: 'galiano', map: { minZoom: 11, maxZoom: 16, @@ -39,7 +44,7 @@ export const galianoConfig: Config = { }, }; -export function getViewViewRegion() { +export function getViewViewProject() { const galianoSignature = 'galiano'; if ( getSubdomain()?.includes(galianoSignature) || @@ -53,7 +58,7 @@ export function getViewViewRegion() { } export function getConfig() { - switch (getViewViewRegion()) { + switch (getViewViewProject()) { case 'galiano': return galianoConfig; default: diff --git a/website/src/getLongestLine.ts b/website/src/lib/getLongestLine.ts similarity index 100% rename from website/src/getLongestLine.ts rename to website/src/lib/getLongestLine.ts diff --git a/website/src/lib/mapConstrains.ts b/website/src/lib/mapConstrains.ts new file mode 100644 index 0000000..d7be0f2 --- /dev/null +++ b/website/src/lib/mapConstrains.ts @@ -0,0 +1,66 @@ +import { LngLat } from 'maplibre-gl'; +import type { AppState } from '../state.svelte'; + +export function transformConstrain(state: AppState) { + return (lngLat: LngLat, zoom: number) => { + return transformConstrainInner(lngLat, zoom, state); + }; +} + +// Custom map bounds that allows hiding most of Antartica, whilst still allowing infinite horizontal +// scroll. +// +// For an official fix, follow: https://github.com/maplibre/maplibre-gl-js/issues/6148 +function transformConstrainInner( + lngLat: LngLat, + zoom: number, + state: AppState, +) { + const latitudeToMercatorY = (latitude: number) => { + return ( + 0.5 - + (0.25 * + Math.log( + (1 + Math.sin((latitude * Math.PI) / 180)) / + (1 - Math.sin((latitude * Math.PI) / 180)), + )) / + Math.PI + ); + }; + + const mercatorYToLatitude = (mercatorY: number) => { + return ( + (360 / Math.PI) * Math.atan(Math.exp((0.5 - mercatorY) * 2 * Math.PI)) - + 90 + ); + }; + + const viewportHeight = state.map?.getContainer().clientHeight; + if (viewportHeight === undefined) { + return { + center: state.config.map.startingCentre, + zoom: state.config.map.startingZoom, + }; + } + + const upperLatitudeBound = 85; + const lowerLatitudeBound = -80; + + const worldSize = 512 * 2 ** zoom; + const mercatorYOffset = viewportHeight / 2 / worldSize; + + const maxMercatorY = latitudeToMercatorY(upperLatitudeBound); + const maxLatitude = mercatorYToLatitude(maxMercatorY + mercatorYOffset); + const minMercatorY = latitudeToMercatorY(lowerLatitudeBound); + const minLatitude = mercatorYToLatitude(minMercatorY - mercatorYOffset); + + const latitude = Math.max(minLatitude, Math.min(maxLatitude, lngLat.lat)); + + return { + center: new LngLat(lngLat.lng, latitude), + zoom: Math.max( + state.config.map.minZoom, + Math.min(state.config.map.maxZoom, zoom), + ), + }; +} diff --git a/website/src/polarSegments.ts b/website/src/lib/polarSegments.ts similarity index 94% rename from website/src/polarSegments.ts rename to website/src/lib/polarSegments.ts index 2aceb28..7bf8c10 100644 --- a/website/src/polarSegments.ts +++ b/website/src/lib/polarSegments.ts @@ -8,10 +8,11 @@ export type PolarSegments = { angleID: number; pairs: number[] }; export class Viewshed { centre: LngLat; polar_segments: PolarSegments[]; - scale: number = 100; // TODO: make configurable + scale: number; - constructor(centre: LngLat, polar_segments: PolarSegments[]) { + constructor(centre: LngLat, scale: number, polar_segments: PolarSegments[]) { this.centre = centre; + this.scale = scale; this.polar_segments = polar_segments; } diff --git a/website/src/renderLongestLine.ts b/website/src/lib/renderLongestLine.ts similarity index 98% rename from website/src/renderLongestLine.ts rename to website/src/lib/renderLongestLine.ts index 1875aa3..4200f71 100644 --- a/website/src/renderLongestLine.ts +++ b/website/src/lib/renderLongestLine.ts @@ -1,8 +1,8 @@ import { type GeoJSONFeature, type GeoJSONSource, LngLat } from 'maplibre-gl'; import proj4 from 'proj4'; import { navigate } from 'svelte5-router'; +import { state } from '../state.svelte.ts'; import { getLongestLine } from './getLongestLine.ts'; -import { state } from './state.svelte.ts'; import { ANGLE_SHIFT, aeqdProjectionString, diff --git a/website/src/renderViewsheds.ts b/website/src/lib/renderViewsheds.ts similarity index 80% rename from website/src/renderViewsheds.ts rename to website/src/lib/renderViewsheds.ts index 3be82cd..fb9ea98 100644 --- a/website/src/renderViewsheds.ts +++ b/website/src/lib/renderViewsheds.ts @@ -1,6 +1,6 @@ import type { GeoJSONSource, LngLat } from 'maplibre-gl'; +import { state } from '../state.svelte.ts'; import { type PolarSegments, Viewshed } from './polarSegments.ts'; -import { state } from './state.svelte.ts'; export function setupViewsheds() { state.map?.addSource('viewshed', { @@ -17,8 +17,8 @@ export function setupViewsheds() { source: 'viewshed', paint: { 'fill-color': '#00ff00', - 'fill-outline-color': '#00ff00', - 'fill-opacity': 0.3, + 'fill-outline-color': 'transparent', + 'fill-opacity': 0.4, }, }); @@ -35,7 +35,8 @@ export async function render(lngLat: LngLat) { const bytes = await getViewshedData(lngLat); const segments = parseViewshedBytes(bytes); - const builder = new Viewshed(lngLat, segments); + const scale = 50; // TODO: Get from API? + const builder = new Viewshed(lngLat, scale, segments); const viewshed = builder.create(); const source = state.map?.getSource('viewshed') as GeoJSONSource; @@ -46,8 +47,12 @@ export async function render(lngLat: LngLat) { } async function getViewshedData(lngLat: LngLat) { + let apiBase = 'https://api.alltheviews.world'; + if (import.meta.env.DEV) { + apiBase = 'http://localhost:3333'; + } const response = await fetch( - `https://api.alltheviews.world/viewshed/${lngLat.lng},${lngLat.lat}`, + `${apiBase}/viewshed/${lngLat.lng},${lngLat.lat}`, ); return await response.bytes(); } diff --git a/website/src/utils.ts b/website/src/lib/utils.ts similarity index 89% rename from website/src/utils.ts rename to website/src/lib/utils.ts index 50f2542..6248452 100644 --- a/website/src/utils.ts +++ b/website/src/lib/utils.ts @@ -1,6 +1,6 @@ import nprogress from 'accessible-nprogress'; import { LngLat, LngLatBounds } from 'maplibre-gl'; -import { getViewViewRegion } from './config'; +import type { AppState } from '../state.svelte'; export const VERSION = 'ryan-fullworld-raw'; export const CDN_BUCKET = 'https://cdn.alltheviews.world'; @@ -170,7 +170,7 @@ export function getPMTilesSource() { const params = new URLSearchParams(self.location.search); const source = params.get('pmtiles'); if (!source) { - if (getViewViewRegion() === 'galiano') { + if (getSubdomain()?.includes('galiano')) { return 'https://pmtiles.alltheviews.world/runs/galiano/pmtiles/galiano'; } return PMTILES_SERVER; @@ -178,3 +178,19 @@ export function getPMTilesSource() { return source; } } + +export function setVectorVisibility(state: AppState, isVisible: boolean) { + const layers = state.map?.getStyle().layers || []; + for (const layer of layers) { + if (layer.id === 'mountain_peaks') continue; + if (layer.id === 'background') continue; + if (layer.id === 'longest-line-fill') continue; + if (layer.id === 'viewshed-fill') continue; + if (state.map?.getLayer(layer.id)) + state.map?.setLayoutProperty( + layer.id, + 'visibility', + isVisible ? 'visible' : 'none', + ); + } +} diff --git a/website/src/worldLines.ts b/website/src/lib/worldLines.ts similarity index 98% rename from website/src/worldLines.ts rename to website/src/lib/worldLines.ts index 4acb384..79b35f7 100644 --- a/website/src/worldLines.ts +++ b/website/src/lib/worldLines.ts @@ -1,5 +1,6 @@ import type { GeoTIFFImage } from 'geotiff'; import { LngLat, type LngLatBounds } from 'maplibre-gl'; +import { state } from '../state.svelte'; import { convertLngLatToRasterXY, convertRasterXYToLngLat, @@ -7,7 +8,6 @@ import { findTilesIntersectingViewport, } from './getLongestLine'; import { longestLineURL } from './renderLongestLine'; -import { state } from './state.svelte'; import { CACHE_BUSTER, CDN_BUCKET, diff --git a/website/src/map_vector.styles.json b/website/src/map_vector.styles.json index 8cff544..75c3c84 100644 --- a/website/src/map_vector.styles.json +++ b/website/src/map_vector.styles.json @@ -16,6 +16,9 @@ "paint": { "background-color": "#151f41", "background-opacity": 1 + }, + "layout": { + "visibility": "visible" } }, { @@ -34,7 +37,8 @@ ], "text-size": 12, "text-font": ["Noto Sans Regular"], - "text-offset": [0, 1.9] + "text-offset": [0, 1.9], + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -55,7 +59,7 @@ ], "layout": { "line-cap": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -82,7 +86,8 @@ ["==", ["get", "intermittent"], 0] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -108,7 +113,8 @@ ["==", ["get", "intermittent"], 1] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -136,7 +142,8 @@ ["==", ["get", "intermittent"], 0] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -163,7 +170,8 @@ ["==", ["get", "intermittent"], 1] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -191,7 +199,8 @@ ["!=", ["get", "intermittent"], 1] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -218,7 +227,8 @@ ["==", ["get", "intermittent"], 1] ], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "#a0c8f0", @@ -243,6 +253,9 @@ "paint": { "fill-color": "hsl(210,67%,85%)", "fill-opacity": 0.7 + }, + "layout": { + "visibility": "none" } }, { @@ -256,7 +269,8 @@ ["match", ["get", "class"], ["service", "track"], true, false] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#cfcdca", @@ -286,7 +300,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(200, 147, 102, 1)", @@ -317,7 +332,8 @@ ["==", ["get", "class"], "minor"] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#cfcdca", @@ -356,7 +372,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -389,7 +406,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -418,7 +436,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -449,7 +468,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -498,6 +518,9 @@ 20, 4 ] + }, + "layout": { + "visibility": "none" } }, { @@ -512,7 +535,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(244, 209, 158, 1)", @@ -542,7 +566,8 @@ ["match", ["get", "class"], ["service", "track"], true, false] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff", @@ -577,7 +602,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff4c6", @@ -607,7 +633,8 @@ ["==", ["get", "class"], "minor"] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff", @@ -637,7 +664,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff4c6", @@ -666,7 +694,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff4c6", @@ -695,7 +724,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#ffdaa6", @@ -736,6 +766,9 @@ 20, 2 ] + }, + "layout": { + "visibility": "none" } }, { @@ -747,7 +780,8 @@ "filter": ["match", ["get", "class"], ["taxiway"], true, false], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(153, 153, 153, 1)", @@ -772,7 +806,8 @@ "filter": ["match", ["get", "class"], ["runway"], true, false], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(153, 153, 153, 1)", @@ -802,6 +837,9 @@ "paint": { "fill-color": "rgba(255, 255, 255, 1)", "fill-opacity": ["interpolate", ["linear"], ["zoom"], 13, 0, 14, 1] + }, + "layout": { + "visibility": "none" } }, { @@ -823,7 +861,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(255, 255, 255, 1)", @@ -858,7 +897,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "rgba(255, 255, 255, 1)", @@ -885,7 +925,7 @@ ["==", ["get", "class"], "pier"] ], "layout": { - "visibility": "visible" + "visibility": "none" }, "paint": { "fill-antialias": true, @@ -911,7 +951,7 @@ "layout": { "line-cap": "round", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#f8f4f0", @@ -941,6 +981,9 @@ "fill-color": "hsla(0,0%,89%,0.56)", "fill-opacity": 0.9, "fill-outline-color": "#cfcdca" + }, + "layout": { + "visibility": "none" } }, { @@ -957,7 +1000,7 @@ "layout": { "line-cap": "round", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -998,7 +1041,7 @@ "layout": { "line-cap": "round", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1038,7 +1081,7 @@ "layout": { "line-cap": "round", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#cfcdca", @@ -1072,7 +1115,7 @@ "layout": { "line-cap": "butt", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1103,7 +1146,7 @@ "layout": { "line-cap": "butt", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1138,7 +1181,7 @@ "layout": { "line-cap": "butt", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1174,7 +1217,7 @@ "layout": { "line-cap": "butt", "line-join": "round", - "visibility": "visible" + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1225,6 +1268,9 @@ 20, 4 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1241,7 +1287,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fc8", @@ -1280,7 +1327,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -1318,7 +1366,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff", @@ -1349,7 +1398,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -1386,7 +1436,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -1423,7 +1474,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -1461,7 +1513,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fc8", @@ -1506,6 +1559,9 @@ 20, 1 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1539,6 +1595,9 @@ 20, 6 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1569,6 +1628,9 @@ 20, 1 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1602,6 +1664,9 @@ 20, 6 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1635,6 +1700,9 @@ 20, 2 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1669,6 +1737,9 @@ 20, 8 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1683,7 +1754,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1721,7 +1793,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1753,7 +1826,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1785,7 +1859,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "hsl(28,76%,67%)", @@ -1816,7 +1891,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#e9ac77", @@ -1854,7 +1930,8 @@ ], "layout": { "line-cap": "butt", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#cfcdca", @@ -1902,6 +1979,9 @@ 20, 18 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1933,6 +2013,9 @@ 20, 4 ] + }, + "layout": { + "visibility": "none" } }, { @@ -1947,7 +2030,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fc8", @@ -1984,7 +2068,8 @@ ["==", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -2022,7 +2107,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fff", @@ -2052,7 +2138,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -2081,7 +2168,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fea", @@ -2110,7 +2198,8 @@ ["!=", ["get", "ramp"], 1] ], "layout": { - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "#fc8", @@ -2150,6 +2239,9 @@ 20, 2 ] + }, + "layout": { + "visibility": "none" } }, { @@ -2176,6 +2268,9 @@ 20, 8 ] + }, + "layout": { + "visibility": "none" } }, { @@ -2186,7 +2281,8 @@ "minzoom": 13, "filter": ["==", ["get", "subclass"], "cable_car"], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "hsl(0,0%,70%)", @@ -2201,7 +2297,8 @@ "minzoom": 13, "filter": ["==", ["get", "subclass"], "cable_car"], "layout": { - "line-cap": "round" + "line-cap": "round", + "visibility": "none" }, "paint": { "line-color": "hsl(0,0%,70%)", @@ -2227,6 +2324,9 @@ "line-color": "hsl(0,0%,70%)", "line-dasharray": [1, 1], "line-width": ["interpolate", ["linear", 1], ["zoom"], 7, 1, 11, 2] + }, + "layout": { + "visibility": "none" } }, { @@ -2243,7 +2343,8 @@ ], "layout": { "line-cap": "round", - "line-join": "round" + "line-join": "round", + "visibility": "none" }, "paint": { "line-color": "hsl(248,7%,66%)", @@ -2265,6 +2366,9 @@ "line-color": "hsl(248,7%,66%)", "line-dasharray": [1, 2], "line-width": ["interpolate", ["linear"], ["zoom"], 3, 1, 5, 1.2, 12, 3] + }, + "layout": { + "visibility": "none" } }, { @@ -2299,7 +2403,8 @@ "icon-rotation-alignment": "map", "icon-size": ["interpolate", ["linear"], ["zoom"], 15, 0.5, 19, 1], "symbol-placement": "line", - "symbol-spacing": 75 + "symbol-spacing": 75, + "visibility": "none" }, "paint": { "icon-opacity": 0.5 @@ -2337,7 +2442,8 @@ "icon-rotation-alignment": "map", "icon-size": ["interpolate", ["linear"], ["zoom"], 15, 0.5, 19, 1], "symbol-placement": "line", - "symbol-spacing": 75 + "symbol-spacing": 75, + "visibility": "none" }, "paint": { "icon-opacity": 0.5 @@ -2369,7 +2475,7 @@ "text-letter-spacing": 0.2, "text-max-width": 5, "text-size": 14, - "visibility": "visible" + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2400,7 +2506,7 @@ "text-letter-spacing": 0.2, "text-max-width": 5, "text-size": ["interpolate", ["linear"], ["zoom"], 0, 10, 8, 14], - "visibility": "visible" + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2433,7 +2539,7 @@ "text-letter-spacing": 0.2, "text-max-width": 5, "text-size": 14, - "visibility": "visible" + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2470,7 +2576,8 @@ "text-font": ["Noto Sans Italic"], "text-max-width": 9, "text-offset": [0, 0.6], - "text-size": 12 + "text-size": 12, + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2509,7 +2616,8 @@ "text-font": ["Noto Sans Italic"], "text-max-width": 9, "text-offset": [0, 0.6], - "text-size": 12 + "text-size": 12, + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2548,7 +2656,8 @@ "text-font": ["Noto Sans Italic"], "text-max-width": 9, "text-offset": [0, 0.6], - "text-size": 12 + "text-size": 12, + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2574,7 +2683,8 @@ ], "text-font": ["Noto Sans Regular"], "text-rotation-alignment": "map", - "text-size": ["interpolate", ["linear"], ["zoom"], 13, 12, 14, 13] + "text-size": ["interpolate", ["linear"], ["zoom"], 13, 12, 14, 13], + "visibility": "none" }, "paint": { "text-color": "hsl(30,23%,62%)", @@ -2614,7 +2724,8 @@ "text-field": ["to-string", ["get", "ref"]], "text-font": ["Noto Sans Regular"], "text-rotation-alignment": "viewport", - "text-size": 10 + "text-size": 10, + "visibility": "none" } }, { @@ -2649,7 +2760,8 @@ "text-field": ["to-string", ["get", "ref"]], "text-font": ["Noto Sans Regular"], "text-rotation-alignment": "viewport", - "text-size": 10 + "text-size": 10, + "visibility": "none" } }, { @@ -2674,7 +2786,8 @@ "text-offset": [0, 0.6], "text-optional": true, "text-padding": 2, - "text-size": 12 + "text-size": 12, + "visibility": "none" }, "paint": { "text-color": "#fff", @@ -2707,7 +2820,8 @@ "text-letter-spacing": 0.1, "text-max-width": 9, "text-size": ["interpolate", ["linear"], ["zoom"], 8, 9, 12, 10], - "text-transform": "uppercase" + "text-transform": "uppercase", + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2745,7 +2859,8 @@ 10, 11, 12 - ] + ], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2783,7 +2898,8 @@ 12, 11, 14 - ] + ], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2811,7 +2927,8 @@ "text-letter-spacing": 0.2, "text-max-width": 9, "text-size": ["interpolate", ["linear"], ["zoom"], 5, 10, 8, 14], - "text-transform": "uppercase" + "text-transform": "uppercase", + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2856,7 +2973,8 @@ 13, 11, 18 - ] + ], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2901,7 +3019,8 @@ 14, 11, 20 - ] + ], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2931,7 +3050,8 @@ ], "text-font": ["Noto Sans Bold"], "text-max-width": 6.25, - "text-size": ["interpolate", ["linear"], ["zoom"], 3, 9, 7, 17] + "text-size": ["interpolate", ["linear"], ["zoom"], 3, 9, 7, 17], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2960,7 +3080,8 @@ ], "text-font": ["Noto Sans Bold"], "text-max-width": 6.25, - "text-size": ["interpolate", ["linear"], ["zoom"], 2, 9, 5, 17] + "text-size": ["interpolate", ["linear"], ["zoom"], 2, 9, 5, 17], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", @@ -2989,7 +3110,8 @@ ], "text-font": ["Noto Sans Bold"], "text-max-width": 6.25, - "text-size": ["interpolate", ["linear"], ["zoom"], 1, 9, 4, 17] + "text-size": ["interpolate", ["linear"], ["zoom"], 1, 9, 4, 17], + "visibility": "none" }, "paint": { "text-color": "#ebebeb", diff --git a/website/src/modals/Acknowledgements.svelte b/website/src/modals/Acknowledgements.svelte new file mode 100644 index 0000000..b9c1b90 --- /dev/null +++ b/website/src/modals/Acknowledgements.svelte @@ -0,0 +1,26 @@ + + + +

Acknowledgments

+

+ This project was made by Tom and + Ryan. Contact us at + hello@alltheviews.world . +

+ +

+ The raw data comes from NASA's + SRTM + mission. We used the cleaned version by + Jonathon de Ferranti. +

+ +

+ The vector map data is hosted by Zsolt Ero's openfreemap.org project. +

+ diff --git a/website/src/modals/CurrentLine.svelte b/website/src/modals/CurrentLine.svelte new file mode 100644 index 0000000..5964a72 --- /dev/null +++ b/website/src/modals/CurrentLine.svelte @@ -0,0 +1,33 @@ + + + + {#if state.longestLine} +

Current Line Of Sight

+
+
+ Distance: {(state.longestLine.distance || 0) / 1000}km +
+
+ Bearing: {state.longestLine.angle}° +
+
+ From: {lonLatRound(state.longestLine.from)} +
+
+ To: {lonLatRound(state.longestLine.to)} +
+
+ {/if} +
+ + diff --git a/website/src/modals/TopLines.svelte b/website/src/modals/TopLines.svelte new file mode 100644 index 0000000..efb61bc --- /dev/null +++ b/website/src/modals/TopLines.svelte @@ -0,0 +1,51 @@ + + + +

Top Lines Of Sight

+ + In current viewport: + {#if state.longestLineInViewport} + { + event.preventDefault(); + if (state.longestLineInViewport !== undefined) { + const url = state.longestLineInViewport?.toURL(); + render(state.longestLineInViewport.coordinate); + navigate(url); + } + }}>{state.longestLineInViewport?.toDistance()} + {:else if state.bruteForceLoadingLine} + loading... + {:else} + + {/if} +
+ + diff --git a/website/src/TopLines.svelte b/website/src/modals/TopLinesContent.svelte similarity index 95% rename from website/src/TopLines.svelte rename to website/src/modals/TopLinesContent.svelte index 23db81f..f5fad85 100644 --- a/website/src/TopLines.svelte +++ b/website/src/modals/TopLinesContent.svelte @@ -1,8 +1,8 @@ + + { + state.isSearchOpen = false; + state.isInfoOpen = true; + }} + onClose={() => { + state.isInfoOpen = false; + }} +> +

All The Views In The World

+

We've calculated all the views on the planet.

+

Click on any point to show the longest line of sight at that location

+

+ The lines are the theoretical ideals. They rely on perfect weather + conditions and favourable refraction. +

+

+ Heatmap colours: the brighter the more and further you can see. The darker + the less you can see. +

+ +

+ Click the icon below to + see our curated list of the planet's longest lines of sight +

+ +

+ See alltheviews.world for more details. +

+
diff --git a/website/src/state.svelte.ts b/website/src/state.svelte.ts index 693b017..512e120 100644 --- a/website/src/state.svelte.ts +++ b/website/src/state.svelte.ts @@ -1,36 +1,40 @@ import type { Map as MapLibre } from 'maplibre-gl'; -import type { LongestLine } from './getLongestLine'; -import type { LongestLineH3 } from './worldLines'; +import { getConfig } from './config'; +import type { LongestLine } from './lib/getLongestLine'; +import type { LongestLineH3 } from './lib/worldLines'; export type HeatmapConfig = { contrast: number; intensity: number; }; -let map: MapLibre | undefined; -let worldLongestLines: LongestLineH3[] | undefined; -let longestLine: LongestLine | undefined; -let longestLineInViewport: LongestLineH3 | undefined; -const isFirstInteraction = false; -const bruteForceLoadingLine = false; -const heatmapConfig: HeatmapConfig = { - contrast: 1 - 0.45, - intensity: 1 - 0.5, -}; -const isFlying = false; -// Mobile-only events -const isSearchOpen = false; -const isInfoOpen = true; +export interface AppState { + map: MapLibre | undefined; + config: ReturnType; + worldLongestLines: LongestLineH3[] | undefined; + longestLine: LongestLine | undefined; + longestLineInViewport: LongestLineH3 | undefined; + isFirstInteraction: boolean; + bruteForceLoadingLine: boolean; + heatmapConfig: HeatmapConfig; + isFlying: boolean; + isSearchOpen: boolean; + isInfoOpen: boolean; +} -export const state = $state({ - map, - worldLongestLines, - longestLine, - longestLineInViewport, - isFirstInteraction, - bruteForceLoadingLine, - heatmapConfig, - isFlying, - isSearchOpen, - isInfoOpen, +export const state = $state({ + map: undefined, + config: getConfig(), + worldLongestLines: undefined, + longestLine: undefined, + longestLineInViewport: undefined, + isFirstInteraction: false, + bruteForceLoadingLine: false, + heatmapConfig: { + contrast: 1 - 0.45, + intensity: 1 - 0.5, + }, + isFlying: false, + isSearchOpen: false, + isInfoOpen: true, }); diff --git a/website/src/styles/index.scss b/website/src/styles/index.scss index 4eb5928..ece1d5e 100644 --- a/website/src/styles/index.scss +++ b/website/src/styles/index.scss @@ -20,6 +20,12 @@ html { pointer-events: none; } +.unclickable_icon { + display: inline-block; + scale: 0.7; + translate: 0px 6px; +} + a { color: color.adjust($primary-colour, $lightness: -15%); &:hover { From 085d1d682df86d816001ffcc29d6276ef7865081 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Thu, 9 Apr 2026 17:33:54 +0100 Subject: [PATCH 06/15] First viewshed viewer UI/UX Hiding/showing, locking, removing, changing colours, etc. There's also some RSBuild changes as I got HMR working. --- website/package.json | 6 +- website/pnpm-lock.yaml | 645 +++++++++++------- website/rsbuild.config.ts | 13 +- website/src/HeatmapLayer.ts | 4 +- website/src/Home.svelte | 45 +- website/src/Layout.svelte | 15 - website/src/index.ts | 15 +- .../src/lib/{polarSegments.ts => Viewshed.ts} | 22 +- website/src/lib/renderViewsheds.ts | 118 +++- website/src/lib/utils.ts | 2 +- website/src/modals/Viewsheds.svelte | 134 ++++ website/src/state.svelte.ts | 3 + 12 files changed, 714 insertions(+), 308 deletions(-) rename website/src/lib/{polarSegments.ts => Viewshed.ts} (85%) create mode 100644 website/src/modals/Viewsheds.svelte diff --git a/website/package.json b/website/package.json index ed319f5..34b0a77 100644 --- a/website/package.json +++ b/website/package.json @@ -24,9 +24,9 @@ }, "devDependencies": { "@biomejs/biome": "2.3.4", - "@rsbuild/core": "^1.6.0", - "@rsbuild/plugin-sass": "^1.4.0", - "@rsbuild/plugin-svelte": "^1.0.11", + "@rsbuild/core": "^1.7.5", + "@rsbuild/plugin-sass": "^1.5.1", + "@rsbuild/plugin-svelte": "^1.1.1", "@types/geojson": "^7946.0.16", "pmtiles": "^4.3.0", "prettier": "^3.6.2", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 2fcb072..6265780 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -37,14 +37,14 @@ importers: specifier: 2.3.4 version: 2.3.4 '@rsbuild/core': - specifier: ^1.6.0 - version: 1.6.7 + specifier: ^1.7.5 + version: 1.7.5 '@rsbuild/plugin-sass': - specifier: ^1.4.0 - version: 1.4.0(@rsbuild/core@1.6.7) + specifier: ^1.5.1 + version: 1.5.1(@rsbuild/core@1.7.5) '@rsbuild/plugin-svelte': - specifier: ^1.0.11 - version: 1.0.11(@rsbuild/core@1.6.7)(postcss@8.5.6)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3) + specifier: ^1.1.1 + version: 1.1.1(@rsbuild/core@1.7.5)(postcss@8.5.9)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3) '@types/geojson': specifier: ^7946.0.16 version: 7946.0.16 @@ -59,7 +59,7 @@ importers: version: 1.97.3 svelte-check: specifier: ^4.3.3 - version: 4.3.4(svelte@5.43.14)(typescript@5.9.3) + version: 4.3.4(picomatch@4.0.4)(svelte@5.43.14)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 @@ -119,17 +119,17 @@ packages: cpu: [x64] os: [win32] - '@bufbuild/protobuf@2.10.1': - resolution: {integrity: sha512-ckS3+vyJb5qGpEYv/s1OebUHDi/xSNtfgw1wqKZo7MR9F2z+qXr0q5XagafAG/9O0QPVIUfST0smluYSTpYFkg==} + '@bufbuild/protobuf@2.11.0': + resolution: {integrity: sha512-sBXGT13cpmPR5BMgHE6UEEfEaShh5Ror6rfN3yEK5si7QVrtZg8LEPQb0VVhiLRUslD2yLnXtnRzG035J/mZXQ==} - '@emnapi/core@1.7.1': - resolution: {integrity: sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==} + '@emnapi/core@1.9.2': + resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==} - '@emnapi/runtime@1.7.1': - resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==} + '@emnapi/runtime@1.9.2': + resolution: {integrity: sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw==} - '@emnapi/wasi-threads@1.1.0': - resolution: {integrity: sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==} + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} '@floating-ui/core@0.7.3': resolution: {integrity: sha512-buc8BXHmG9l82+OQXOFU3Kr2XQx9ys01U/Q9HMIrZ300iLc8HLMgh7dcCqgYzAzf4BkoQvDcXf5Y+CuEZ5JBYg==} @@ -189,6 +189,9 @@ packages: '@mapbox/tiny-sdf@2.0.7': resolution: {integrity: sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug==} + '@mapbox/tiny-sdf@2.1.0': + resolution: {integrity: sha512-uFJhNh36BR4OCuWIEiWaEix9CA2WzT6CAIcqVjWYpnx8+QDtS+oC4QehRrx5cX4mgWs37MmKnwUejeHxVymzNg==} + '@mapbox/unitbezier@0.0.1': resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} @@ -209,23 +212,23 @@ packages: '@maplibre/vt-pbf@4.0.3': resolution: {integrity: sha512-YsW99BwnT+ukJRkseBcLuZHfITB4puJoxnqPVjo72rhW/TaawVYsgQHcqWLzTxqknttYoDpgyERzWSa/XrETdA==} - '@module-federation/error-codes@0.21.4': - resolution: {integrity: sha512-ClpL5MereWNXh+EgDjz7w4RrC1JlisQTvXDa1gLxpviHafzNDfdViVmuhi9xXVuj+EYo8KU70Y999KHhk9424Q==} + '@module-federation/error-codes@0.22.0': + resolution: {integrity: sha512-xF9SjnEy7vTdx+xekjPCV5cIHOGCkdn3pIxo9vU7gEZMIw0SvAEdsy6Uh17xaCpm8V0FWvR0SZoK9Ik6jGOaug==} - '@module-federation/runtime-core@0.21.4': - resolution: {integrity: sha512-SGpmoOLGNxZofpTOk6Lxb2ewaoz5wMi93AFYuuJB04HTVcngEK+baNeUZ2D/xewrqNIJoMY6f5maUjVfIIBPUA==} + '@module-federation/runtime-core@0.22.0': + resolution: {integrity: sha512-GR1TcD6/s7zqItfhC87zAp30PqzvceoeDGYTgF3Vx2TXvsfDrhP6Qw9T4vudDQL3uJRne6t7CzdT29YyVxlgIA==} - '@module-federation/runtime-tools@0.21.4': - resolution: {integrity: sha512-RzFKaL0DIjSmkn76KZRfzfB6dD07cvID84950jlNQgdyoQFUGkqD80L6rIpVCJTY/R7LzR3aQjHnoqmq4JPo3w==} + '@module-federation/runtime-tools@0.22.0': + resolution: {integrity: sha512-4ScUJ/aUfEernb+4PbLdhM/c60VHl698Gn1gY21m9vyC1Ucn69fPCA1y2EwcCB7IItseRMoNhdcWQnzt/OPCNA==} - '@module-federation/runtime@0.21.4': - resolution: {integrity: sha512-wgvGqryurVEvkicufJmTG0ZehynCeNLklv8kIk5BLIsWYSddZAE+xe4xov1kgH5fIJQAoQNkRauFFjVNlHoAkA==} + '@module-federation/runtime@0.22.0': + resolution: {integrity: sha512-38g5iPju2tPC3KHMPxRKmy4k4onNp6ypFPS1eKGsNLUkXgHsPMBFqAjDw96iEcjri91BrahG4XcdyKi97xZzlA==} - '@module-federation/sdk@0.21.4': - resolution: {integrity: sha512-tzvhOh/oAfX++6zCDDxuvioHY4Jurf8vcfoCbKFxusjmyKr32GPbwFDazUP+OPhYCc3dvaa9oWU6X/qpUBLfJw==} + '@module-federation/sdk@0.22.0': + resolution: {integrity: sha512-x4aFNBKn2KVQRuNVC5A7SnrSCSqyfIWmm1DvubjbO9iKFe7ith5niw8dqSFBekYBg2Fwy+eMg4sEFNVvCAdo6g==} - '@module-federation/webpack-bundler-runtime@0.21.4': - resolution: {integrity: sha512-dusmR3uPnQh9u9ChQo3M+GLOuGFthfvnh7WitF/a1eoeTfRmXqnMFsXtZCUK+f/uXf+64874Zj/bhAgbBcVHZA==} + '@module-federation/webpack-bundler-runtime@0.22.0': + resolution: {integrity: sha512-aM8gCqXu+/4wBmJtVeMeeMN5guw3chf+2i6HajKtQv7SJfxV/f4IyNQJUeUQu9HfiAZHjqtMV5Lvq/Lvh8LdyA==} '@napi-rs/wasm-runtime@1.0.7': resolution: {integrity: sha512-SeDnOO0Tk7Okiq6DbXmmBODgOAb9dp9gjlphokTUxmt8U3liIP1ZsozBahH69j/RJv+Rfs6IwUKHTgQYJ/HBAw==} @@ -236,154 +239,242 @@ packages: cpu: [arm64] os: [android] + '@parcel/watcher-android-arm64@2.5.6': + resolution: {integrity: sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + '@parcel/watcher-darwin-arm64@2.5.1': resolution: {integrity: sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [darwin] + '@parcel/watcher-darwin-arm64@2.5.6': + resolution: {integrity: sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + '@parcel/watcher-darwin-x64@2.5.1': resolution: {integrity: sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [darwin] + '@parcel/watcher-darwin-x64@2.5.6': + resolution: {integrity: sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + '@parcel/watcher-freebsd-x64@2.5.1': resolution: {integrity: sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [freebsd] + '@parcel/watcher-freebsd-x64@2.5.6': + resolution: {integrity: sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + '@parcel/watcher-linux-arm-glibc@2.5.1': resolution: {integrity: sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + '@parcel/watcher-linux-arm-glibc@2.5.6': + resolution: {integrity: sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + '@parcel/watcher-linux-arm-musl@2.5.1': resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] + '@parcel/watcher-linux-arm-musl@2.5.6': + resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + '@parcel/watcher-linux-arm64-glibc@2.5.1': resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + '@parcel/watcher-linux-arm64-glibc@2.5.6': + resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + '@parcel/watcher-linux-arm64-musl@2.5.1': resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] + '@parcel/watcher-linux-arm64-musl@2.5.6': + resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + '@parcel/watcher-linux-x64-glibc@2.5.1': resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + '@parcel/watcher-linux-x64-glibc@2.5.6': + resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + '@parcel/watcher-linux-x64-musl@2.5.1': resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] + '@parcel/watcher-linux-x64-musl@2.5.6': + resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + '@parcel/watcher-win32-arm64@2.5.1': resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [win32] + '@parcel/watcher-win32-arm64@2.5.6': + resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + '@parcel/watcher-win32-ia32@2.5.1': resolution: {integrity: sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==} engines: {node: '>= 10.0.0'} cpu: [ia32] os: [win32] + '@parcel/watcher-win32-ia32@2.5.6': + resolution: {integrity: sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g==} + engines: {node: '>= 10.0.0'} + cpu: [ia32] + os: [win32] + '@parcel/watcher-win32-x64@2.5.1': resolution: {integrity: sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [win32] + '@parcel/watcher-win32-x64@2.5.6': + resolution: {integrity: sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + '@parcel/watcher@2.5.1': resolution: {integrity: sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==} engines: {node: '>= 10.0.0'} + '@parcel/watcher@2.5.6': + resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} + engines: {node: '>= 10.0.0'} + '@petamoriken/float16@3.9.3': resolution: {integrity: sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==} - '@rsbuild/core@1.6.7': - resolution: {integrity: sha512-V0INbMrT/LwyhzKmvpupe2oSvPFWaivz7sdriFRp381BJvD0d2pYcq9iRW91bxgMRX78MgTzFYAu868hMAzoSw==} + '@rsbuild/core@1.7.5': + resolution: {integrity: sha512-i37urpoV4y9NSsGiUOuLdoI42KJ5h4gAZ8EG8Ilmsond3bxoAoOCu7YvC+1pJ7p+r16suVPW8cki891ZKHOoXQ==} engines: {node: '>=18.12.0'} hasBin: true - '@rsbuild/plugin-sass@1.4.0': - resolution: {integrity: sha512-2XcrXUwJftWsWURsvGKKXc7A4yEAUdVkw+K95Qrfmy5E5IfARik0Ko3TFB1828wdvhZ1OFLvDQEzONCIyQV1yg==} + '@rsbuild/plugin-sass@1.5.1': + resolution: {integrity: sha512-rwou4KvDerqvAI0hstjlwF9jgzxPaSOr0PkwtEPjTnQcR5mK6AXZICpUBJbHGgjlAM0aTegKf5IYl/ENvaAQrQ==} peerDependencies: - '@rsbuild/core': 1.x + '@rsbuild/core': ^1.3.0 || ^2.0.0-0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true - '@rsbuild/plugin-svelte@1.0.11': - resolution: {integrity: sha512-ThmBSTMILWDB+i/P61hgw76UnmFnv7WYYZMjcwqxUXEKM3zobL5jdtEj56wN8yn/xadStM0INjNjHWTf5E7kIQ==} + '@rsbuild/plugin-svelte@1.1.1': + resolution: {integrity: sha512-maRgnSH/8nAJKl8yhQyf8W07kzz/tOCK8zZLpOIL7dP1j3Pxir7Uiy98ikPq3fWhPIBBtIzpmY8FUaR44t2CJA==} peerDependencies: - '@rsbuild/core': 1.x + '@rsbuild/core': ^1.4.0 || ^2.0.0-0 + peerDependenciesMeta: + '@rsbuild/core': + optional: true - '@rspack/binding-darwin-arm64@1.6.4': - resolution: {integrity: sha512-qD2C5xwdY2qKEXTZiPJQx1L1kELapOc0AaZDqcAyzXs30d1qTKpx6PdyW3HN+gueKovyWZwMMYfz6RxcMCnaDQ==} + '@rspack/binding-darwin-arm64@1.7.11': + resolution: {integrity: sha512-oduECiZVqbO5zlVw+q7Vy65sJFth99fWPTyucwvLJJtJkPL5n17Uiql2cYP6Ijn0pkqtf1SXgK8WjiKLG5bIig==} cpu: [arm64] os: [darwin] - '@rspack/binding-darwin-x64@1.6.4': - resolution: {integrity: sha512-IHceyLDxeubqIrGz4gUqJavnygTij4vtDDE2Fkgobz7hkTJwGtD5mxBKbVNRqGvhrasVw0h9rEjR7tdbDSiUhQ==} + '@rspack/binding-darwin-x64@1.7.11': + resolution: {integrity: sha512-a1+TtTE9ap6RalgFi7FGIgkJP6O4Vy6ctv+9WGJy53E4kuqHR0RygzaiVxCI/GMc/vBT9vY23hyrpWb3d1vtXA==} cpu: [x64] os: [darwin] - '@rspack/binding-linux-arm64-gnu@1.6.4': - resolution: {integrity: sha512-Ldpoz2wWnBaL2+XKLIOyCZMkAkd4pk/L24EVgma3SpRtwgenLEr10bQupvwGAK5OLkjayslOTZmRiAv0FH5o/w==} + '@rspack/binding-linux-arm64-gnu@1.7.11': + resolution: {integrity: sha512-P0QrGRPbTWu6RKWfN0bDtbnEps3rXH0MWIMreZABoUrVmNQKtXR6e73J3ub6a+di5s2+K0M2LJ9Bh2/H4UsDUA==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-arm64-musl@1.6.4': - resolution: {integrity: sha512-3fLMSDK5yMjKmx7iFbYG3P3A0xNdtmNu09v5P6hzq65tkJ3dflIt3p8DvtOTURtuSgQZV2A1LDd9hpIXdnigqA==} + '@rspack/binding-linux-arm64-musl@1.7.11': + resolution: {integrity: sha512-6ky7R43VMjWwmx3Yx7Jl7faLBBMAgMDt+/bN35RgwjiPgsIByz65EwytUVuW9rikB43BGHvA/eqlnjLrUzNBqw==} cpu: [arm64] os: [linux] - '@rspack/binding-linux-x64-gnu@1.6.4': - resolution: {integrity: sha512-5YzXUKLnaiqND05CDgkKE0WNRtC1ulkVncYs78xPikonzZmgVXa8eRaTPOZC6ZjpLR0eTsg+MSesLUsPUu27hA==} + '@rspack/binding-linux-x64-gnu@1.7.11': + resolution: {integrity: sha512-cuOJMfCOvb2Wgsry5enXJ3iT1FGUjdPqtGUBVupQlEG4ntSYsQ2PtF4wIDVasR3wdxC5nQbipOrDiN/u6fYsdQ==} cpu: [x64] os: [linux] - '@rspack/binding-linux-x64-musl@1.6.4': - resolution: {integrity: sha512-KcSFla8a9bXG1mmV5oQ1R5h/dSXfd41/qHOsNuLqho2UCX8CVh4dezUA153dj7p1S4yOhTy6VZZi6C1szweE9A==} + '@rspack/binding-linux-x64-musl@1.7.11': + resolution: {integrity: sha512-CoK37hva4AmHGh3VCsQXmGr40L36m1/AdnN5LEjUX6kx5rEH7/1nEBN6Ii72pejqDVvk9anEROmPDiPw10tpFg==} cpu: [x64] os: [linux] - '@rspack/binding-wasm32-wasi@1.6.4': - resolution: {integrity: sha512-mfFJbDJkRy5I1iW3m0JlWbc0X8pjVd+GRUz5nhbccwEhSQOc27ao3evf7XPU4aaDxud1B3UEqYiRcRmtm1BrjA==} + '@rspack/binding-wasm32-wasi@1.7.11': + resolution: {integrity: sha512-OtrmnPUVJMxjNa3eDMfHyPdtlLRmmp/aIm0fQHlAOATbZvlGm12q7rhPW5BXTu1yh+1rQ1/uqvz+SzKEZXuJaQ==} cpu: [wasm32] - '@rspack/binding-win32-arm64-msvc@1.6.4': - resolution: {integrity: sha512-QtIqxsfeTSS1lwfaPGrPFfJ9ir/3aWZv5t3iAgYj/CNUA8MTKWt4vQKcco7NRIGK4ZLMI+dgJBFtvd/lUDMQsw==} + '@rspack/binding-win32-arm64-msvc@1.7.11': + resolution: {integrity: sha512-lObFW6e5lCWNgTBNwT//yiEDbsxm9QG4BYUojqeXxothuzJ/L6ibXz6+gLMvbOvLGV3nKgkXmx8GvT9WDKR0mA==} cpu: [arm64] os: [win32] - '@rspack/binding-win32-ia32-msvc@1.6.4': - resolution: {integrity: sha512-HXEWGDllgh0jFwjGhkGcLqb0dzXbc/rA8vQr2JcSdC41p1DTzLgO215jWdKSIvzCzhyPh3VeQkXk76hjFB2cLQ==} + '@rspack/binding-win32-ia32-msvc@1.7.11': + resolution: {integrity: sha512-0pYGnZd8PPqNR68zQ8skamqNAXEA1sUfXuAdYcknIIRq2wsbiwFzIc0Pov1cIfHYab37G7sSIPBiOUdOWF5Ivw==} cpu: [ia32] os: [win32] - '@rspack/binding-win32-x64-msvc@1.6.4': - resolution: {integrity: sha512-MAO5rOnGYoeuT2LPn/P7JVJCi3d78XoXgOq3tkGh6qXhvhkjsBRtYluWCzACXQpXfFHEWYd7uT5yHoZgxiVuoA==} + '@rspack/binding-win32-x64-msvc@1.7.11': + resolution: {integrity: sha512-EeQXayoQk/uBkI3pdoXfQBXNIUrADq56L3s/DFyM2pJeUDrWmhfIw2UFIGkYPTMSCo8F2JcdcGM32FGJrSnU0Q==} cpu: [x64] os: [win32] - '@rspack/binding@1.6.4': - resolution: {integrity: sha512-vUxc/zUdsCuyysOvP4CTdIYxsZPb2jIXST5vrLABiTPIaHpXZ0hVdgKif2XPJwJeuCVS6w25xvyPN0mBCU0MvQ==} + '@rspack/binding@1.7.11': + resolution: {integrity: sha512-2MGdy2s2HimsDT444Bp5XnALzNRxuBNc7y0JzyuqKbHBywd4x2NeXyhWXXoxufaCFu5PBc9Qq9jyfjW2Aeh06Q==} - '@rspack/core@1.6.4': - resolution: {integrity: sha512-5F1+MQD8rfbFbUHnaiZe4jqOu9pnSb+PliqQvi0lj+uvpMpcS3sJDIs/mz6P1u87lfkfBXChIT4zSLAzeOgMWw==} + '@rspack/core@1.7.11': + resolution: {integrity: sha512-rsD9b+Khmot5DwCMiB3cqTQo53ioPG3M/A7BySu8+0+RS7GCxKm+Z+mtsjtG/vsu4Tn2tcqCdZtA3pgLoJB+ew==} engines: {node: '>=18.12.0'} peerDependencies: '@swc/helpers': '>=0.5.1' @@ -399,8 +490,8 @@ packages: peerDependencies: acorn: ^8.9.0 - '@swc/helpers@0.5.17': - resolution: {integrity: sha512-5IKx/Y13RsYd+sauPb2x+U/xZikHjolzfuDgTAl/Tdf3Q8rslRvC19NKDLgAJQ6wsqADk10ntlv08nPFw/gO/A==} + '@swc/helpers@0.5.21': + resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -446,9 +537,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - buffer-builder@0.2.0: - resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} - cheap-ruler@4.0.0: resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==} @@ -463,8 +551,8 @@ packages: colorjs.io@0.5.2: resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} - core-js@3.46.0: - resolution: {integrity: sha512-vDMm9B0xnqqZ8uSBpZ8sNtRtOdmfShrvT6h2TuQGLs0Is+cR0DYbj/KWP6ALVNbWPpqA/qPLoOuppJN07humpA==} + core-js@3.47.0: + resolution: {integrity: sha512-c3Q2VVkGAUyupsjRnaNX6u8Dq2vAdzm9iuPj5FW0fRxzlxgq9Q39MDq10IvmQSpLgHQNyQzQmOo6bgGHmH3NNg==} csscolorparser@1.0.3: resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} @@ -478,6 +566,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + earcut@3.0.2: resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} @@ -534,6 +626,9 @@ packages: immutable@5.1.4: resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==} + immutable@5.1.5: + resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -632,11 +727,15 @@ packages: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + pmtiles@4.3.0: resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.9: + resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} potpack@2.1.0: @@ -664,8 +763,8 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} - reduce-configs@1.1.1: - resolution: {integrity: sha512-EYtsVGAQarE8daT54cnaY1PIknF2VB78ug6Zre2rs36EsJfC40EG6hmTU2A2P1ZuXnKAt2KI0fzOGHcX7wzdPw==} + reduce-configs@1.1.2: + resolution: {integrity: sha512-AgBP55V8FC7NaqoOP2RCbTpu6LE+YuX3LUZkNAoitcfyS3/PIC8Obg/TJrBzTkJ+lDvZv0TTAeDpLkzjTtYlbw==} resolve-protobuf-schema@2.1.0: resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} @@ -683,122 +782,122 @@ packages: resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} engines: {node: '>=6'} - sass-embedded-all-unknown@1.93.3: - resolution: {integrity: sha512-3okGgnE41eg+CPLtAPletu6nQ4N0ij7AeW+Sl5Km4j29XcmqZQeFwYjHe1AlKTEgLi/UAONk1O8i8/lupeKMbw==} + sass-embedded-all-unknown@1.99.0: + resolution: {integrity: sha512-qPIRG8Uhjo6/OKyAKixTnwMliTz+t9K6Duk0mx5z+K7n0Ts38NSJz2sjDnc7cA/8V9Lb3q09H38dZ1CLwD+ssw==} cpu: ['!arm', '!arm64', '!riscv64', '!x64'] - sass-embedded-android-arm64@1.93.3: - resolution: {integrity: sha512-uqUl3Kt1IqdGVAcAdbmC+NwuUJy8tM+2ZnB7/zrt6WxWVShVCRdFnWR9LT8HJr7eJN7AU8kSXxaVX/gedanPsg==} + sass-embedded-android-arm64@1.99.0: + resolution: {integrity: sha512-fNHhdnP23yqqieCbAdym4N47AleSwjbNt6OYIYx4DdACGdtERjQB4iOX/TaKsW034MupfF7SjnAAK8w7Ptldtg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [android] - sass-embedded-android-arm@1.93.3: - resolution: {integrity: sha512-8xOw9bywfOD6Wv24BgCmgjkk6tMrsOTTHcb28KDxeJtFtoxiUyMbxo0vChpPAfp2Hyg2tFFKS60s0s4JYk+Raw==} + sass-embedded-android-arm@1.99.0: + resolution: {integrity: sha512-EHvJ0C7/VuP78Qr6f8gIUVUmCqIorEQpw2yp3cs3SMg02ZuumlhjXvkTcFBxHmFdFR23vTNk1WnhY6QSeV1nFQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [android] - sass-embedded-android-riscv64@1.93.3: - resolution: {integrity: sha512-2jNJDmo+3qLocjWqYbXiBDnfgwrUeZgZFHJIwAefU7Fn66Ot7rsXl+XPwlokaCbTpj7eMFIqsRAZ/uDueXNCJg==} + sass-embedded-android-riscv64@1.99.0: + resolution: {integrity: sha512-4zqDFRvgGDTL5vTHuIhRxUpXFoh0Cy7Gm5Ywk19ASd8Settmd14YdPRZPmMxfgS1GH292PofV1fq1ifiSEJWBw==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [android] - sass-embedded-android-x64@1.93.3: - resolution: {integrity: sha512-y0RoAU6ZenQFcjM9PjQd3cRqRTjqwSbtWLL/p68y2oFyh0QGN0+LQ826fc0ZvU/AbqCsAizkqjzOn6cRZJxTTQ==} + sass-embedded-android-x64@1.99.0: + resolution: {integrity: sha512-Uk53k/dGYt04RjOL4gFjZ0Z9DH9DKh8IA8WsXUkNqsxerAygoy3zqRBS2zngfE9K2jiOM87q+1R1p87ory9oQQ==} engines: {node: '>=14.0.0'} cpu: [x64] os: [android] - sass-embedded-darwin-arm64@1.93.3: - resolution: {integrity: sha512-7zb/hpdMOdKteK17BOyyypemglVURd1Hdz6QGsggy60aUFfptTLQftLRg8r/xh1RbQAUKWFbYTNaM47J9yPxYg==} + sass-embedded-darwin-arm64@1.99.0: + resolution: {integrity: sha512-u61/7U3IGLqoO6gL+AHeiAtlTPFwJK1+964U8gp45ZN0hzh1yrARf5O1mivXv8NnNgJvbG2wWJbiNZP0lG/lTg==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [darwin] - sass-embedded-darwin-x64@1.93.3: - resolution: {integrity: sha512-Ek1Vp8ZDQEe327Lz0b7h3hjvWH3u9XjJiQzveq74RPpJQ2q6d9LfWpjiRRohM4qK6o4XOHw1X10OMWPXJtdtWg==} + sass-embedded-darwin-x64@1.99.0: + resolution: {integrity: sha512-j/kkk/NcXdIameLezSfXjgCiBkVcA+G60AXrX768/3g0miK1g7M9dj7xOhCb1i7/wQeiEI3rw2LLuO63xRIn4A==} engines: {node: '>=14.0.0'} cpu: [x64] os: [darwin] - sass-embedded-linux-arm64@1.93.3: - resolution: {integrity: sha512-RBrHWgfd8Dd8w4fbmdRVXRrhh8oBAPyeWDTKAWw8ZEmuXfVl4ytjDuyxaVilh6rR1xTRTNpbaA/YWApBlLrrNw==} + sass-embedded-linux-arm64@1.99.0: + resolution: {integrity: sha512-btNcFpItcB56L40n8hDeL7sRSMLDXQ56nB5h2deddJx1n60rpKSElJmkaDGHtpkrY+CTtDRV0FZDjHeTJddYew==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-arm@1.93.3: - resolution: {integrity: sha512-yeiv2y+dp8B4wNpd3+JsHYD0mvpXSfov7IGyQ1tMIR40qv+ROkRqYiqQvAOXf76Qwh4Y9OaYZtLpnsPjfeq6mA==} + sass-embedded-linux-arm@1.99.0: + resolution: {integrity: sha512-d4IjJZrX2+AwB2YCy1JySwdptJECNP/WfAQLUl8txI3ka8/d3TUI155GtelnoZUkio211PwIeFvvAeZ9RXPQnw==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-arm64@1.93.3: - resolution: {integrity: sha512-PS829l+eUng+9W4PFclXGb4uA2+965NHV3/Sa5U7qTywjeeUUYTZg70dJHSqvhrBEfCc2XJABeW3adLJbyQYkw==} + sass-embedded-linux-musl-arm64@1.99.0: + resolution: {integrity: sha512-Hi2bt/IrM5P4FBKz6EcHAlniwfpoz9mnTdvSd58y+avA3SANM76upIkAdSayA8ZGwyL3gZokru1AKDPF9lJDNw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [linux] - sass-embedded-linux-musl-arm@1.93.3: - resolution: {integrity: sha512-fU0fwAwbp7sBE3h5DVU5UPzvaLg7a4yONfFWkkcCp6ZrOiPuGRHXXYriWQ0TUnWy4wE+svsVuWhwWgvlb/tkKg==} + sass-embedded-linux-musl-arm@1.99.0: + resolution: {integrity: sha512-2gvHOupgIw3ytatXT4nFUow71LFbuOZPEwG+HUzcNQDH8ue4Ez8cr03vsv5MDv3lIjOKcXwDvWD980t18MwkoQ==} engines: {node: '>=14.0.0'} cpu: [arm] os: [linux] - sass-embedded-linux-musl-riscv64@1.93.3: - resolution: {integrity: sha512-cK1oBY+FWQquaIGEeQ5H74KTO8cWsSWwXb/WaildOO9U6wmUypTgUYKQ0o5o/29nZbWWlM1PHuwVYTSnT23Jjg==} + sass-embedded-linux-musl-riscv64@1.99.0: + resolution: {integrity: sha512-mKqGvVaJ9rHMqyZsF0kikQe4NO0f4osb67+X6nLhBiVDKvyazQHJ3zJQreNefIE36yL2sjHIclSB//MprzaQDg==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-musl-x64@1.93.3: - resolution: {integrity: sha512-A7wkrsHu2/I4Zpa0NMuPGkWDVV7QGGytxGyUq3opSXgAexHo/vBPlGoDXoRlSdex0cV+aTMRPjoGIfdmNlHwyg==} + sass-embedded-linux-musl-x64@1.99.0: + resolution: {integrity: sha512-huhgOMmOc30r7CH7qbRbT9LerSEGSnWuS4CYNOskr9BvNeQp4dIneFufNRGZ7hkOAxUM8DglxIZJN/cyAT95Ew==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-linux-riscv64@1.93.3: - resolution: {integrity: sha512-vWkW1+HTF5qcaHa6hO80gx/QfB6GGjJUP0xLbnAoY4pwEnw5ulGv6RM8qYr8IDhWfVt/KH+lhJ2ZFxnJareisQ==} + sass-embedded-linux-riscv64@1.99.0: + resolution: {integrity: sha512-mevFPIFAVhrH90THifxLfOntFmHtcEKOcdWnep2gJ0X4DVva4AiVIRlQe/7w9JFx5+gnDRE1oaJJkzuFUuYZsA==} engines: {node: '>=14.0.0'} cpu: [riscv64] os: [linux] - sass-embedded-linux-x64@1.93.3: - resolution: {integrity: sha512-k6uFxs+e5jSuk1Y0niCwuq42F9ZC5UEP7P+RIOurIm8w/5QFa0+YqeW+BPWEW5M1FqVOsNZH3qGn4ahqvAEjPA==} + sass-embedded-linux-x64@1.99.0: + resolution: {integrity: sha512-9k7IkULqIZdCIVt4Mboryt6vN8Mjmm3EhI1P3mClU5y5i3wLK5ExC3cbVWk047KsID/fvB1RLslqghXJx5BoxA==} engines: {node: '>=14.0.0'} cpu: [x64] os: [linux] - sass-embedded-unknown-all@1.93.3: - resolution: {integrity: sha512-o5wj2rLpXH0C+GJKt/VpWp6AnMsCCbfFmnMAttcrsa+U3yrs/guhZ3x55KAqqUsE8F47e3frbsDL+1OuQM5DAA==} + sass-embedded-unknown-all@1.99.0: + resolution: {integrity: sha512-P7MxiUtL/XzGo3PX0CaB8lNNEFLQWKikPA8pbKytx9ZCLZSDkt2NJcdAbblB/sqMs4AV3EK2NadV8rI/diq3xg==} os: ['!android', '!darwin', '!linux', '!win32'] - sass-embedded-win32-arm64@1.93.3: - resolution: {integrity: sha512-0dOfT9moy9YmBolodwYYXtLwNr4jL4HQC9rBfv6mVrD7ud8ue2kDbn+GVzj1hEJxvEexVSmDCf7MHUTLcGs9xQ==} + sass-embedded-win32-arm64@1.99.0: + resolution: {integrity: sha512-8whpsW7S+uO8QApKfQuc36m3P9EISzbVZOgC79goob4qGy09u8Gz/rYvw8h1prJDSjltpHGhOzBE6LDz7WvzVw==} engines: {node: '>=14.0.0'} cpu: [arm64] os: [win32] - sass-embedded-win32-x64@1.93.3: - resolution: {integrity: sha512-wHFVfxiS9hU/sNk7KReD+lJWRp3R0SLQEX4zfOnRP2zlvI2X4IQR5aZr9GNcuMP6TmNpX0nQPZTegS8+h9RrEg==} + sass-embedded-win32-x64@1.99.0: + resolution: {integrity: sha512-ipuOv1R2K4MHeuCEAZGpuUbAgma4gb0sdacyrTjJtMOy/OY9UvWfVlwErdB09KIkp4fPDpQJDJfvYN6bC8jeNg==} engines: {node: '>=14.0.0'} cpu: [x64] os: [win32] - sass-embedded@1.93.3: - resolution: {integrity: sha512-+VUy01yfDqNmIVMd/LLKl2TTtY0ovZN0rTonh+FhKr65mFwIYgU9WzgIZKS7U9/SPCQvWTsTGx9jyt+qRm/XFw==} + sass-embedded@1.99.0: + resolution: {integrity: sha512-gF/juR1aX02lZHkvwxdF80SapkQeg2fetoDF6gIQkNbSw5YEUFspMkyGTjPjgZSgIHuZpy+Wz4PlebKnLXMjdg==} engines: {node: '>=16.0.0'} hasBin: true - sass@1.93.3: - resolution: {integrity: sha512-elOcIZRTM76dvxNAjqYrucTSI0teAF/L2Lv0s6f6b7FOwcwIuA357bIE871580AjHJuSvLIRUosgV+lIWx6Rgg==} + sass@1.97.3: + resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} engines: {node: '>=14.0.0'} hasBin: true - sass@1.97.3: - resolution: {integrity: sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==} + sass@1.99.0: + resolution: {integrity: sha512-kgW13M54DUB7IsIRM5LvJkNlpH+WhMpooUcaWGFARkF1Tc82v9mIWkCbCYf+MBvpIUBSeSOTilpZjEPr2VYE6Q==} engines: {node: '>=14.0.0'} hasBin: true @@ -891,8 +990,8 @@ packages: resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} engines: {node: '>=16.0.0'} - sync-message-port@1.1.3: - resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==} + sync-message-port@1.2.0: + resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} tabbable@5.3.3: @@ -968,20 +1067,20 @@ snapshots: '@biomejs/cli-win32-x64@2.3.4': optional: true - '@bufbuild/protobuf@2.10.1': {} + '@bufbuild/protobuf@2.11.0': {} - '@emnapi/core@1.7.1': + '@emnapi/core@1.9.2': dependencies: - '@emnapi/wasi-threads': 1.1.0 + '@emnapi/wasi-threads': 1.2.1 tslib: 2.8.1 optional: true - '@emnapi/runtime@1.7.1': + '@emnapi/runtime@1.9.2': dependencies: tslib: 2.8.1 optional: true - '@emnapi/wasi-threads@1.1.0': + '@emnapi/wasi-threads@1.2.1': dependencies: tslib: 2.8.1 optional: true @@ -1044,6 +1143,8 @@ snapshots: '@mapbox/tiny-sdf@2.0.7': {} + '@mapbox/tiny-sdf@2.1.0': {} + '@mapbox/unitbezier@0.0.1': {} '@mapbox/vector-tile@2.0.4': @@ -1078,77 +1179,116 @@ snapshots: pbf: 4.0.1 supercluster: 8.0.1 - '@module-federation/error-codes@0.21.4': {} + '@module-federation/error-codes@0.22.0': {} - '@module-federation/runtime-core@0.21.4': + '@module-federation/runtime-core@0.22.0': dependencies: - '@module-federation/error-codes': 0.21.4 - '@module-federation/sdk': 0.21.4 + '@module-federation/error-codes': 0.22.0 + '@module-federation/sdk': 0.22.0 - '@module-federation/runtime-tools@0.21.4': + '@module-federation/runtime-tools@0.22.0': dependencies: - '@module-federation/runtime': 0.21.4 - '@module-federation/webpack-bundler-runtime': 0.21.4 + '@module-federation/runtime': 0.22.0 + '@module-federation/webpack-bundler-runtime': 0.22.0 - '@module-federation/runtime@0.21.4': + '@module-federation/runtime@0.22.0': dependencies: - '@module-federation/error-codes': 0.21.4 - '@module-federation/runtime-core': 0.21.4 - '@module-federation/sdk': 0.21.4 + '@module-federation/error-codes': 0.22.0 + '@module-federation/runtime-core': 0.22.0 + '@module-federation/sdk': 0.22.0 - '@module-federation/sdk@0.21.4': {} + '@module-federation/sdk@0.22.0': {} - '@module-federation/webpack-bundler-runtime@0.21.4': + '@module-federation/webpack-bundler-runtime@0.22.0': dependencies: - '@module-federation/runtime': 0.21.4 - '@module-federation/sdk': 0.21.4 + '@module-federation/runtime': 0.22.0 + '@module-federation/sdk': 0.22.0 '@napi-rs/wasm-runtime@1.0.7': dependencies: - '@emnapi/core': 1.7.1 - '@emnapi/runtime': 1.7.1 + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.9.2 '@tybys/wasm-util': 0.10.1 optional: true '@parcel/watcher-android-arm64@2.5.1': optional: true + '@parcel/watcher-android-arm64@2.5.6': + optional: true + '@parcel/watcher-darwin-arm64@2.5.1': optional: true + '@parcel/watcher-darwin-arm64@2.5.6': + optional: true + '@parcel/watcher-darwin-x64@2.5.1': optional: true + '@parcel/watcher-darwin-x64@2.5.6': + optional: true + '@parcel/watcher-freebsd-x64@2.5.1': optional: true + '@parcel/watcher-freebsd-x64@2.5.6': + optional: true + '@parcel/watcher-linux-arm-glibc@2.5.1': optional: true + '@parcel/watcher-linux-arm-glibc@2.5.6': + optional: true + '@parcel/watcher-linux-arm-musl@2.5.1': optional: true + '@parcel/watcher-linux-arm-musl@2.5.6': + optional: true + '@parcel/watcher-linux-arm64-glibc@2.5.1': optional: true + '@parcel/watcher-linux-arm64-glibc@2.5.6': + optional: true + '@parcel/watcher-linux-arm64-musl@2.5.1': optional: true + '@parcel/watcher-linux-arm64-musl@2.5.6': + optional: true + '@parcel/watcher-linux-x64-glibc@2.5.1': optional: true + '@parcel/watcher-linux-x64-glibc@2.5.6': + optional: true + '@parcel/watcher-linux-x64-musl@2.5.1': optional: true + '@parcel/watcher-linux-x64-musl@2.5.6': + optional: true + '@parcel/watcher-win32-arm64@2.5.1': optional: true + '@parcel/watcher-win32-arm64@2.5.6': + optional: true + '@parcel/watcher-win32-ia32@2.5.1': optional: true + '@parcel/watcher-win32-ia32@2.5.6': + optional: true + '@parcel/watcher-win32-x64@2.5.1': optional: true + '@parcel/watcher-win32-x64@2.5.6': + optional: true + '@parcel/watcher@2.5.1': dependencies: detect-libc: 1.0.3 @@ -1171,30 +1311,54 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.1 optional: true + '@parcel/watcher@2.5.6': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.4 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.5.6 + '@parcel/watcher-darwin-arm64': 2.5.6 + '@parcel/watcher-darwin-x64': 2.5.6 + '@parcel/watcher-freebsd-x64': 2.5.6 + '@parcel/watcher-linux-arm-glibc': 2.5.6 + '@parcel/watcher-linux-arm-musl': 2.5.6 + '@parcel/watcher-linux-arm64-glibc': 2.5.6 + '@parcel/watcher-linux-arm64-musl': 2.5.6 + '@parcel/watcher-linux-x64-glibc': 2.5.6 + '@parcel/watcher-linux-x64-musl': 2.5.6 + '@parcel/watcher-win32-arm64': 2.5.6 + '@parcel/watcher-win32-ia32': 2.5.6 + '@parcel/watcher-win32-x64': 2.5.6 + optional: true + '@petamoriken/float16@3.9.3': {} - '@rsbuild/core@1.6.7': + '@rsbuild/core@1.7.5': dependencies: - '@rspack/core': 1.6.4(@swc/helpers@0.5.17) + '@rspack/core': 1.7.11(@swc/helpers@0.5.21) '@rspack/lite-tapable': 1.1.0 - '@swc/helpers': 0.5.17 - core-js: 3.46.0 + '@swc/helpers': 0.5.21 + core-js: 3.47.0 jiti: 2.6.1 - '@rsbuild/plugin-sass@1.4.0(@rsbuild/core@1.6.7)': + '@rsbuild/plugin-sass@1.5.1(@rsbuild/core@1.7.5)': dependencies: - '@rsbuild/core': 1.6.7 deepmerge: 4.3.1 loader-utils: 2.0.4 - postcss: 8.5.6 - reduce-configs: 1.1.1 - sass-embedded: 1.93.3 + postcss: 8.5.9 + reduce-configs: 1.1.2 + sass-embedded: 1.99.0 + optionalDependencies: + '@rsbuild/core': 1.7.5 - '@rsbuild/plugin-svelte@1.0.11(@rsbuild/core@1.6.7)(postcss@8.5.6)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3)': + '@rsbuild/plugin-svelte@1.1.1(@rsbuild/core@1.7.5)(postcss@8.5.9)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3)': dependencies: - '@rsbuild/core': 1.6.7 svelte-loader: 3.2.4(svelte@5.43.14) - svelte-preprocess: 6.0.3(postcss@8.5.6)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3) + svelte-preprocess: 6.0.3(postcss@8.5.9)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3) + optionalDependencies: + '@rsbuild/core': 1.7.5 transitivePeerDependencies: - '@babel/core' - coffeescript @@ -1208,58 +1372,58 @@ snapshots: - svelte - typescript - '@rspack/binding-darwin-arm64@1.6.4': + '@rspack/binding-darwin-arm64@1.7.11': optional: true - '@rspack/binding-darwin-x64@1.6.4': + '@rspack/binding-darwin-x64@1.7.11': optional: true - '@rspack/binding-linux-arm64-gnu@1.6.4': + '@rspack/binding-linux-arm64-gnu@1.7.11': optional: true - '@rspack/binding-linux-arm64-musl@1.6.4': + '@rspack/binding-linux-arm64-musl@1.7.11': optional: true - '@rspack/binding-linux-x64-gnu@1.6.4': + '@rspack/binding-linux-x64-gnu@1.7.11': optional: true - '@rspack/binding-linux-x64-musl@1.6.4': + '@rspack/binding-linux-x64-musl@1.7.11': optional: true - '@rspack/binding-wasm32-wasi@1.6.4': + '@rspack/binding-wasm32-wasi@1.7.11': dependencies: '@napi-rs/wasm-runtime': 1.0.7 optional: true - '@rspack/binding-win32-arm64-msvc@1.6.4': + '@rspack/binding-win32-arm64-msvc@1.7.11': optional: true - '@rspack/binding-win32-ia32-msvc@1.6.4': + '@rspack/binding-win32-ia32-msvc@1.7.11': optional: true - '@rspack/binding-win32-x64-msvc@1.6.4': + '@rspack/binding-win32-x64-msvc@1.7.11': optional: true - '@rspack/binding@1.6.4': + '@rspack/binding@1.7.11': optionalDependencies: - '@rspack/binding-darwin-arm64': 1.6.4 - '@rspack/binding-darwin-x64': 1.6.4 - '@rspack/binding-linux-arm64-gnu': 1.6.4 - '@rspack/binding-linux-arm64-musl': 1.6.4 - '@rspack/binding-linux-x64-gnu': 1.6.4 - '@rspack/binding-linux-x64-musl': 1.6.4 - '@rspack/binding-wasm32-wasi': 1.6.4 - '@rspack/binding-win32-arm64-msvc': 1.6.4 - '@rspack/binding-win32-ia32-msvc': 1.6.4 - '@rspack/binding-win32-x64-msvc': 1.6.4 - - '@rspack/core@1.6.4(@swc/helpers@0.5.17)': - dependencies: - '@module-federation/runtime-tools': 0.21.4 - '@rspack/binding': 1.6.4 + '@rspack/binding-darwin-arm64': 1.7.11 + '@rspack/binding-darwin-x64': 1.7.11 + '@rspack/binding-linux-arm64-gnu': 1.7.11 + '@rspack/binding-linux-arm64-musl': 1.7.11 + '@rspack/binding-linux-x64-gnu': 1.7.11 + '@rspack/binding-linux-x64-musl': 1.7.11 + '@rspack/binding-wasm32-wasi': 1.7.11 + '@rspack/binding-win32-arm64-msvc': 1.7.11 + '@rspack/binding-win32-ia32-msvc': 1.7.11 + '@rspack/binding-win32-x64-msvc': 1.7.11 + + '@rspack/core@1.7.11(@swc/helpers@0.5.21)': + dependencies: + '@module-federation/runtime-tools': 0.22.0 + '@rspack/binding': 1.7.11 '@rspack/lite-tapable': 1.1.0 optionalDependencies: - '@swc/helpers': 0.5.17 + '@swc/helpers': 0.5.21 '@rspack/lite-tapable@1.1.0': {} @@ -1267,7 +1431,7 @@ snapshots: dependencies: acorn: 8.15.0 - '@swc/helpers@0.5.17': + '@swc/helpers@0.5.21': dependencies: tslib: 2.8.1 @@ -1307,8 +1471,6 @@ snapshots: fill-range: 7.1.1 optional: true - buffer-builder@0.2.0: {} - cheap-ruler@4.0.0: {} chokidar@4.0.3: @@ -1319,7 +1481,7 @@ snapshots: colorjs.io@0.5.2: {} - core-js@3.46.0: {} + core-js@3.47.0: {} csscolorparser@1.0.3: {} @@ -1328,6 +1490,9 @@ snapshots: detect-libc@1.0.3: optional: true + detect-libc@2.1.2: + optional: true + earcut@3.0.2: {} emojis-list@3.0.0: {} @@ -1338,7 +1503,9 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - fdir@6.5.0: {} + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 fflate@0.8.2: {} @@ -1374,6 +1541,8 @@ snapshots: immutable@5.1.4: {} + immutable@5.1.5: {} + is-extglob@2.1.1: optional: true @@ -1416,7 +1585,7 @@ snapshots: '@mapbox/jsonlint-lines-primitives': 2.0.2 '@mapbox/mapbox-gl-supported': 3.0.0 '@mapbox/point-geometry': 1.1.0 - '@mapbox/tiny-sdf': 2.0.7 + '@mapbox/tiny-sdf': 2.1.0 '@mapbox/unitbezier': 0.0.1 '@mapbox/vector-tile': 2.0.4 '@mapbox/whoots-js': 3.1.0 @@ -1506,11 +1675,14 @@ snapshots: picomatch@2.3.1: optional: true + picomatch@4.0.4: + optional: true + pmtiles@4.3.0: dependencies: fflate: 0.8.2 - postcss@8.5.6: + postcss@8.5.9: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -1533,7 +1705,7 @@ snapshots: readdirp@4.1.2: {} - reduce-configs@1.1.1: {} + reduce-configs@1.1.2: {} resolve-protobuf-schema@2.1.0: dependencies: @@ -1551,110 +1723,109 @@ snapshots: dependencies: mri: 1.2.0 - sass-embedded-all-unknown@1.93.3: + sass-embedded-all-unknown@1.99.0: dependencies: - sass: 1.93.3 + sass: 1.99.0 optional: true - sass-embedded-android-arm64@1.93.3: + sass-embedded-android-arm64@1.99.0: optional: true - sass-embedded-android-arm@1.93.3: + sass-embedded-android-arm@1.99.0: optional: true - sass-embedded-android-riscv64@1.93.3: + sass-embedded-android-riscv64@1.99.0: optional: true - sass-embedded-android-x64@1.93.3: + sass-embedded-android-x64@1.99.0: optional: true - sass-embedded-darwin-arm64@1.93.3: + sass-embedded-darwin-arm64@1.99.0: optional: true - sass-embedded-darwin-x64@1.93.3: + sass-embedded-darwin-x64@1.99.0: optional: true - sass-embedded-linux-arm64@1.93.3: + sass-embedded-linux-arm64@1.99.0: optional: true - sass-embedded-linux-arm@1.93.3: + sass-embedded-linux-arm@1.99.0: optional: true - sass-embedded-linux-musl-arm64@1.93.3: + sass-embedded-linux-musl-arm64@1.99.0: optional: true - sass-embedded-linux-musl-arm@1.93.3: + sass-embedded-linux-musl-arm@1.99.0: optional: true - sass-embedded-linux-musl-riscv64@1.93.3: + sass-embedded-linux-musl-riscv64@1.99.0: optional: true - sass-embedded-linux-musl-x64@1.93.3: + sass-embedded-linux-musl-x64@1.99.0: optional: true - sass-embedded-linux-riscv64@1.93.3: + sass-embedded-linux-riscv64@1.99.0: optional: true - sass-embedded-linux-x64@1.93.3: + sass-embedded-linux-x64@1.99.0: optional: true - sass-embedded-unknown-all@1.93.3: + sass-embedded-unknown-all@1.99.0: dependencies: - sass: 1.93.3 + sass: 1.99.0 optional: true - sass-embedded-win32-arm64@1.93.3: + sass-embedded-win32-arm64@1.99.0: optional: true - sass-embedded-win32-x64@1.93.3: + sass-embedded-win32-x64@1.99.0: optional: true - sass-embedded@1.93.3: + sass-embedded@1.99.0: dependencies: - '@bufbuild/protobuf': 2.10.1 - buffer-builder: 0.2.0 + '@bufbuild/protobuf': 2.11.0 colorjs.io: 0.5.2 - immutable: 5.1.4 + immutable: 5.1.5 rxjs: 7.8.2 supports-color: 8.1.1 sync-child-process: 1.0.2 varint: 6.0.0 optionalDependencies: - sass-embedded-all-unknown: 1.93.3 - sass-embedded-android-arm: 1.93.3 - sass-embedded-android-arm64: 1.93.3 - sass-embedded-android-riscv64: 1.93.3 - sass-embedded-android-x64: 1.93.3 - sass-embedded-darwin-arm64: 1.93.3 - sass-embedded-darwin-x64: 1.93.3 - sass-embedded-linux-arm: 1.93.3 - sass-embedded-linux-arm64: 1.93.3 - sass-embedded-linux-musl-arm: 1.93.3 - sass-embedded-linux-musl-arm64: 1.93.3 - sass-embedded-linux-musl-riscv64: 1.93.3 - sass-embedded-linux-musl-x64: 1.93.3 - sass-embedded-linux-riscv64: 1.93.3 - sass-embedded-linux-x64: 1.93.3 - sass-embedded-unknown-all: 1.93.3 - sass-embedded-win32-arm64: 1.93.3 - sass-embedded-win32-x64: 1.93.3 - - sass@1.93.3: + sass-embedded-all-unknown: 1.99.0 + sass-embedded-android-arm: 1.99.0 + sass-embedded-android-arm64: 1.99.0 + sass-embedded-android-riscv64: 1.99.0 + sass-embedded-android-x64: 1.99.0 + sass-embedded-darwin-arm64: 1.99.0 + sass-embedded-darwin-x64: 1.99.0 + sass-embedded-linux-arm: 1.99.0 + sass-embedded-linux-arm64: 1.99.0 + sass-embedded-linux-musl-arm: 1.99.0 + sass-embedded-linux-musl-arm64: 1.99.0 + sass-embedded-linux-musl-riscv64: 1.99.0 + sass-embedded-linux-musl-x64: 1.99.0 + sass-embedded-linux-riscv64: 1.99.0 + sass-embedded-linux-x64: 1.99.0 + sass-embedded-unknown-all: 1.99.0 + sass-embedded-win32-arm64: 1.99.0 + sass-embedded-win32-x64: 1.99.0 + + sass@1.97.3: dependencies: chokidar: 4.0.3 immutable: 5.1.4 source-map-js: 1.2.1 optionalDependencies: '@parcel/watcher': 2.5.1 - optional: true - sass@1.97.3: + sass@1.99.0: dependencies: chokidar: 4.0.3 - immutable: 5.1.4 + immutable: 5.1.5 source-map-js: 1.2.1 optionalDependencies: - '@parcel/watcher': 2.5.1 + '@parcel/watcher': 2.5.6 + optional: true source-map-js@1.2.1: {} @@ -1670,11 +1841,11 @@ snapshots: dependencies: has-flag: 4.0.0 - svelte-check@4.3.4(svelte@5.43.14)(typescript@5.9.3): + svelte-check@4.3.4(picomatch@4.0.4)(svelte@5.43.14)(typescript@5.9.3): dependencies: '@jridgewell/trace-mapping': 0.3.31 chokidar: 4.0.3 - fdir: 6.5.0 + fdir: 6.5.0(picomatch@4.0.4) picocolors: 1.1.1 sade: 1.8.1 svelte: 5.43.14 @@ -1695,11 +1866,11 @@ snapshots: svelte-dev-helper: 1.1.9 svelte-hmr: 0.14.12(svelte@5.43.14) - svelte-preprocess@6.0.3(postcss@8.5.6)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3): + svelte-preprocess@6.0.3(postcss@8.5.9)(sass@1.97.3)(svelte@5.43.14)(typescript@5.9.3): dependencies: svelte: 5.43.14 optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.9 sass: 1.97.3 typescript: 5.9.3 @@ -1726,9 +1897,9 @@ snapshots: sync-child-process@1.0.2: dependencies: - sync-message-port: 1.1.3 + sync-message-port: 1.2.0 - sync-message-port@1.1.3: {} + sync-message-port@1.2.0: {} tabbable@5.3.3: {} diff --git a/website/rsbuild.config.ts b/website/rsbuild.config.ts index 24fd483..bb2fe98 100644 --- a/website/rsbuild.config.ts +++ b/website/rsbuild.config.ts @@ -3,11 +3,14 @@ import { pluginSass } from '@rsbuild/plugin-sass'; import { pluginSvelte } from '@rsbuild/plugin-svelte'; export default defineConfig({ - plugins: [pluginSvelte(), pluginSass()], - dev: { - // See: https://github.com/sveltejs/svelte-loader?tab=readme-ov-file#hot-reload - hmr: false, - }, + plugins: [ + pluginSvelte({ + svelteLoaderOptions: { + emitCss: true, + }, + }), + pluginSass(), + ], html: { template: './public/index.html', }, diff --git a/website/src/HeatmapLayer.ts b/website/src/HeatmapLayer.ts index d7471d6..47ee971 100644 --- a/website/src/HeatmapLayer.ts +++ b/website/src/HeatmapLayer.ts @@ -88,7 +88,7 @@ function onWorkerMessage(event: MessageEvent) { } } -const HeatmapLayer: CustomLayerInterface = { +export const HeatmapLayer: CustomLayerInterface = { id: 'heatmap-tiles', type: 'custom', renderingMode: '2d', @@ -394,5 +394,3 @@ function makeFillerTile() { fillerTile = tile; } - -export { HeatmapLayer }; diff --git a/website/src/Home.svelte b/website/src/Home.svelte index e31b1a6..51c0406 100644 --- a/website/src/Home.svelte +++ b/website/src/Home.svelte @@ -1,6 +1,7 @@ +
+
+ {#if state.config.project === "galiano"} + + {/if} + {#if state.config.project === "world"} {/if} @@ -150,6 +162,13 @@ diff --git a/website/src/index.ts b/website/src/index.ts index 2b268d1..c09515a 100644 --- a/website/src/index.ts +++ b/website/src/index.ts @@ -1,9 +1,22 @@ import { mount } from 'svelte'; import App from './App.svelte'; import './styles/index.scss'; +import { state } from './state.svelte'; + +const root = document.getElementById('root') as HTMLElement; + +// Because of MaplibreGL we have to manually handle some post PMR setup +root.innerHTML = ''; const app = mount(App, { - target: document.getElementById('root') as HTMLElement, + target: root, }); +if (import.meta.webpackHot) { + import.meta.webpackHot.accept(); + import.meta.webpackHot.dispose(() => { + state.map?.remove(); + }); +} + export default app; diff --git a/website/src/lib/polarSegments.ts b/website/src/lib/Viewshed.ts similarity index 85% rename from website/src/lib/polarSegments.ts rename to website/src/lib/Viewshed.ts index 7bf8c10..3b771a2 100644 --- a/website/src/lib/polarSegments.ts +++ b/website/src/lib/Viewshed.ts @@ -4,19 +4,37 @@ import proj4 from 'proj4'; import { ANGLE_SHIFT, aeqdProjectionString, rotate, toRadians } from './utils'; export type PolarSegments = { angleID: number; pairs: number[] }; +export const DEFAULT_OPACITY = 0.5; export class Viewshed { + id: string; centre: LngLat; polar_segments: PolarSegments[]; scale: number; + colour = '#00ff00'; + isVisible = true; + isLocked = false; - constructor(centre: LngLat, scale: number, polar_segments: PolarSegments[]) { + constructor( + id: string, + centre: LngLat, + scale: number, + polar_segments: PolarSegments[], + ) { + this.id = id; this.centre = centre; this.scale = scale; this.polar_segments = polar_segments; } - create() { + getViewshedLayerID() { + return `viewshed-layer-${this.id}`; + } + getViewshedSourceID() { + return `viewshed-source-${this.id}`; + } + + geoJSON() { const features = []; for (const polar_segment of this.polar_segments) { for (let i = 0; i < polar_segment.pairs.length; i += 2) { diff --git a/website/src/lib/renderViewsheds.ts b/website/src/lib/renderViewsheds.ts index fb9ea98..81f4f45 100644 --- a/website/src/lib/renderViewsheds.ts +++ b/website/src/lib/renderViewsheds.ts @@ -1,49 +1,111 @@ -import type { GeoJSONSource, LngLat } from 'maplibre-gl'; +import type { LngLat } from 'maplibre-gl'; import { state } from '../state.svelte.ts'; -import { type PolarSegments, Viewshed } from './polarSegments.ts'; +import { DEFAULT_OPACITY, type PolarSegments, Viewshed } from './Viewshed.ts'; export function setupViewsheds() { - state.map?.addSource('viewshed', { - type: 'geojson', - data: { - type: 'FeatureCollection', - features: [], - }, - }); - - state.map?.addLayer({ - id: 'viewshed-fill', - type: 'fill', - source: 'viewshed', - paint: { - 'fill-color': '#00ff00', - 'fill-outline-color': 'transparent', - 'fill-opacity': 0.4, - }, - }); + renderAllViewsheds(); state.map?.on('click', async (event) => { if (!state.map) { return; } - render(event.lngLat); + removeUnlockedViewsheds(); + renderNewViewshed(event.lngLat); }); } -export async function render(lngLat: LngLat) { +export function renderAllViewsheds() { + for (const viewshed of state.viewsheds) { + const layer = state.map?.getLayer(viewshed.getViewshedLayerID()); + if (!layer) { + addViewshed(viewshed); + } + + state.map?.setLayoutProperty( + viewshed.getViewshedLayerID(), + 'visibility', + viewshed.isVisible ? 'visible' : 'none', + ); + } +} + +export function highlightViewshed(viewshedToHighlight: Viewshed) { + for (const viewshed of state.viewsheds) { + if (viewshed.id === viewshedToHighlight.id) continue; + + state.map?.setPaintProperty( + viewshed.getViewshedLayerID(), + 'fill-opacity', + 0.05, + ); + } +} + +export function desaturateAllViewsheds() { + for (const viewshed of state.viewsheds) { + state.map?.setPaintProperty( + viewshed.getViewshedLayerID(), + 'fill-opacity', + DEFAULT_OPACITY, + ); + } +} + +export function updateViewshedColour(viewshed: Viewshed) { + state.map?.setPaintProperty( + viewshed.getViewshedLayerID(), + 'fill-color', + viewshed.colour, + ); +} + +export function removeUnlockedViewsheds() { + for (const viewshed of state.viewsheds) { + const layer = state.map?.getLayer(viewshed.getViewshedLayerID()); + + if (layer && !viewshed.isLocked) { + removeViewshed(viewshed); + } + } +} + +export function removeViewshed(viewshed: Viewshed) { + state.viewsheds = state.viewsheds.filter((v) => v !== viewshed); + state.map?.removeLayer(viewshed.getViewshedLayerID()); + state.map?.removeSource(viewshed.getViewshedSourceID()); +} + +export async function renderNewViewshed(lngLat: LngLat) { const bytes = await getViewshedData(lngLat); const segments = parseViewshedBytes(bytes); const scale = 50; // TODO: Get from API? - const builder = new Viewshed(lngLat, scale, segments); - const viewshed = builder.create(); + const id = crypto.randomUUID(); + const viewshed = new Viewshed(id, lngLat, scale, segments); + state.viewsheds.push(viewshed); + + renderAllViewsheds(); +} - const source = state.map?.getSource('viewshed') as GeoJSONSource; +function addViewshed(viewshed: Viewshed) { + const sourceID = viewshed.getViewshedSourceID(); - if (viewshed) { - source?.setData(viewshed); - } + state.map?.addSource(sourceID, { + type: 'geojson', + data: viewshed.geoJSON(), + }); + + state.map?.addLayer({ + id: viewshed.getViewshedLayerID(), + type: 'fill', + source: sourceID, + paint: { + 'fill-color': viewshed.colour, + 'fill-outline-color': 'transparent', + 'fill-opacity': DEFAULT_OPACITY, + }, + }); } async function getViewshedData(lngLat: LngLat) { diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 6248452..22ddd08 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -185,7 +185,7 @@ export function setVectorVisibility(state: AppState, isVisible: boolean) { if (layer.id === 'mountain_peaks') continue; if (layer.id === 'background') continue; if (layer.id === 'longest-line-fill') continue; - if (layer.id === 'viewshed-fill') continue; + if (layer.id.includes('viewshed-')) continue; if (state.map?.getLayer(layer.id)) state.map?.setLayoutProperty( layer.id, diff --git a/website/src/modals/Viewsheds.svelte b/website/src/modals/Viewsheds.svelte new file mode 100644 index 0000000..4dea848 --- /dev/null +++ b/website/src/modals/Viewsheds.svelte @@ -0,0 +1,134 @@ + + + +

Viewsheds

+ {#if state.viewsheds.length == 0} + Click any point to render its viewshed + {/if} + + {#if state.viewsheds.length > 0} +
+ {#each state.viewsheds as viewshed} +
{ + if (viewshed.isVisible) { + highlightViewshed(viewshed); + } + }} + onmouseleave={() => { + desaturateAllViewsheds(); + }} + > + + + + + + + {viewshed.centre.lat.toFixed(5)},{viewshed.centre.lng.toFixed(5)} +
+ {/each} +
+ {/if} +
+ + diff --git a/website/src/state.svelte.ts b/website/src/state.svelte.ts index 512e120..d5fa4fa 100644 --- a/website/src/state.svelte.ts +++ b/website/src/state.svelte.ts @@ -1,6 +1,7 @@ import type { Map as MapLibre } from 'maplibre-gl'; import { getConfig } from './config'; import type { LongestLine } from './lib/getLongestLine'; +import type { Viewshed } from './lib/Viewshed'; import type { LongestLineH3 } from './lib/worldLines'; export type HeatmapConfig = { @@ -17,6 +18,7 @@ export interface AppState { isFirstInteraction: boolean; bruteForceLoadingLine: boolean; heatmapConfig: HeatmapConfig; + viewsheds: Viewshed[]; isFlying: boolean; isSearchOpen: boolean; isInfoOpen: boolean; @@ -34,6 +36,7 @@ export const state = $state({ contrast: 1 - 0.45, intensity: 1 - 0.5, }, + viewsheds: [], isFlying: false, isSearchOpen: false, isInfoOpen: true, From 0116490e387985c7e988ae73953212ddfc957699 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Fri, 10 Apr 2026 10:49:01 +0100 Subject: [PATCH 07/15] scripts: support variable resolution --- scripts/cloud_init_ubuntu22.bash | 2 +- scripts/make_pmtiles.bash | 37 ++++++++++++++++++++++++-------- scripts/s3.bash | 1 + 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/scripts/cloud_init_ubuntu22.bash b/scripts/cloud_init_ubuntu22.bash index 155ba73..9811b32 100644 --- a/scripts/cloud_init_ubuntu22.bash +++ b/scripts/cloud_init_ubuntu22.bash @@ -11,7 +11,7 @@ function cloud_init_ubuntu22 { sudo apt update sudo apt install --yes \ libvulkan1 mesa-vulkan-drivers vulkan-tools \ - build-essential pkg-config \ + build-essential pkg-config clang \ libgdal-dev gdal-bin python3-gdal rsync htop \ jq rclone tmux sqlite3 parallel bc curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y diff --git a/scripts/make_pmtiles.bash b/scripts/make_pmtiles.bash index 1a73461..4834d05 100644 --- a/scripts/make_pmtiles.bash +++ b/scripts/make_pmtiles.bash @@ -9,6 +9,8 @@ function make_pmtiles { # Output `.pmtile` tile. local output=$2 + local resolution=${3} + ensure_tiles_env if [[ $version == "latest" ]]; then @@ -26,21 +28,35 @@ function make_pmtiles { exit 1 fi - prepare_all_tiffs work/raw "$archive" + prepare_all_tiffs work/raw "$archive" "$resolution" # Collate all the heatmap GeoTiffs into a single virtual file. gdalbuildvrt "$world_vrt" "$archive"/*.tiff + max_zoom="$(scale_to_max_zoom "$resolution")" + # Create the global `.pmtile` - uv run scripts/to_pmtiles.py "$world_vrt" "$output" \ + UV_PYTHON_DOWNLOADS=automatic \ + uv run --python 3.12 \ + scripts/to_pmtiles.py "$world_vrt" "$output" \ --min_zoom 0 \ - --max_zoom 11 + --max_zoom "$max_zoom" if [[ $version != "local" ]]; then rclone_put "$output" viewview/runs/"$version"/pmtiles/ fi } +function scale_to_max_zoom { + local scale=$1 + EARTH_CIRCUMFERENCE=40075016.686 + echo " + scale=10; \ + z = l($EARTH_CIRCUMFERENCE / (256 * $scale)) / l(2);\ + scale=0; if (z>scale(z)) z/1 + 1 else z/1 + " | bc -l +} + # Interpolate the TVS heatmap's data to EPSG:3857. # # Along with all the other heatmap GeoTiff's, it will be used to create the single global @@ -57,12 +73,13 @@ function process_raw_tvs_tiff { local input=$1 local output=$2 + local resolution=${3} # These are in an array because I've been exploring splitting tiles that cross the antimeridian. # Having an array makes it easier to diverge the processing into west and east branches. local warp_args=( "-overwrite" - "-tr" "100" "100" + "-tr" "$resolution" "$resolution" "-t_srs" "EPSG:3857" "-dstnodata" "0" "-srcnodata" "0" @@ -93,22 +110,24 @@ function prepare_tiff { local source=$1 local destination=$2 + local resolution=${3} filename=$(basename "$source") latitude=$(echo "$filename" | sed -E 's#.*_([0-9.-]+)\.tiff#\1#') + is_above_antaricta=$(echo "$latitude > -80" | bc -l) - if (($(echo "$latitude > -80" | bc -l))); then - process_raw_tvs_tiff "$source" "$destination/$filename" + if [[ "$is_above_antaricta" -eq 1 || "$filename" == "total_surfaces.tiff" ]]; then + process_raw_tvs_tiff "$source" "$destination/$filename" "$resolution" create_overviews_for_tiff "$destination/$filename" else - echo "Not creating preparing heatmap tiff for Antartic tile: $input" + echo "Not preparing heatmap tiff for Antartic tile: $input" fi - } function prepare_all_tiffs { local source_directory=$1 local destination_directory=$2 + local resolution=${3} export -f prepare_tiff export -f process_raw_tvs_tiff @@ -117,5 +136,5 @@ function prepare_all_tiffs { mkdir -p "$destination_directory" find "$source_directory" -name "*.tiff" | - parallel -j +0 --halt now,fail=1 prepare_tiff {} "$destination_directory" + parallel -j +0 --halt now,fail=1 prepare_tiff {} "$destination_directory" "$resolution" } diff --git a/scripts/s3.bash b/scripts/s3.bash index 51bf6e0..f09b202 100644 --- a/scripts/s3.bash +++ b/scripts/s3.bash @@ -6,6 +6,7 @@ function s3 { --host-bucket="%(bucket)s.$R2_HOST" \ --access_key="$VIEWVIEW_S3_ACCESS_KEY" \ --secret_key="$VIEWVIEW_S3_SECRET" \ + --human \ "$@" } From 4d7d79b720b1ceb56143e387da9672c9a1c34acc Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Fri, 10 Apr 2026 10:49:51 +0100 Subject: [PATCH 08/15] api: support new framing For each blob: `u16` angle + `u16` length + bytes --- crates/server/src/app.rs | 36 ++++++++++++++++++++---------------- crates/server/src/config.rs | 4 ++-- crates/server/src/test.rs | 25 ++++++++++++------------- crates/server/src/utils.rs | 2 +- 4 files changed, 35 insertions(+), 32 deletions(-) diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index a6bdd6d..8e26cf3 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -15,11 +15,11 @@ use axum::{ struct AppState { /// Pool of connections to the DB. pool: sqlx::Pool, - /// Metadata about the underlying DEM that generated the viewsheds + /// Metadata about the underlying DEM that generated the viewsheds. metadata: crate::metadata::MetaData, } -/// Setup the DB +/// Setup the DB. pub async fn db(config: crate::config::Config) -> Result> { let options = sqlx::sqlite::SqliteConnectOptions::new().filename(config.db_path); let pool = sqlx::sqlite::SqlitePool::connect_with(options).await?; @@ -40,6 +40,17 @@ pub async fn router(pool: sqlx::Pool) -> Result { tower_http::compression::CompressionLayer::new() .gzip(true) .br(true), + ) + .layer( + tower_http::cors::CorsLayer::new() + .allow_origin([ + "http://localhost:3000".parse()?, + "https://alltheviews.world".parse()?, + "https://map.alltheviews.world".parse()?, + "https://galiano.alltheviews.world".parse()?, + "https://tombh-galiano-viewview.tom-364.workers.dev".parse()?, + ]) + .allow_methods([axum::http::Method::GET]), ); Ok(router) @@ -73,13 +84,12 @@ async fn get_viewshed( .into_response(); }; - let result: std::result::Result,)>, sqlx::Error> = sqlx::query_as( + let result: std::result::Result)>, sqlx::Error> = sqlx::query_as( " - SELECT MIN(visible_segments) AS visible_segments + SELECT angle_id, MIN(visible_segments) FROM polar_segments WHERE dem_id = ?1 - GROUP BY angle_id - ORDER BY angle_id ASC; + GROUP BY angle_id; ", ) .bind(dem_id) @@ -107,24 +117,18 @@ async fn get_viewshed( .into_response(); } - // Framing: `u16` count, then for each blob `u16` length + bytes + // Framing: for each blob: `u16` angle + `u16` length + bytes let mut payload = Vec::new(); - #[expect( - clippy::as_conversions, - clippy::cast_possible_truncation, - reason = "The number of angles must always fit in `u16`" - )] - let angle_count = rows.len() as u16; - payload.extend(angle_count.to_be_bytes()); for blob in &rows { + payload.extend(blob.0.to_be_bytes()); #[expect( clippy::as_conversions, clippy::cast_possible_truncation, reason = "The number of segments for a given angle must fit in `u16`" )] - payload.extend((blob.0.len() as u16).to_be_bytes()); - payload.extend(blob.0.clone()); + payload.extend((blob.1.len() as u16).to_be_bytes()); + payload.extend(blob.1.clone()); } let body = bytes::Bytes::from(payload); diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index 1571068..e0a9679 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -1,9 +1,9 @@ -//! Config for the CLI +//! Config for the CLI. /// Config arguments. #[derive(clap::Parser, Debug, Clone, Default)] pub struct Config { - /// Path to the viewshed database + /// Path to the viewshed database. #[arg(long, value_name = "Database path")] pub db_path: std::path::PathBuf, } diff --git a/crates/server/src/test.rs b/crates/server/src/test.rs index 6ccc20b..247a975 100644 --- a/crates/server/src/test.rs +++ b/crates/server/src/test.rs @@ -24,24 +24,23 @@ mod tests { clippy::integer_division_remainder_used, reason = "These are just for tests" )] - fn parse_payload_cursor(data: &[u8]) -> Vec> { + fn parse_payload_cursor(data: &[u8]) -> Vec<(u16, Vec)> { let mut cursor = std::io::Cursor::new(data); - let angle_count = read_u16_be(&mut cursor) as usize; - let mut out = Vec::with_capacity(angle_count); + let mut out = Vec::new(); - for _ in 0..angle_count { - let byte_length = read_u16_be(&mut cursor) as usize; + while (cursor.position() as usize) < data.len() { + let angle_id = read_u16_be(&mut cursor); - let mut values = Vec::with_capacity(byte_length / 2); - let mut buf = vec![0u8; byte_length]; - cursor.read_exact(&mut buf).unwrap(); - for chunk in buf.chunks_exact(2) { + let segments_length = read_u16_be(&mut cursor) as usize; + let mut values = Vec::with_capacity(segments_length / 2); + let mut buffer = vec![0u8; segments_length]; + cursor.read_exact(&mut buffer).unwrap(); + for chunk in buffer.chunks_exact(2) { values.push(u16::from_be_bytes(chunk.try_into().unwrap())); } - out.push(values); + out.push((angle_id, values)); } - assert!(cursor.position() as usize == data.len()); out } @@ -87,7 +86,7 @@ mod tests { let segments = parse_payload_cursor(&body); - assert_eq!(segments[0], vec![0, 4]); - assert_eq!(segments[300], vec![0, 2, 3, 1]); + assert_eq!(segments[0], (0, vec![0, 4])); + assert_eq!(segments[300], (300, vec![0, 2, 3, 1])); } } diff --git a/crates/server/src/utils.rs b/crates/server/src/utils.rs index 364f360..81e1f1e 100644 --- a/crates/server/src/utils.rs +++ b/crates/server/src/utils.rs @@ -1,4 +1,4 @@ -//! Helper code +//! Helper code. use color_eyre::Result; From 997c44ef57899c6e4ab2fd03da3a85066ee0e508 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Sat, 11 Apr 2026 00:18:39 +0100 Subject: [PATCH 09/15] perf(api): Some read optimisations --- Cargo.lock | 30 +++++----- crates/server/Cargo.toml | 1 + crates/server/src/app.rs | 90 +++++++++++++++++------------- website/src/lib/renderViewsheds.ts | 9 +++ 4 files changed, 76 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d9fad74..66bc266 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1272,9 +1272,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -1282,9 +1282,9 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" @@ -1310,15 +1310,15 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", @@ -1327,15 +1327,15 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" @@ -1345,9 +1345,9 @@ checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -1357,7 +1357,6 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] @@ -3219,6 +3218,7 @@ dependencies = [ "bytes", "clap", "color-eyre", + "futures-util", "serde", "serde_json", "sqlx", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index fdf1609..591ea86 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -17,6 +17,7 @@ tracing-subscriber = { version = "0.3.20", default-features = false, features = tokio = { version = "1.47.1", features = ["rt-multi-thread"] } tower = "0.5.2" tower-http = { version = "0.6.6", features = ["fs", "trace", "cors", "compression-gzip", "compression-br"] } +futures-util = "0.3.32" [lints] workspace = true diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index 8e26cf3..a74e3fa 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -10,6 +10,10 @@ use axum::{ routing, }; +use futures_util::StreamExt as _; +use sqlx::ConnectOptions as _; +use sqlx::Row as _; + /// All state needed for the app's lifetime. #[derive(Clone)] struct AppState { @@ -21,7 +25,17 @@ struct AppState { /// Setup the DB. pub async fn db(config: crate::config::Config) -> Result> { - let options = sqlx::sqlite::SqliteConnectOptions::new().filename(config.db_path); + let options = sqlx::sqlite::SqliteConnectOptions::new() + .filename(config.db_path) + .journal_mode(sqlx::sqlite::SqliteJournalMode::Off) + .synchronous(sqlx::sqlite::SqliteSynchronous::Off) + .disable_statement_logging() + .read_only(true) + .immutable(true) + .pragma("locking_mode", "EXCLUSIVE") + .pragma("cache_size", "-20000") + .pragma("mmap_size", "1073741824") + .pragma("query_only", "ON"); let pool = sqlx::sqlite::SqlitePool::connect_with(options).await?; Ok(pool) @@ -51,7 +65,14 @@ pub async fn router(pool: sqlx::Pool) -> Result { "https://tombh-galiano-viewview.tom-364.workers.dev".parse()?, ]) .allow_methods([axum::http::Method::GET]), - ); + ) + .layer(tower_http::trace::TraceLayer::new_for_http().on_response( + |_response: &axum::response::Response, + latency: std::time::Duration, + _span: &tracing::Span| { + tracing::debug!("Request completed in {:?}", latency); + }, + )); Ok(router) } @@ -84,57 +105,48 @@ async fn get_viewshed( .into_response(); }; - let result: std::result::Result)>, sqlx::Error> = sqlx::query_as( - " - SELECT angle_id, MIN(visible_segments) - FROM polar_segments - WHERE dem_id = ?1 - GROUP BY angle_id; - ", - ) - .bind(dem_id) - .fetch_all(&state.pool) + let start = tokio::time::Instant::now(); + let mut rows = + sqlx::query("SELECT angle_id, visible_segments FROM polar_segments WHERE dem_id = ?1") + .bind(dem_id) + .fetch(&state.pool); + + let mut payload = Vec::with_capacity(1024 * 16); + let result: Result<()> = async { + // Streaming the results should be faster. + while let Some(row_result) = rows.next().await { + let row = row_result?; + + let angle_id: u16 = row.try_get(0)?; + let bytes: Vec = row.try_get(1)?; + let bytes_length = u16::try_from(bytes.len())?; + + payload.extend_from_slice(&angle_id.to_be_bytes()); + payload.extend_from_slice(&bytes_length.to_be_bytes()); + payload.extend_from_slice(&bytes); + } + Ok(()) + } .await; - let Ok(rows) = result else { + if result.is_err() { return ( StatusCode::INTERNAL_SERVER_ERROR, "Couldn't fetch viewshed data from DB", ) .into_response(); - }; + } - if rows.is_empty() { + if payload.is_empty() { let msg = format!("No viewshed found. Using DEM ID: {dem_id}"); return (StatusCode::NOT_FOUND, msg).into_response(); } - if rows.len() > 360 { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "More than 360 rows found", - ) - .into_response(); - } - - // Framing: for each blob: `u16` angle + `u16` length + bytes - let mut payload = Vec::new(); - - for blob in &rows { - payload.extend(blob.0.to_be_bytes()); - #[expect( - clippy::as_conversions, - clippy::cast_possible_truncation, - reason = "The number of segments for a given angle must fit in `u16`" - )] - payload.extend((blob.1.len() as u16).to_be_bytes()); - payload.extend(blob.1.clone()); - } - let body = bytes::Bytes::from(payload); tracing::debug!( - "Viewshed request for DEM ID {dem_id} found {} bytes", - body.len() + "Viewshed request for DEM ID {dem_id} found {} bytes in {:?}", + body.len(), + start.elapsed() ); ( StatusCode::OK, diff --git a/website/src/lib/renderViewsheds.ts b/website/src/lib/renderViewsheds.ts index 81f4f45..3fd3413 100644 --- a/website/src/lib/renderViewsheds.ts +++ b/website/src/lib/renderViewsheds.ts @@ -78,10 +78,12 @@ export function removeViewshed(viewshed: Viewshed) { export async function renderNewViewshed(lngLat: LngLat) { const bytes = await getViewshedData(lngLat); + const segments = parseViewshedBytes(bytes); const scale = 50; // TODO: Get from API? const id = crypto.randomUUID(); + const viewshed = new Viewshed(id, lngLat, scale, segments); state.viewsheds.push(viewshed); @@ -113,9 +115,16 @@ async function getViewshedData(lngLat: LngLat) { if (import.meta.env.DEV) { apiBase = 'http://localhost:3333'; } + + const start = performance.now(); const response = await fetch( `${apiBase}/viewshed/${lngLat.lng},${lngLat.lat}`, ); + const end = performance.now(); + if (import.meta.env.DEV) { + console.log(`Viewshed fetched in ms`, end - start); + } + return await response.bytes(); } From 42341106924ccdd0147ecd435f23a94bd864933b Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Mon, 13 Apr 2026 21:13:07 +0100 Subject: [PATCH 10/15] `./ctl.sh rclone_put_zip_stream` --- scripts/s3.bash | 38 ++++++++++++++++++++++++++------------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/scripts/s3.bash b/scripts/s3.bash index f09b202..ee038b9 100644 --- a/scripts/s3.bash +++ b/scripts/s3.bash @@ -41,19 +41,33 @@ function rclone_put { local source=$1 local destination=$2 - rclone copy "$source" :s3:"$destination" \ - --s3-endpoint=https://$R2_HOST \ - --s3-provider=Cloudflare \ - --s3-access-key-id="$VIEWVIEW_S3_ACCESS_KEY" \ - --s3-secret-access-key="$VIEWVIEW_S3_SECRET" \ - --s3-no-check-bucket \ - --progress \ - --transfers=32 \ - --checkers=32 \ - --multi-thread-streams=16 \ - --multi-thread-cutoff=0 \ - --s3-chunk-size=64M \ + local args=( + "--s3-endpoint=https://$R2_HOST" + --s3-provider=Cloudflare + "--s3-access-key-id=$VIEWVIEW_S3_ACCESS_KEY" + "--s3-secret-access-key=$VIEWVIEW_S3_SECRET" + --s3-no-check-bucket + --progress + --transfers=32 + --checkers=32 + --multi-thread-streams=16 + --multi-thread-cutoff=0 + --s3-chunk-size=64M --s3-upload-concurrency=16 + ) + + if [[ $source == "-" ]]; then + rclone rcat :s3:"$destination" "${args[@]}" + else + rclone copy "$source" :s3:"$destination" "${args[@]}" + fi +} + +function rclone_put_zip_stream { + local source=$1 + local destination=$2 + + cat "$source" | gzip | rclone_put - "$destination" } function get_srtm_folder { From 33f67463168560289ef1cb14d81fd6db094c5cdb Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Mon, 13 Apr 2026 23:27:12 +0100 Subject: [PATCH 11/15] website: Reconstruct viewsheds in worker This prevents the main thread blocking when doing a union of all the individual polar segments. --- website/package.json | 1 + website/pnpm-lock.yaml | 49 ++++++++++++ website/src/HeatmapLayer.ts | 6 +- website/src/{Worker.ts => TileWorker.ts} | 6 +- website/src/ViewshedWorker.ts | 77 +++++++++++++++++++ website/src/lib/Viewshed.ts | 36 ++++++--- website/src/lib/renderViewsheds.ts | 98 +++++++++--------------- 7 files changed, 193 insertions(+), 80 deletions(-) rename website/src/{Worker.ts => TileWorker.ts} (95%) create mode 100644 website/src/ViewshedWorker.ts diff --git a/website/package.json b/website/package.json index 34b0a77..c7c67b7 100644 --- a/website/package.json +++ b/website/package.json @@ -15,6 +15,7 @@ "dependencies": { "@lucide/svelte": "^0.554.0", "@mapbox/search-js-web": "^1.5.1", + "@turf/union": "^7.3.4", "accessible-nprogress": "^2.1.2", "geotiff": "^2.1.4-beta.1", "maplibre-gl": "^5.11.0", diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 6265780..29e02b6 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@mapbox/search-js-web': specifier: ^1.5.1 version: 1.5.1(mapbox-gl@3.18.0) + '@turf/union': + specifier: ^7.3.4 + version: 7.3.4 accessible-nprogress: specifier: ^2.1.2 version: 2.1.2 @@ -493,6 +496,15 @@ packages: '@swc/helpers@0.5.21': resolution: {integrity: sha512-jI/VAmtdjB/RnI8GTnokyX7Ug8c+g+ffD6QRLa6XQewtnGyukKkKSk3wLTM3b5cjt1jNh9x0jfVlagdN2gDKQg==} + '@turf/helpers@7.3.4': + resolution: {integrity: sha512-U/S5qyqgx3WTvg4twaH0WxF3EixoTCfDsmk98g1E3/5e2YKp7JKYZdz0vivsS5/UZLJeZDEElOSFH4pUgp+l7g==} + + '@turf/meta@7.3.4': + resolution: {integrity: sha512-tlmw9/Hs1p2n0uoHVm1w3ugw1I6L8jv9YZrcdQa4SH5FX5UY0ATrKeIvfA55FlL//PGuYppJp+eyg/0eb4goqw==} + + '@turf/union@7.3.4': + resolution: {integrity: sha512-JJYyPMmGcrTa9sPv2ief2QU9Hb//cEAU1zgKu/OfoCMa9a8Imp5QVm9UTAkhGlc+4qm/N/X16iJ+cvVWaxPjkg==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -533,6 +545,9 @@ packages: big.js@5.2.2: resolution: {integrity: sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} @@ -734,6 +749,9 @@ packages: pmtiles@4.3.0: resolution: {integrity: sha512-wnzQeSiYT/MyO63o7AVxwt7+uKqU0QUy2lHrivM7GvecNy0m1A4voVyGey7bujnEW5Hn+ZzLdvHPoFaqrOzbPA==} + polyclip-ts@0.16.8: + resolution: {integrity: sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ==} + postcss@8.5.9: resolution: {integrity: sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==} engines: {node: ^10 || ^12 || >=14} @@ -905,6 +923,9 @@ packages: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} + splaytree-ts@1.0.2: + resolution: {integrity: sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA==} + splaytree@0.1.4: resolution: {integrity: sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==} @@ -1435,6 +1456,25 @@ snapshots: dependencies: tslib: 2.8.1 + '@turf/helpers@7.3.4': + dependencies: + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/meta@7.3.4': + dependencies: + '@turf/helpers': 7.3.4 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/union@7.3.4': + dependencies: + '@turf/helpers': 7.3.4 + '@turf/meta': 7.3.4 + '@types/geojson': 7946.0.16 + polyclip-ts: 0.16.8 + tslib: 2.8.1 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -1466,6 +1506,8 @@ snapshots: big.js@5.2.2: {} + bignumber.js@9.3.1: {} + braces@3.0.3: dependencies: fill-range: 7.1.1 @@ -1682,6 +1724,11 @@ snapshots: dependencies: fflate: 0.8.2 + polyclip-ts@0.16.8: + dependencies: + bignumber.js: 9.3.1 + splaytree-ts: 1.0.2 + postcss@8.5.9: dependencies: nanoid: 3.3.11 @@ -1829,6 +1876,8 @@ snapshots: source-map-js@1.2.1: {} + splaytree-ts@1.0.2: {} + splaytree@0.1.4: {} subtag@0.5.0: {} diff --git a/website/src/HeatmapLayer.ts b/website/src/HeatmapLayer.ts index 47ee971..65de4d6 100644 --- a/website/src/HeatmapLayer.ts +++ b/website/src/HeatmapLayer.ts @@ -13,8 +13,8 @@ import { tileKey, } from './lib/utils'; import { state as sharedState, state } from './state.svelte.ts'; +import type { TileWorkerEvent } from './TileWorker'; import vertex from './vertex.glsl?raw'; -import type { WorkerEvent } from './Worker'; export type TileGL = { key: string; @@ -69,7 +69,7 @@ function initialise() { makeFillerTile(); } -function onWorkerMessage(event: MessageEvent) { +function onWorkerMessage(event: MessageEvent) { if (heatmapState === undefined) { return; } @@ -148,7 +148,7 @@ export const HeatmapLayer: CustomLayerInterface = { vertexBuffer, uniforms, tileCache: new Map(), - worker: new Worker(new URL('./Worker.js', import.meta.url)), + worker: new Worker(new URL('./TileWorker.js', import.meta.url)), lastGC: Date.now(), }; diff --git a/website/src/Worker.ts b/website/src/TileWorker.ts similarity index 95% rename from website/src/Worker.ts rename to website/src/TileWorker.ts index 5bb55ea..4cdd1cc 100644 --- a/website/src/Worker.ts +++ b/website/src/TileWorker.ts @@ -8,7 +8,7 @@ import { tileToLatLonBounds, } from './lib/utils'; -export type WorkerEvent = +export type TileWorkerEvent = | { type: 'init'; source: string } | ({ type: 'tile' } & Omit & { data: Uint8Array }) | { type: 'getTile'; z: number; x: number; y: number }; @@ -19,7 +19,7 @@ let isProductionMapServer: boolean; const loading = new Map(); -self.onmessage = async (event: MessageEvent) => { +self.onmessage = async (event: MessageEvent) => { if (event.data.type === 'init') { const { source } = event.data; pmtilesSource = source; @@ -100,7 +100,7 @@ self.onmessage = async (event: MessageEvent) => { const bounds = tileToLatLonBounds(z, x, y); - const message: WorkerEvent = { + const message: TileWorkerEvent = { type: 'tile', key, data: new Uint8Array(tvs_surfaces.buffer), diff --git a/website/src/ViewshedWorker.ts b/website/src/ViewshedWorker.ts new file mode 100644 index 0000000..5acb147 --- /dev/null +++ b/website/src/ViewshedWorker.ts @@ -0,0 +1,77 @@ +import type { LngLat } from 'maplibre-gl'; +import { type PolarSegments, Viewshed } from './lib/Viewshed'; + +export type ViewshedWorkerEvent = + | { type: 'getViewshed'; coordinate: LngLat } + | { type: 'setViewshed'; viewshed: Viewshed } + | { type: 'updateViewshed'; viewshed: Viewshed }; + +self.onmessage = async (event: MessageEvent) => { + if (event.data.type === 'getViewshed') { + const bytes = await getViewshedData(event.data.coordinate); + + const segments = parseViewshedBytes(bytes); + + const scale = 50; // TODO: Get from API? + const id = crypto.randomUUID(); + + const viewshed = new Viewshed(id, event.data.coordinate, scale, segments); + const messageDirty = { + type: 'setViewshed', + viewshed, + } as ViewshedWorkerEvent; + self.postMessage(messageDirty); + + viewshed.unionGeoJSON(); + const messageClean = { + type: 'updateViewshed', + viewshed, + } as ViewshedWorkerEvent; + self.postMessage(messageClean); + } +}; + +async function getViewshedData(lngLat: LngLat) { + let apiBase = 'https://api.alltheviews.world'; + if (import.meta.env.DEV) { + apiBase = 'http://localhost:3333'; + } + + const start = performance.now(); + const response = await fetch( + `${apiBase}/viewshed/${lngLat.lng},${lngLat.lat}`, + ); + const end = performance.now(); + if (import.meta.env.DEV) { + console.log(`Viewshed fetched in ms`, end - start); + } + + return await response.bytes(); +} + +function parseViewshedBytes(data: Uint8Array): PolarSegments[] { + const buffer = data.buffer; + const view = new DataView(buffer); + let offset = 0; + const out: PolarSegments[] = []; + + while (offset < buffer.byteLength) { + const angleID = view.getUint16(offset, false); + offset += 2; + + const segmentsLength = view.getUint16(offset, false); + offset += 2; + + const pairs = []; + const numElements = segmentsLength / 2; + + for (let j = 0; j < numElements; j++) { + pairs.push(view.getUint16(offset, false)); + offset += 2; + } + + out.push({ angleID, pairs: pairs }); + } + + return out; +} diff --git a/website/src/lib/Viewshed.ts b/website/src/lib/Viewshed.ts index 3b771a2..5a01937 100644 --- a/website/src/lib/Viewshed.ts +++ b/website/src/lib/Viewshed.ts @@ -1,4 +1,11 @@ -import type { Feature, FeatureCollection, Polygon } from 'geojson'; +import union from '@turf/union'; +import type { + Feature, + FeatureCollection, + GeoJsonProperties, + MultiPolygon, + Polygon, +} from 'geojson'; import type { LngLat } from 'maplibre-gl'; import proj4 from 'proj4'; import { ANGLE_SHIFT, aeqdProjectionString, rotate, toRadians } from './utils'; @@ -9,7 +16,9 @@ export const DEFAULT_OPACITY = 0.5; export class Viewshed { id: string; centre: LngLat; - polar_segments: PolarSegments[]; + geoJSON: + | FeatureCollection + | Feature; scale: number; colour = '#00ff00'; isVisible = true; @@ -24,7 +33,7 @@ export class Viewshed { this.id = id; this.centre = centre; this.scale = scale; - this.polar_segments = polar_segments; + this.geoJSON = this.generateGeoJSON(polar_segments); } getViewshedLayerID() { @@ -34,9 +43,9 @@ export class Viewshed { return `viewshed-source-${this.id}`; } - geoJSON() { + generateGeoJSON(polar_segments: PolarSegments[]) { const features = []; - for (const polar_segment of this.polar_segments) { + for (const polar_segment of polar_segments) { for (let i = 0; i < polar_segment.pairs.length; i += 2) { const start = polar_segment.pairs[i] * this.scale; const end = start + polar_segment.pairs[i + 1] * this.scale; @@ -66,23 +75,28 @@ export class Viewshed { } } - const collection: FeatureCollection = { + return { type: 'FeatureCollection', features: features, - }; + } as FeatureCollection; + } - return collection; + unionGeoJSON() { + if (this.geoJSON === undefined) return; + const unioned = union(this.geoJSON as FeatureCollection); + if (!unioned) return; + this.geoJSON = unioned; } polarDistanceToPair(angleID: number, distance: number) { // TODO: Don't assume that the kernel is computing 360 angles. - const angle = angleID + ANGLE_SHIFT; + const angle = angleID - 90; const θ = toRadians(angle); const dx = distance * Math.cos(θ); const dy = distance * Math.sin(θ); - const rotatedClockwiseAEQD = rotate(dx, dy, -0.5); - const rotatedAntiAEQD = rotate(dx, dy, +0.5); + const rotatedClockwiseAEQD = rotate(dx, dy, -0.6); + const rotatedAntiAEQD = rotate(dx, dy, +0.6); const aeqd = aeqdProjectionString(this.centre.lng, this.centre.lat); diff --git a/website/src/lib/renderViewsheds.ts b/website/src/lib/renderViewsheds.ts index 3fd3413..4c4b484 100644 --- a/website/src/lib/renderViewsheds.ts +++ b/website/src/lib/renderViewsheds.ts @@ -1,8 +1,11 @@ -import type { LngLat } from 'maplibre-gl'; import { state } from '../state.svelte.ts'; -import { DEFAULT_OPACITY, type PolarSegments, Viewshed } from './Viewshed.ts'; +import type { ViewshedWorkerEvent } from '../ViewshedWorker.ts'; +import { DEFAULT_OPACITY, Viewshed } from './Viewshed.ts'; export function setupViewsheds() { + const worker = new Worker(new URL('../ViewshedWorker.js', import.meta.url)); + worker.onmessage = renderNewViewshed; + renderAllViewsheds(); state.map?.on('click', async (event) => { @@ -11,10 +14,38 @@ export function setupViewsheds() { } removeUnlockedViewsheds(); - renderNewViewshed(event.lngLat); + + worker.postMessage({ + type: 'getViewshed', + coordinate: event.lngLat, + } as ViewshedWorkerEvent); }); } +export function renderNewViewshed(event: MessageEvent) { + const isViewshedData = + event.data.type === 'setViewshed' || event.data.type === 'updateViewshed'; + if (!isViewshedData) return; + + const viewshed = event.data.viewshed; + Object.setPrototypeOf(viewshed, Viewshed.prototype); // Rehydrate the serialised data. + + if (event.data.type === 'setViewshed') { + state.viewsheds.push(viewshed); + renderAllViewsheds(); + } + + if (event.data.type === 'updateViewshed') { + updateExistingViewshedData(viewshed); + } +} + +function updateExistingViewshedData(viewshed: Viewshed) { + const source = state.map?.getSource(viewshed.getViewshedSourceID()); + // @ts-expect-error: For some reason TS thinks this is for a tile layer. + source?.setData(viewshed.geoJSON); +} + export function renderAllViewsheds() { for (const viewshed of state.viewsheds) { const layer = state.map?.getLayer(viewshed.getViewshedLayerID()); @@ -76,26 +107,12 @@ export function removeViewshed(viewshed: Viewshed) { state.map?.removeSource(viewshed.getViewshedSourceID()); } -export async function renderNewViewshed(lngLat: LngLat) { - const bytes = await getViewshedData(lngLat); - - const segments = parseViewshedBytes(bytes); - - const scale = 50; // TODO: Get from API? - const id = crypto.randomUUID(); - - const viewshed = new Viewshed(id, lngLat, scale, segments); - state.viewsheds.push(viewshed); - - renderAllViewsheds(); -} - function addViewshed(viewshed: Viewshed) { const sourceID = viewshed.getViewshedSourceID(); state.map?.addSource(sourceID, { type: 'geojson', - data: viewshed.geoJSON(), + data: viewshed.geoJSON, }); state.map?.addLayer({ @@ -109,48 +126,3 @@ function addViewshed(viewshed: Viewshed) { }, }); } - -async function getViewshedData(lngLat: LngLat) { - let apiBase = 'https://api.alltheviews.world'; - if (import.meta.env.DEV) { - apiBase = 'http://localhost:3333'; - } - - const start = performance.now(); - const response = await fetch( - `${apiBase}/viewshed/${lngLat.lng},${lngLat.lat}`, - ); - const end = performance.now(); - if (import.meta.env.DEV) { - console.log(`Viewshed fetched in ms`, end - start); - } - - return await response.bytes(); -} - -function parseViewshedBytes(data: Uint8Array): PolarSegments[] { - const buffer = data.buffer; - const view = new DataView(buffer); - let offset = 0; - const out: PolarSegments[] = []; - - while (offset < buffer.byteLength) { - const angleID = view.getUint16(offset, false); - offset += 2; - - const segmentsLength = view.getUint16(offset, false); - offset += 2; - - const pairs = []; - const numElements = segmentsLength / 2; - - for (let j = 0; j < numElements; j++) { - pairs.push(view.getUint16(offset, false)); - offset += 2; - } - - out.push({ angleID, pairs: pairs }); - } - - return out; -} From 577d57d3ff4b3561e7b4d3626ebc5900d04578e9 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Sun, 19 Apr 2026 22:55:10 +0100 Subject: [PATCH 12/15] feat: Parallel viewshed DB queries across shards Also refactoring to put code into a `shared` crate. --- Cargo.lock | 37 ++++- Cargo.toml | 9 + crates/server/Cargo.toml | 8 +- .../fixtures/{ => shards}/sample_16x16.db | Bin crates/server/src/app.rs | 157 +++++++----------- crates/server/src/config.rs | 2 +- crates/server/src/get_viewshed.rs | 99 +++++++++++ crates/server/src/main.rs | 6 +- crates/server/src/test.rs | 7 +- crates/server/src/utils.rs | 31 ---- crates/shared/Cargo.toml | 15 ++ crates/{tasks => shared}/src/lib.rs | 3 + crates/{server => shared}/src/metadata.rs | 2 +- crates/{tasks => shared}/src/projector.rs | 4 +- crates/shared/src/utils.rs | 35 ++++ crates/tasks/Cargo.toml | 16 +- .../tasks/src/atlas/longest_lines/overview.rs | 14 +- crates/tasks/src/atlas/run.rs | 16 +- crates/tasks/src/config.rs | 19 +-- crates/tasks/src/main.rs | 20 +-- crates/tasks/src/max_subtile.rs | 5 +- crates/tasks/src/packer.rs | 23 ++- crates/tasks/src/tile.rs | 14 +- 23 files changed, 332 insertions(+), 210 deletions(-) rename crates/server/fixtures/{ => shards}/sample_16x16.db (100%) create mode 100644 crates/server/src/get_viewshed.rs delete mode 100644 crates/server/src/utils.rs create mode 100644 crates/shared/Cargo.toml rename crates/{tasks => shared}/src/lib.rs (83%) rename crates/{server => shared}/src/metadata.rs (95%) rename crates/{tasks => shared}/src/projector.rs (98%) create mode 100644 crates/shared/src/utils.rs diff --git a/Cargo.lock b/Cargo.lock index 66bc266..f351d4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -917,6 +917,27 @@ dependencies = [ "typenum", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.9.2" @@ -3221,8 +3242,8 @@ dependencies = [ "futures-util", "serde", "serde_json", + "shared", "sqlx", - "tasks", "tokio", "tower", "tower-http", @@ -3261,6 +3282,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shared" +version = "0.1.0" +dependencies = [ + "color-eyre", + "geo", + "proj4rs", + "rstar", + "serde", + "serde_json", +] + [[package]] name = "shlex" version = "1.3.0" @@ -3656,6 +3689,7 @@ dependencies = [ "bytemuck", "clap", "color-eyre", + "csv", "futures", "gdal", "geo", @@ -3665,6 +3699,7 @@ dependencies = [ "rstar", "serde", "serde_json", + "shared", "sqlx", "srtm_reader", "strip-ansi-escapes", diff --git a/Cargo.toml b/Cargo.toml index 8c95c44..205095a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,10 +3,19 @@ resolver = "2" members = [ "crates/server", "crates/tasks", + "crates/shared", ] [workspace.dependencies] +color-eyre = "0.6.5" clap = { version = "4.5.47", features = ["derive"] } +geo = "0.31.0" +proj4rs = { version = "0.1.9", features = ["aeqd"] } +rstar = "0.12.2" +shared = { path = "crates/shared", version = "0.1.0" } +serde = "1.0.228" +serde_json = "1.0.149" +sqlx = { version = "0.8.6", features = [ "runtime-tokio" ] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "ansi"] } diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 591ea86..3cac4b9 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -8,10 +8,10 @@ axum = "0.8.4" bytes = "1.11.1" clap.workspace = true color-eyre = "0.6.5" -serde = "1.0.228" -serde_json = "1.0.149" -sqlx = "0.8.6" -tasks = { path = "../tasks", version = "0.1.0" } +serde.workspace = true +serde_json.workspace = true +sqlx.workspace = true +shared.workspace = true tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", default-features = false, features = ["env-filter", "ansi"] } tokio = { version = "1.47.1", features = ["rt-multi-thread"] } diff --git a/crates/server/fixtures/sample_16x16.db b/crates/server/fixtures/shards/sample_16x16.db similarity index 100% rename from crates/server/fixtures/sample_16x16.db rename to crates/server/fixtures/shards/sample_16x16.db diff --git a/crates/server/src/app.rs b/crates/server/src/app.rs index a74e3fa..d1dae7b 100644 --- a/crates/server/src/app.rs +++ b/crates/server/src/app.rs @@ -1,32 +1,59 @@ //! The main app code. -use color_eyre::Result; +use std::sync::Arc; -use axum::{ - Router, - extract::{Path, State}, - http::StatusCode, - response::IntoResponse, - routing, -}; +use color_eyre::{Result, eyre::ContextCompat as _}; + +use axum::{Router, routing}; -use futures_util::StreamExt as _; use sqlx::ConnectOptions as _; -use sqlx::Row as _; + +/// Shorthand for a single connection to a single shard. Even though it's a `Pool` it's limited to +/// a concurrency of 1. +pub type Shard = Arc>; /// All state needed for the app's lifetime. #[derive(Clone)] -struct AppState { - /// Pool of connections to the DB. - pool: sqlx::Pool, +pub struct AppState { + /// A collection of single-concurrency pools, one for each database shard. + pub shards: Vec, /// Metadata about the underlying DEM that generated the viewsheds. - metadata: crate::metadata::MetaData, + pub metadata: shared::metadata::MetaData, +} + +/// Entrypoint for starting the server. +pub async fn build(config: crate::config::Config) -> Result { + let pools = crate::app::all_databases(config).await?; + crate::app::router(pools).await +} + +/// Setup all the databases. +pub async fn all_databases(config: crate::config::Config) -> Result> { + #[expect( + clippy::redundant_closure_for_method_calls, + reason = "It's too verbose" + )] + let db_paths: Vec<_> = std::fs::read_dir(&config.db_dir)? + .filter_map(|result| result.ok()) + .map(|file| file.path()) + .filter(|path| path.extension().is_some_and(|ext| ext == "db")) + .collect(); + + let mut pools = Vec::new(); + for path in db_paths { + let pool = one_database(&path).await?; + pools.push(Arc::new(pool)); + } + + tracing::info!("Connected to {} databases.", pools.len()); + + Ok(pools) } -/// Setup the DB. -pub async fn db(config: crate::config::Config) -> Result> { +/// Setup a DB. +async fn one_database(path: &std::path::PathBuf) -> Result> { let options = sqlx::sqlite::SqliteConnectOptions::new() - .filename(config.db_path) + .filename(path) .journal_mode(sqlx::sqlite::SqliteJournalMode::Off) .synchronous(sqlx::sqlite::SqliteSynchronous::Off) .disable_statement_logging() @@ -36,19 +63,31 @@ pub async fn db(config: crate::config::Config) -> Result) -> Result { - let metadata = load_metadata(&pool).await?; - let state = AppState { pool, metadata }; +async fn router(shards: Vec) -> Result { + let metadata = load_metadata( + shards + .first() + .context("No DBs available to query metadate from.")?, + ) + .await?; + let state = AppState { shards, metadata }; let router = Router::new() .route("/", routing::get(|| async { "hello" })) - .route("/viewshed/{coordinate}", routing::get(get_viewshed)) + .route( + "/viewshed/{coordinate}", + routing::get(crate::get_viewshed::get_viewshed), + ) .with_state(state) .layer( tower_http::compression::CompressionLayer::new() @@ -78,80 +117,12 @@ pub async fn router(pool: sqlx::Pool) -> Result { } /// Load the metadata for the DEM that generated the viewsheds. -async fn load_metadata(pool: &sqlx::Pool) -> Result { +async fn load_metadata(shard: &sqlx::Pool) -> Result { let (json,): (String,) = sqlx::query_as("SELECT json FROM metadata") - .fetch_one(pool) + .fetch_one(shard) .await?; - let metadata: crate::metadata::MetaData = serde_json::from_str(&json)?; + let metadata: shared::metadata::MetaData = serde_json::from_str(&json)?; tracing::info!("Loaded metadata: {metadata:?}"); Ok(metadata) } - -/// Return the polar segments for an entire viewshed. -async fn get_viewshed( - State(state): State, - Path(coordinate): Path, -) -> impl IntoResponse { - let Ok(lonlat) = tasks::projector::LonLatCoord::parse(&coordinate) else { - return (StatusCode::BAD_REQUEST, "Couldn't parse coordinate").into_response(); - }; - - let Ok(dem_id) = crate::utils::latlon_to_dem_id(&state.metadata, lonlat) else { - return ( - StatusCode::BAD_REQUEST, - "Couldn't get a DEM ID for the provided coordinate", - ) - .into_response(); - }; - - let start = tokio::time::Instant::now(); - let mut rows = - sqlx::query("SELECT angle_id, visible_segments FROM polar_segments WHERE dem_id = ?1") - .bind(dem_id) - .fetch(&state.pool); - - let mut payload = Vec::with_capacity(1024 * 16); - let result: Result<()> = async { - // Streaming the results should be faster. - while let Some(row_result) = rows.next().await { - let row = row_result?; - - let angle_id: u16 = row.try_get(0)?; - let bytes: Vec = row.try_get(1)?; - let bytes_length = u16::try_from(bytes.len())?; - - payload.extend_from_slice(&angle_id.to_be_bytes()); - payload.extend_from_slice(&bytes_length.to_be_bytes()); - payload.extend_from_slice(&bytes); - } - Ok(()) - } - .await; - - if result.is_err() { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - "Couldn't fetch viewshed data from DB", - ) - .into_response(); - } - - if payload.is_empty() { - let msg = format!("No viewshed found. Using DEM ID: {dem_id}"); - return (StatusCode::NOT_FOUND, msg).into_response(); - } - - let body = bytes::Bytes::from(payload); - tracing::debug!( - "Viewshed request for DEM ID {dem_id} found {} bytes in {:?}", - body.len(), - start.elapsed() - ); - ( - StatusCode::OK, - [("content-type", "application/octet-stream")], - body, - ) - .into_response() -} diff --git a/crates/server/src/config.rs b/crates/server/src/config.rs index e0a9679..9f9888c 100644 --- a/crates/server/src/config.rs +++ b/crates/server/src/config.rs @@ -5,5 +5,5 @@ pub struct Config { /// Path to the viewshed database. #[arg(long, value_name = "Database path")] - pub db_path: std::path::PathBuf, + pub db_dir: std::path::PathBuf, } diff --git a/crates/server/src/get_viewshed.rs b/crates/server/src/get_viewshed.rs new file mode 100644 index 0000000..1317a04 --- /dev/null +++ b/crates/server/src/get_viewshed.rs @@ -0,0 +1,99 @@ +//! Get a single viewshed from many database shards. + +use color_eyre::Result; + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, +}; + +use futures_util::StreamExt as _; +use sqlx::Row as _; + +/// Return the polar segments for an entire viewshed. +pub async fn get_viewshed( + State(state): State, + Path(coordinate): Path, +) -> impl IntoResponse { + let Ok(lonlat) = shared::projector::LonLatCoord::parse(&coordinate) else { + return (StatusCode::BAD_REQUEST, "Couldn't parse coordinate").into_response(); + }; + + let Ok(dem_id) = shared::utils::lonlat_to_dem_id(&state.metadata, lonlat) else { + return ( + StatusCode::BAD_REQUEST, + "Couldn't get a DEM ID for the provided coordinate", + ) + .into_response(); + }; + + let start = tokio::time::Instant::now(); + + let mut tasks = query_shards(state.shards, dem_id); + let mut payload = Vec::with_capacity(1024 * 16); + while let Some(task_result) = tasks.next().await { + let fetch_result: Result<()> = async { + let rows = task_result??; + for row in rows { + let angle_id: u16 = row.try_get(0)?; + let bytes: Vec = row.try_get(1)?; + let bytes_length = u16::try_from(bytes.len())?; + + payload.extend_from_slice(&angle_id.to_be_bytes()); + payload.extend_from_slice(&bytes_length.to_be_bytes()); + payload.extend_from_slice(&bytes); + } + Ok(()) + } + .await; + + if let Err(error) = fetch_result { + tracing::error!("{error:?}"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + "Couldn't fetch viewshed data from DB", + ) + .into_response(); + } + } + + if payload.is_empty() { + let msg = format!("No viewshed found. Using DEM ID: {dem_id}"); + return (StatusCode::NOT_FOUND, msg).into_response(); + } + + let body = bytes::Bytes::from(payload); + tracing::debug!( + "Viewshed request for DEM ID {dem_id} found {} bytes in {:?}", + body.len(), + start.elapsed() + ); + ( + StatusCode::OK, + [("content-type", "application/octet-stream")], + body, + ) + .into_response() +} + +/// Query all shards in parallel. +fn query_shards( + shards: Vec, + dem_id: i64, +) -> futures_util::stream::FuturesUnordered< + tokio::task::JoinHandle>>, +> { + let tasks = futures_util::stream::FuturesUnordered::new(); + + for shard in shards { + tasks.push(tokio::spawn(async move { + sqlx::query("SELECT angle_id, visible_segments FROM polar_segments WHERE dem_id = ?") + .bind(dem_id) + .fetch_all(&*shard) + .await + })); + } + + tasks +} diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 117bc2a..f20bbc1 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -11,8 +11,7 @@ use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::Subscriber mod app; mod config; -mod metadata; -mod utils; +mod get_viewshed; #[cfg(test)] mod test; @@ -22,8 +21,7 @@ mod test; async fn main() -> Result<()> { setup_logging()?; let config = crate::config::Config::parse(); - let pool = app::db(config.clone()).await?; - let router = app::router(pool).await?; + let router = app::build(config).await?; let address = "0.0.0.0:3333"; diff --git a/crates/server/src/test.rs b/crates/server/src/test.rs index 247a975..dcf20f5 100644 --- a/crates/server/src/test.rs +++ b/crates/server/src/test.rs @@ -46,11 +46,10 @@ mod tests { async fn app() -> axum::Router { let config = crate::config::Config { - db_path: "./fixtures/sample_16x16.db".into(), + db_dir: "./fixtures/shards".into(), }; - let pool = crate::app::db(config).await.unwrap(); - crate::app::router(pool).await.unwrap() + crate::app::build(config).await.unwrap() } #[tokio::test] @@ -73,7 +72,7 @@ mod tests { let response = app() .await .oneshot( - Request::get("/viewshed/-3.1229856,51.4897910") + Request::get("/viewshed/-3.123,51.4898") .body(Body::empty()) .unwrap(), ) diff --git a/crates/server/src/utils.rs b/crates/server/src/utils.rs deleted file mode 100644 index 81e1f1e..0000000 --- a/crates/server/src/utils.rs +++ /dev/null @@ -1,31 +0,0 @@ -//! Helper code. - -use color_eyre::Result; - -/// Convert a lat/lon to a DEM coordinate. -pub fn latlon_to_dem_id( - metadata: &crate::metadata::MetaData, - latlon: tasks::projector::LonLatCoord, -) -> Result { - let width_f64 = f64::from(metadata.width - 1); - let scale = f64::from(metadata.scale); - let coord_metric = tasks::projector::Convert { - base: metadata.centre, - } - .to_meters(latlon)?; - let offset = (width_f64 * scale) / 2.0f64; - #[expect( - clippy::as_conversions, - clippy::cast_possible_truncation, - clippy::cast_sign_loss, - reason = "The coordinates should always fit in `u32`" - )] - let (x, y) = { - ( - ((coord_metric.x + offset) / scale) as u32, - ((-coord_metric.y + offset) / scale) as u32, - ) - }; - let dem_id = (y * metadata.width) + x; - Ok(dem_id) -} diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml new file mode 100644 index 0000000..408ce69 --- /dev/null +++ b/crates/shared/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "shared" +version = "0.1.0" +edition = "2024" + +[dependencies] +color-eyre.workspace = true +geo.workspace = true +proj4rs.workspace = true +rstar.workspace = true +serde.workspace = true +serde_json.workspace = true + +[lints] +workspace = true diff --git a/crates/tasks/src/lib.rs b/crates/shared/src/lib.rs similarity index 83% rename from crates/tasks/src/lib.rs rename to crates/shared/src/lib.rs index 35df9a7..5daf4c0 100644 --- a/crates/tasks/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -5,4 +5,7 @@ reason = "This lib is mostly for our internal use" )] +pub mod metadata; pub mod projector; +pub mod utils; + diff --git a/crates/server/src/metadata.rs b/crates/shared/src/metadata.rs similarity index 95% rename from crates/server/src/metadata.rs rename to crates/shared/src/metadata.rs index 0127181..3713ef1 100644 --- a/crates/server/src/metadata.rs +++ b/crates/shared/src/metadata.rs @@ -17,5 +17,5 @@ pub struct MetaData { pub reserved_ring_size: usize, /// The lat/lon coordinates for the centre of the 2D DEM grid. Used for accurately converting /// between degree and metric coordinate systems. - pub centre: tasks::projector::LonLatCoord, + pub centre: crate::projector::LonLatCoord, } diff --git a/crates/tasks/src/projector.rs b/crates/shared/src/projector.rs similarity index 98% rename from crates/tasks/src/projector.rs rename to crates/shared/src/projector.rs index 1a9e3fe..b28d354 100644 --- a/crates/tasks/src/projector.rs +++ b/crates/shared/src/projector.rs @@ -13,7 +13,7 @@ impl LonLatCoord { /// Parse coordinates from a string. /// /// # Errors - /// On parsing errors + /// On parsing errors. #[inline] pub fn parse(coordinates: &str) -> Result { let mut parts = coordinates.split(','); @@ -62,7 +62,7 @@ impl rstar::Point for LonLatCoord { /// Convert between different coordinate system. pub struct Convert { - /// The lat/lon base coordinates for the AEQD mercator projected coordinates. + /// The lon/lat base coordinates for the AEQD mercator projected coordinates. pub base: LonLatCoord, } diff --git a/crates/shared/src/utils.rs b/crates/shared/src/utils.rs new file mode 100644 index 0000000..13201f7 --- /dev/null +++ b/crates/shared/src/utils.rs @@ -0,0 +1,35 @@ +//! Helper code. + +use color_eyre::Result; + +#[inline] +/// Convert a lon/lat to a DEM coordinate. +/// +/// # Errors +/// +/// If projection errors. +pub fn lonlat_to_dem_id( + metadata: &crate::metadata::MetaData, + latlon: crate::projector::LonLatCoord, +) -> Result { + let width_f64 = f64::from(metadata.width + 1); + let scale = f64::from(metadata.scale); + let coord_metric = crate::projector::Convert { + base: metadata.centre, + } + .to_meters(latlon)?; + let offset = (width_f64 * scale) / 2.0f64; + #[expect( + clippy::as_conversions, + clippy::cast_possible_truncation, + reason = "The coordinates should always fit in `u64`" + )] + let (x, y) = { + ( + ((coord_metric.x + offset) / scale) as i64, + ((-coord_metric.y + offset) / scale) as i64, + ) + }; + let dem_id = (y * i64::from(metadata.width)) + x; + Ok(dem_id) +} diff --git a/crates/tasks/Cargo.toml b/crates/tasks/Cargo.toml index 731d4e6..9decd5e 100644 --- a/crates/tasks/Cargo.toml +++ b/crates/tasks/Cargo.toml @@ -12,23 +12,25 @@ async-ssh2-tokio = "0.12.0" axum = { version = "0.8", features = ["json", "tokio", "query"] } bytemuck = { version = "1.23.2", features = ["derive"] } clap.workspace = true -color-eyre = "0.6.5" +color-eyre.workspace = true futures = "0.3.31" gdal = { version = "0.18.0", features = ["bindgen"] } -geo = "0.31.0" +geo.workspace = true geojson = { version = "0.24.2", features = ["geo-types"] } h3o = "0.9.4" -proj4rs = { version = "0.1.9", features = ["aeqd"] } -rstar = "0.12.2" -serde = "1.0.219" -serde_json = "1.0.143" +proj4rs.workspace = true +rstar.workspace = true +shared.workspace = true +serde.workspace = true +serde_json.workspace = true srtm_reader = "0.5.1" -sqlx = { version = "0.8", features = [ "runtime-tokio" ] } +sqlx.workspace = true tokio = { version = "1.48.0", features = ["full", "tracing"] } tracing.workspace = true tracing-subscriber.workspace = true tower-http = { version = "0.6.7", features = ["fs", "normalize-path"] } strip-ansi-escapes = "0.2.1" +csv = "1.4.0" [lints] workspace = true diff --git a/crates/tasks/src/atlas/longest_lines/overview.rs b/crates/tasks/src/atlas/longest_lines/overview.rs index e61c8c3..f1c4b1b 100644 --- a/crates/tasks/src/atlas/longest_lines/overview.rs +++ b/crates/tasks/src/atlas/longest_lines/overview.rs @@ -13,7 +13,7 @@ use color_eyre::{Result, eyre::ContextCompat as _}; #[derive(Debug, Clone, Copy)] struct LongestLine { /// The coordinates of the line. - lonlat: crate::projector::LonLatCoord, + lonlat: shared::projector::LonLatCoord, /// The raster coordinates of the line in its containing COG. Used mostly for debugging /// because different COG can contain the same line. coord: geo::Coord, @@ -31,7 +31,7 @@ type StateHash = Arc>; /// Representation of a longest lines COG file. struct Tile { /// The AEQD to lon/lat coordinate converter - projector: crate::projector::Convert, + projector: shared::projector::Convert, /// The raw point data for the tile buffer: gdal::raster::Buffer, /// The width of the tile in points @@ -53,7 +53,7 @@ impl Tile { )] let offset = width as f64 / 2.0f64; let tile = Self { - projector: crate::projector::Convert { base: centre }, + projector: shared::projector::Convert { base: centre }, buffer, width, offset, @@ -65,7 +65,7 @@ impl Tile { } /// Get the lon/lat coordinates representing the centre of the longest lines COG. - fn parse_centre_coords(path: &std::path::Path) -> Result { + fn parse_centre_coords(path: &std::path::Path) -> Result { let filestem = path .file_stem() .context("Couldn't get file stem of longest lines tile")? @@ -85,7 +85,7 @@ impl Tile { .get(1) .context("Latitude not present in path parts")? .parse()?; - let coord = crate::projector::LonLatCoord(geo::coord! {x: lon, y: lat}); + let coord = shared::projector::LonLatCoord(geo::coord! {x: lon, y: lat}); Ok(coord) } @@ -148,7 +148,7 @@ impl Tile { } /// Convert the index of a single point in a tile to its lon/lat coordinate. - fn coord_to_lonlat(&self, point_coord: geo::Coord) -> Result { + fn coord_to_lonlat(&self, point_coord: geo::Coord) -> Result { #[expect( clippy::cast_precision_loss, clippy::as_conversions, @@ -255,7 +255,7 @@ async fn update_state(world: &StateHash, local: HashMap) -> Result<()> { } let mut longest = LongestLine { - lonlat: crate::projector::LonLatCoord(geo::Coord::zero()), + lonlat: shared::projector::LonLatCoord(geo::Coord::zero()), coord: geo::Coord::zero(), packed: crate::atlas::longest_lines::packed::LineOfSight(0.0), }; diff --git a/crates/tasks/src/atlas/run.rs b/crates/tasks/src/atlas/run.rs index fc3f684..94620e6 100644 --- a/crates/tasks/src/atlas/run.rs +++ b/crates/tasks/src/atlas/run.rs @@ -57,7 +57,7 @@ impl Atlas { .next() .context("No width in tile.csv line")? .parse::()?; - let centre = crate::projector::LonLatCoord(geo::Coord { x: lon, y: lat }); + let centre = shared::projector::LonLatCoord(geo::Coord { x: lon, y: lat }); tiles.push(crate::tile::Tile { centre, width }); } @@ -95,7 +95,7 @@ impl Atlas { let mut tile_store = super::db::atlas_worker_store().await?; tracing::debug!("Adding tile jobs to worker..."); - let start_from = crate::projector::LonLatCoord(config.centre.into()); + let start_from = shared::projector::LonLatCoord(config.centre.into()); let amount_of_tiles_to_add = config.amount.unwrap_or_else(|| atlas.tiles.size()); let mut count = 0; for master_tile in atlas @@ -113,21 +113,15 @@ impl Atlas { tile: master_tile.data, }; - // map the priority of the tile width (10,000->900,000) to an integer 1-9 // and then invert the priority so that the smallest go first let priority = -((tile_args.tile.width / 100_000.0f32) as i32); - let ctx = SqlContext::new() - .with_priority(priority); + let ctx = SqlContext::new().with_priority(priority); - let task = Task::builder(tile_args) - .with_ctx(ctx) - .build(); + let task = Task::builder(tile_args).with_ctx(ctx).build(); - tile_store - .push_task(task) - .await?; + tile_store.push_task(task).await?; count += 1; if count >= amount_of_tiles_to_add { diff --git a/crates/tasks/src/config.rs b/crates/tasks/src/config.rs index de28e71..5279190 100644 --- a/crates/tasks/src/config.rs +++ b/crates/tasks/src/config.rs @@ -5,7 +5,7 @@ use color_eyre::Result; /// The marker to indicate that this is a local, non-production run. pub const RUN_ID_LOCAL: &str = "local"; -/// `Config` +/// `Config`. #[derive(clap::Parser, Debug)] #[clap(author, version)] #[command(name = "vv-tasks")] @@ -35,9 +35,9 @@ pub enum Commands { /// `atlas` subcommands. #[derive(clap::Subcommand, Debug)] pub enum AtlasCommands { - /// Create a new machine + /// Create a new machine. NewMachine(NewMachine), - /// Run + /// Run. Run(Atlas), /// Run and manage all the tasks for processing the entire planet. Worker(Worker), @@ -54,7 +54,7 @@ pub enum AtlasCommands { /// `cargo run packer` arguments. #[derive(clap::Parser, Debug, Clone)] pub struct Packer { - /// Just run for one step + /// Just run for one step. #[arg( long, allow_hyphen_values(true), @@ -113,7 +113,7 @@ pub struct StitchAll { #[arg(long, value_name = "Path to master tiles list")] pub master: std::path::PathBuf, - /// Number of CPUS to use, + /// Number of CPUS to use. #[arg(long, value_name = "Number of cpus", default_value_t = number_of_cpus_on_machine())] pub num_cpus: usize, } @@ -134,7 +134,7 @@ pub struct Atlas { #[arg(long, value_name = "Path to master tiles list")] pub master: std::path::PathBuf, - /// The lon/lat coord from which to start processing + /// The lon/lat coord from which to start processing. #[arg( long, allow_hyphen_values(true), @@ -152,7 +152,7 @@ pub struct Atlas { #[arg(long, value_name = "Amount of tiles to process")] pub amount: Option, - /// Maximum width of tile to process + /// Maximum width of tile to process. #[arg(long, value_name = "Max width of tile")] pub max_tile_width: Option, @@ -198,7 +198,7 @@ pub enum Backend { Vulkan, /// Vulkan shader but run on the CPU. VulkanCPU, - /// Optimised cache-efficient CPU kernel + /// Optimised cache-efficient CPU kernel. CPU, } @@ -231,7 +231,7 @@ pub enum ComputeProvider { DigitalOcean, /// Run on Vultr compute. Requires an already authed `vultr-ctl`. Vultr, - /// Run on Google Cloud. Requires an already authed and installed `gcloud` + /// Run on Google Cloud. Requires an already authed and installed `gcloud`. GoogleCloud, } @@ -287,4 +287,3 @@ pub fn number_of_cpus_on_machine() -> usize { .filter(|cpu| *cpu > 0) .unwrap_or(1) } - diff --git a/crates/tasks/src/main.rs b/crates/tasks/src/main.rs index ace012a..7076000 100644 --- a/crates/tasks/src/main.rs +++ b/crates/tasks/src/main.rs @@ -1,4 +1,4 @@ -//! Entrypoint +//! Entrypoint. #![expect( clippy::panic_in_result_fn, reason = "This is just code for short tasks, so panicking is better" @@ -48,18 +48,10 @@ mod packer; mod stitch; mod tile; -#[expect( - clippy::exhaustive_structs, - reason = "This lib is mostly for our internal use" -)] -pub mod projector; - use clap::Parser as _; use color_eyre::Result; use tracing_subscriber::{Layer as _, layer::SubscriberExt as _, util::SubscriberInitExt as _}; -use crate::projector::LonLatCoord; - #[tokio::main] async fn main() -> Result<()> { let broadcaster = apalis_board_api::sse::TracingBroadcaster::create(); @@ -77,10 +69,12 @@ async fn main() -> Result<()> { config::Commands::Packer(packer_config) => { let mut packer = packer::Packer::new(packer_config.clone())?; match packer_config.one { - Some(coordinate) => packer.run_one(LonLatCoord(geo::coord! { - x: coordinate.0, - y: coordinate.1 - }))?, + Some(coordinate) => { + packer.run_one(shared::projector::LonLatCoord(geo::coord! { + x: coordinate.0, + y: coordinate.1 + }))?; + } None => packer.run_all()?, } } diff --git a/crates/tasks/src/max_subtile.rs b/crates/tasks/src/max_subtile.rs index 29a6107..ff64465 100644 --- a/crates/tasks/src/max_subtile.rs +++ b/crates/tasks/src/max_subtile.rs @@ -21,14 +21,15 @@ pub const ARCSEC_PER_DEG: f32 = 3600.0; #[repr(C)] #[derive(Copy, Clone, Debug, PartialEq, bytemuck::Zeroable, bytemuck::Pod)] pub struct MaxSubTile { - /// Longtitude + /// Longtitude. pub lon: f32, - /// Latitude + /// Latitude. pub lat: f32, /// The maximum height within the subtile region. pub max_height: i32, } +/// Break the world down into smaller manageable tiles. pub struct Subtiler { /// Keep track of _all_ the subtiles on the planet. subtiles: Vec, diff --git a/crates/tasks/src/packer.rs b/crates/tasks/src/packer.rs index 11e8ac8..270ac1f 100644 --- a/crates/tasks/src/packer.rs +++ b/crates/tasks/src/packer.rs @@ -11,8 +11,7 @@ use std::{collections::VecDeque, panic}; use color_eyre::{Result, eyre::ContextCompat as _}; use geo::{Area as _, BooleanOps as _, Contains as _, Intersects as _, Within as _}; use rstar::PointDistance as _; - -use crate::projector::LonLatCoord; +use shared::projector::LonLatCoord; /// Where to save the final packed tiles output. const TILES_OUTPUT: &str = "output/tiles.csv"; @@ -38,7 +37,7 @@ const WINDOW_STEP: f64 = WINDOW_RADIUS / 2.0f64; /// A single point of elevation that represents the highest elevation within the resolution range of /// the point. The resolution is defined by another process, `max_subtile.rs`. -type PointRstar = rstar::primitives::GeomWithData; +type PointRstar = rstar::primitives::GeomWithData; /// How we store tiles for fast lookups. pub type TileRstar = rstar::primitives::GeomWithData; @@ -53,7 +52,7 @@ pub struct Packer { /// 3. AEQD anchored to the cnetre of a given tile. This is necessary for things like /// constructing the tile's actual corners. The simple act of adding a distance to a point /// must be done in as local as possible projection. - projector: crate::projector::Convert, + projector: shared::projector::Convert, /// An unchanging canonical reference of all the world's maximum elevation points. canonical: rstar::RTree, /// A stack of points, each of which must at some point be proven to fall within a tile. @@ -76,8 +75,8 @@ impl Packer { stack: VecDeque::new(), // tiles: rstar::RTree::new(), tiles: rstar::RTree::new(), - projector: crate::projector::Convert { - base: crate::projector::LonLatCoord(geo::Coord::zero()), + projector: shared::projector::Convert { + base: shared::projector::LonLatCoord(geo::Coord::zero()), }, }) } @@ -145,13 +144,13 @@ impl Packer { /// Calculate the number of degrees to go Eastward to reach the next window. fn calculate_longtitude_step(latitude: f64) -> f64 { - let degrees_per_meter = 1.0 / crate::projector::Convert::meters_per_degree(latitude); + let degrees_per_meter = 1.0 / shared::projector::Convert::meters_per_degree(latitude); f64::from(degrees_per_meter) * WINDOW_STEP } /// Do a sort of "carriage return" to the next latitude South. fn start_new_latitude(current: LonLatCoord) -> Result> { - let projector = crate::projector::Convert { base: current }; + let projector = shared::projector::Convert { base: current }; let down = projector.to_degrees(geo::coord! { x: 0.0f64, y: -WINDOW_STEP, @@ -180,7 +179,7 @@ impl Packer { /// Build a view onto a subset of the total data. fn build_window(&mut self, centre: LonLatCoord) -> Result<()> { self.stack = VecDeque::new(); - self.projector = crate::projector::Convert { base: centre }; + self.projector = shared::projector::Convert { base: centre }; tracing::debug!("Ordering around coordinate: {centre:?}"); for canonical_point in self.canonical.nearest_neighbor_iter(¢re) { @@ -461,7 +460,7 @@ impl Packer { let mut nearest_tile = *nearest_tile_reference; let old_width = nearest_tile.data.width; - let tile_projecter = crate::projector::Convert { + let tile_projecter = shared::projector::Convert { base: nearest_tile.data.centre, }; @@ -622,7 +621,7 @@ impl Packer { )] let elevation = elevation_i32 as f32; - (2.0 * crate::projector::EARTH_RADIUS * 1000.0) + (2.0 * shared::projector::EARTH_RADIUS * 1000.0) .mul_add(elevation, elevation.powi(2)) .sqrt() * 2.0 @@ -910,7 +909,7 @@ impl Packer { let magic = 1.5; let latitude = tile.data.centre.0.y; let extension = - (crate::projector::Convert::meters_per_degree(latitude) / subtile_resolution) * magic; + (shared::projector::Convert::meters_per_degree(latitude) / subtile_resolution) * magic; let new_width = tile.data.width + extension; self.set_tile_width(tile.data.centre, new_width)?; self.ensure_tile_is_big_enough(tile)?; diff --git a/crates/tasks/src/tile.rs b/crates/tasks/src/tile.rs index 7323a92..b4606ed 100644 --- a/crates/tasks/src/tile.rs +++ b/crates/tasks/src/tile.rs @@ -4,13 +4,13 @@ use color_eyre::{Result, eyre::ContextCompat as _}; use geo::{Area as _, BoundingRect as _, Buffer as _}; use rstar::PointDistance as _; -use crate::projector::LonLatCoord; +use shared::projector::LonLatCoord; /// The tile data itself. #[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)] pub struct Tile { /// The centre of the tile. - pub centre: crate::projector::LonLatCoord, + pub centre: shared::projector::LonLatCoord, /// The width of the tile. Therefore, not the distance from the centre to an edge. Width is /// better than radius because it defines the minimum line of sight distance we are interested /// in. @@ -24,8 +24,8 @@ impl Tile { } /// The centre coordinate reprojected to the given metric projection. - pub fn centre_metric(&self, anchor: crate::projector::LonLatCoord) -> Result { - let projecter = crate::projector::Convert { base: anchor }; + pub fn centre_metric(&self, anchor: shared::projector::LonLatCoord) -> Result { + let projecter = shared::projector::Convert { base: anchor }; projecter.to_meters(self.centre) } @@ -46,7 +46,7 @@ impl Tile { /// Make a polygon representing the tile in metric coordinates. pub fn to_polygon_metric( self, - anchor: crate::projector::LonLatCoord, + anchor: shared::projector::LonLatCoord, ) -> Result { let centre = self.centre_metric(anchor)?; let circle = geo::Point::new(centre.x, centre.y).buffer(self.radius()); @@ -82,7 +82,7 @@ impl Tile { /// Calculate the distance in meters of the tile from the given point. pub fn distance_from(&self, point_lonlat: LonLatCoord) -> Result { - let projector = crate::projector::Convert { base: point_lonlat }; + let projector = shared::projector::Convert { base: point_lonlat }; let point = projector.to_meters(self.centre)?; Ok(self.centre_metric(point_lonlat)?.distance_2(&point).sqrt()) @@ -99,7 +99,7 @@ impl Tile { let center_lat = origin.y().to_radians(); let bearing_rad = bearing.to_radians(); - let rad = meters / f64::from(crate::projector::EARTH_RADIUS * 1000.0); + let rad = meters / f64::from(shared::projector::EARTH_RADIUS * 1000.0); let lat = { center_lat.sin() * rad.cos() + center_lat.cos() * rad.sin() * bearing_rad.cos() } From 5ff733fdb941c932f4e759ce799204732cfc8144 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Tue, 14 Apr 2026 11:37:40 +0100 Subject: [PATCH 13/15] website: config for galiano 1m heatmap --- website/src/ViewshedWorker.ts | 6 +++++- website/src/config.ts | 12 +++++++++--- website/src/lib/Viewshed.ts | 2 +- website/src/lib/utils.ts | 2 +- website/src/state.svelte.ts | 8 +++++--- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/website/src/ViewshedWorker.ts b/website/src/ViewshedWorker.ts index 5acb147..bb3dddf 100644 --- a/website/src/ViewshedWorker.ts +++ b/website/src/ViewshedWorker.ts @@ -12,7 +12,11 @@ self.onmessage = async (event: MessageEvent) => { const segments = parseViewshedBytes(bytes); - const scale = 50; // TODO: Get from API? + let scale = 1; // TODO: Get from API? + if (import.meta.env.DEV) { + scale = 10; + } + const id = crypto.randomUUID(); const viewshed = new Viewshed(id, event.data.coordinate, scale, segments); diff --git a/website/src/config.ts b/website/src/config.ts index 61422da..946acc3 100644 --- a/website/src/config.ts +++ b/website/src/config.ts @@ -15,6 +15,8 @@ type Config = { // The average surface area visibile from a point far out at sea, where it can only see sea. // This is used to fill regions for which there is no elevation data. averageVisibility: number; + defaultContrast: number; + defaultIntensity: number; }; }; @@ -28,6 +30,8 @@ export const worldConfig: Config = { }, heatmap: { averageVisibility: 700000.0, + defaultContrast: 1 - 0.45, + defaultIntensity: 1 - 0.5, }, }; @@ -35,12 +39,14 @@ export const galianoConfig: Config = { project: 'galiano', map: { minZoom: 11, - maxZoom: 16, - startingZoom: 11.0, + maxZoom: 20, + startingZoom: 11.5, startingCentre: new LngLat(-123.445503, 48.934705), }, heatmap: { - averageVisibility: 900000.0, + averageVisibility: 39000000.0, + defaultContrast: 1 - 0.09, + defaultIntensity: 1 - 0.6, }, }; diff --git a/website/src/lib/Viewshed.ts b/website/src/lib/Viewshed.ts index 5a01937..216b24a 100644 --- a/website/src/lib/Viewshed.ts +++ b/website/src/lib/Viewshed.ts @@ -8,7 +8,7 @@ import type { } from 'geojson'; import type { LngLat } from 'maplibre-gl'; import proj4 from 'proj4'; -import { ANGLE_SHIFT, aeqdProjectionString, rotate, toRadians } from './utils'; +import { aeqdProjectionString, rotate, toRadians } from './utils'; export type PolarSegments = { angleID: number; pairs: number[] }; export const DEFAULT_OPACITY = 0.5; diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 22ddd08..8c13ffe 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -11,7 +11,7 @@ export const PMTILES_SERVER = `${MAP_SERVER}/runs/${VERSION}/pmtiles/${WORLD_PMT // This is for busting Cloudflare asset cache. Like for an updated `world.pmtiles`, // longest lines index, etc. -export const CACHE_BUSTER = '?buster=19:26-20/01/2026'; +export const CACHE_BUSTER = '?buster=23:05-19/04/2026'; export const EARTH_RADIUS = 6371_000.0; diff --git a/website/src/state.svelte.ts b/website/src/state.svelte.ts index d5fa4fa..ae1a22d 100644 --- a/website/src/state.svelte.ts +++ b/website/src/state.svelte.ts @@ -4,6 +4,8 @@ import type { LongestLine } from './lib/getLongestLine'; import type { Viewshed } from './lib/Viewshed'; import type { LongestLineH3 } from './lib/worldLines'; +const config = getConfig(); + export type HeatmapConfig = { contrast: number; intensity: number; @@ -26,15 +28,15 @@ export interface AppState { export const state = $state({ map: undefined, - config: getConfig(), + config, worldLongestLines: undefined, longestLine: undefined, longestLineInViewport: undefined, isFirstInteraction: false, bruteForceLoadingLine: false, heatmapConfig: { - contrast: 1 - 0.45, - intensity: 1 - 0.5, + contrast: config.heatmap.defaultContrast, + intensity: config.heatmap.defaultIntensity, }, viewsheds: [], isFlying: false, From 310988a20c6adfefff1c2429659f9dad61ba1383 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Fri, 24 Apr 2026 10:13:33 +0100 Subject: [PATCH 14/15] Galiano script updates for 1m run --- scripts/cloud_init_ubuntu22.bash | 2 +- scripts/galiano.bash | 55 +++++++++++++++++++++++++------- scripts/make_pmtiles.bash | 2 +- scripts/s3.bash | 2 +- 4 files changed, 46 insertions(+), 15 deletions(-) diff --git a/scripts/cloud_init_ubuntu22.bash b/scripts/cloud_init_ubuntu22.bash index 9811b32..bd1d0d3 100644 --- a/scripts/cloud_init_ubuntu22.bash +++ b/scripts/cloud_init_ubuntu22.bash @@ -13,7 +13,7 @@ function cloud_init_ubuntu22 { libvulkan1 mesa-vulkan-drivers vulkan-tools \ build-essential pkg-config clang \ libgdal-dev gdal-bin python3-gdal rsync htop \ - jq rclone tmux sqlite3 parallel bc + jq rclone tmux sqlite3 parallel bc pigz curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y echo 'source ~/.cargo/env' >> ~/.bashrc curl -LsSf https://astral.sh/uv/install.sh | sh diff --git a/scripts/galiano.bash b/scripts/galiano.bash index 45ed12a..0cada24 100644 --- a/scripts/galiano.bash +++ b/scripts/galiano.bash @@ -1,9 +1,26 @@ # CRS: NAD83(CSRS) / UTM zone 10N (already metric and equally spaced pixels) -# Center: 467048.260, 5420616.975 (123d26'59.83"W, 48d56'15.37"N) +# Center: 467048.260, 5420616.975 (-123.44996829267, 48.9376084029813) # Pixels: 87616x78341 (target for factor of 48 87648x87648) # Resolution: 0.25m (25cm) # Extent: 456096.26 5410824.35 478000.26 5430409.6 +# How to compute: +# +# RUSTFLAGS='-Ctarget-cpu=native' cargo run --release --features=ring_data \ +# compute dsm-1.0.tiff \ +# --thread-count 160 \ +# --database-per-thread \ +# --backend cpu \ +# --process total-surfaces,viewsheds \ +# --observer-height 0.0 \ +# --aoi-point -123.57209623620894,49.02110568820024 \ +# --aoi-point -123.29132955324245,48.87887000504236 \ +# --aoi-point -123.34211991205905,48.83884334550575 \ +# --aoi-point -123.61305988176157,48.99841636016359 \ +# --aoi-point -123.57209623620894,49.0211056882002 \ +# --output-dir /mnt/disks/viewshed \ +# --viewsheds-db-path /mnt/disks/viewshed/dbs + # Convert the Galiano DSM to: # * a square # * a sauare with a width of factor 48 @@ -15,10 +32,21 @@ function galiano_prepare { local output=$2 local resolution=$3 + read -r xcentre ycentre <<<"$( + gdalinfo \ + -json "$input" | jq -r '.cornerCoordinates.center | join(" ")' + )" + + source_crs=$(gdalsrsinfo -o wkt "$input") + read -r lon_centre lat_centre _ <<<"$( + echo "$xcentre" "$ycentre" | gdaltransform -s_srs "$source_crs" -t_srs EPSG:4326 + )" + read -ra extent < <(_galiano_extent_padded) gdalwarp \ -overwrite \ + -t_srs "+proj=aeqd +lat_0=$lat_centre +lon_0=$lon_centre +datum=WGS84 +units=m" \ -te "${extent[0]}" "${extent[1]}" "${extent[2]}" "${extent[3]}" \ -tr "$resolution" "$resolution" \ -r bilinear \ @@ -28,21 +56,24 @@ function galiano_prepare { "$input" \ "$output" + pad_to_factor "$output" + } function _galiano_extent_padded { + local original_scale=0.25 + local auxiliary=3 + local square_to_circle=1.414 # Make the circle _outside_ the DEM width=$(gdalinfo -json "$input" | jq '.size[0]') - width_outer=$(echo "$width * 1.414" | bc) # Make the circle _outside_ the DEM - target_pixels=$(next_factor "$width_outer") - extend_meters=$(echo "$target_pixels * 0.25 * 1.5" | bc) - read -r xcentre ycentre <<<"$( - gdalinfo \ - -json "$input" | jq -r '.cornerCoordinates.center | join(" ")' - )" - xmin=$(echo "$xcentre - $extend_meters" | bc) - ymin=$(echo "$ycentre - $extend_meters" | bc) - xmax=$(echo "$xcentre + $extend_meters" | bc) - ymax=$(echo "$ycentre + $extend_meters" | bc) + width_all=$(echo "$width * $square_to_circle * $auxiliary" | bc) + extend_meters=$( + echo "($width_all * $original_scale) / 2" | bc + ) + + xmin=-$extend_meters + ymin=-$extend_meters + xmax=$extend_meters + ymax=$extend_meters echo "$xmin $ymin $xmax $ymax" } diff --git a/scripts/make_pmtiles.bash b/scripts/make_pmtiles.bash index 4834d05..147f600 100644 --- a/scripts/make_pmtiles.bash +++ b/scripts/make_pmtiles.bash @@ -38,7 +38,7 @@ function make_pmtiles { # Create the global `.pmtile` UV_PYTHON_DOWNLOADS=automatic \ uv run --python 3.12 \ - scripts/to_pmtiles.py "$world_vrt" "$output" \ + "$PROJECT_ROOT/scripts/to_pmtiles.py" "$world_vrt" "$output" \ --min_zoom 0 \ --max_zoom "$max_zoom" diff --git a/scripts/s3.bash b/scripts/s3.bash index ee038b9..fb9dffc 100644 --- a/scripts/s3.bash +++ b/scripts/s3.bash @@ -67,7 +67,7 @@ function rclone_put_zip_stream { local source=$1 local destination=$2 - cat "$source" | gzip | rclone_put - "$destination" + cat "$source" | pigz | rclone_put - "$destination" } function get_srtm_folder { From 83cbe071a50e8f4cc304649a0c4b38e5fcb85ac6 Mon Sep 17 00:00:00 2001 From: Thomas Buckley-Houston Date: Mon, 30 Mar 2026 22:20:10 +0100 Subject: [PATCH 15/15] Google Earth links --- website/src/lib/getLongestLine.ts | 29 +++++++++++++++++++++++++++ website/src/lib/renderLongestLine.ts | 27 ++++++++++++++----------- website/src/lib/utils.ts | 8 ++++++++ website/src/modals/CurrentLine.svelte | 22 +++++++++++++++++++- 4 files changed, 73 insertions(+), 13 deletions(-) diff --git a/website/src/lib/getLongestLine.ts b/website/src/lib/getLongestLine.ts index afa90bb..7fb0983 100644 --- a/website/src/lib/getLongestLine.ts +++ b/website/src/lib/getLongestLine.ts @@ -8,6 +8,7 @@ import { CDN_BUCKET, clamp, endLoadingSpinner, + getElevationAtCoord, Log, startLoadingSpinner, VERSION, @@ -22,6 +23,7 @@ export type LongestLine = { angle: number; from: LngLat; to: LngLat; + googleEarth: string; }; type IndexedTile = { @@ -123,6 +125,33 @@ async function getLongestLineCandidate(cog: GeoTIFFImage, coordinate: LngLat) { return { distance, angle } as LongestLine; } +export async function makeGoogleEarthLink(line: LongestLine) { + const base = 'https://earth.google.com/web/@'; + const latlon = `${line.from.lat},${line.from.lng}`; + + // Add a bit because sometimes we're placed under the terrain. + const extraAltitude = 10; + + const altitude = (await getElevationAtCoord(line.from)) + extraAltitude; + + // We need a little bit of distance so that the `heading` has something to point at. + const distance = 1; + + // Google Earth measures heading from North. TODO: we should too. + const heading = 90 - line.angle; + + // How wide the camera view is? + const fieldOfView = 15; + + // Whether camera is pointing up or down. 90 is just flat looking at the horizon. + const tilt = 90; + + // Looks like "CgRCAggBQgIIAEoNCP___________wEQAA", I think it's just user-specific session stuff? + const data = ``; + + return `${base}${latlon},${altitude}a,${distance}d,${fieldOfView}y,${heading}h,${tilt}t,0r/data=${data}`; +} + async function getPointFromRaster(cog: GeoTIFFImage, x: number, y: number) { // We need to find the longest line in a 5x5 grid to get around precision loss. const around = 2; diff --git a/website/src/lib/renderLongestLine.ts b/website/src/lib/renderLongestLine.ts index 4200f71..a299f5f 100644 --- a/website/src/lib/renderLongestLine.ts +++ b/website/src/lib/renderLongestLine.ts @@ -1,8 +1,10 @@ import { type GeoJSONFeature, type GeoJSONSource, LngLat } from 'maplibre-gl'; import proj4 from 'proj4'; import { navigate } from 'svelte5-router'; + import { state } from '../state.svelte.ts'; -import { getLongestLine } from './getLongestLine.ts'; +import { getLongestLine, makeGoogleEarthLink } from './getLongestLine.ts'; + import { ANGLE_SHIFT, aeqdProjectionString, @@ -58,30 +60,31 @@ function extractCoordFromURL(coordFromURL: string) { } export async function render(lngLat: LngLat) { - const longest_line = await getLongestLine(lngLat); - if (longest_line === undefined) { + const longestLine = await getLongestLine(lngLat); + if (longestLine === undefined) { return; } - state.longestLine = longest_line; + state.longestLine = longestLine; if (import.meta.env.DEV) { - console.log(longest_line); + console.log(longestLine); } - longest_line.angle = longest_line.angle + ANGLE_SHIFT; + longestLine.angle = longestLine.angle + ANGLE_SHIFT; - const θ = toRadians(longest_line.angle); - const dx = longest_line.distance * Math.cos(θ); - const dy = longest_line.distance * Math.sin(θ); + const θ = toRadians(longestLine.angle); + const dx = longestLine.distance * Math.cos(θ); + const dy = longestLine.distance * Math.sin(θ); const rotatedClockwiseAEQD = rotate(dx, dy, -0.5); const rotatedAntiAEQD = rotate(dx, dy, +0.5); const aeqd = aeqdProjectionString(lngLat.lng, lngLat.lat); const unrotated = proj4(aeqd, proj4.WGS84, [dx, dy]); - longest_line.from = lngLat; - longest_line.to = new LngLat(unrotated[0], unrotated[1]); - state.longestLine = longest_line; + longestLine.from = lngLat; + longestLine.to = new LngLat(unrotated[0], unrotated[1]); + longestLine.googleEarth = await makeGoogleEarthLink(longestLine); + state.longestLine = longestLine; const rotatedClockwiseLonLat = proj4(aeqd, proj4.WGS84, rotatedClockwiseAEQD); const rotatedAntiLonLat = proj4( diff --git a/website/src/lib/utils.ts b/website/src/lib/utils.ts index 8c13ffe..0fbdee6 100644 --- a/website/src/lib/utils.ts +++ b/website/src/lib/utils.ts @@ -194,3 +194,11 @@ export function setVectorVisibility(state: AppState, isVisible: boolean) { ); } } + +export async function getElevationAtCoord(coord: LngLat) { + const base = 'https://api.elevationapi.com/api/Elevation'; + const url = `${base}?lat=${coord.lat}&lon=${coord.lng}&dataSet=NASADEM`; + const result = await fetch(url); + const payload = await result.json(); + return payload.geoPoints[0].elevation; +} diff --git a/website/src/modals/CurrentLine.svelte b/website/src/modals/CurrentLine.svelte index 5964a72..a3eac1b 100644 --- a/website/src/modals/CurrentLine.svelte +++ b/website/src/modals/CurrentLine.svelte @@ -1,5 +1,5 @@