From a2f1a55ed09428c95eb490aba904b5f751f508e6 Mon Sep 17 00:00:00 2001 From: n30nex Date: Sat, 12 Sep 2026 16:02:05 -0400 Subject: [PATCH] Add shared visual controls and native prebuilt worldwide Docker releases --- .env.example | 9 +- .github/workflows/ci.yml | 196 +++++++- .github/workflows/release.yml | 121 +++++ AGENTS.md | 4 +- Dockerfile | 6 +- VERSION | 2 +- backend/cmd/cartolite/main.go | 2 +- backend/internal/config/basemap_test.go | 43 ++ backend/internal/config/config.go | 50 +- .../internal/httpapi/public_config_test.go | 27 ++ backend/internal/httpapi/server.go | 30 +- backend/internal/httpapi/server_test.go | 8 +- compose.build.yml | 11 + compose.carto.yml | 11 + compose.yml | 15 +- scripts/compose-smoke.sh | 72 +++ web/e2e/terrain.spec.ts | 6 +- web/e2e/visual-smoke.spec.ts | 17 +- web/index.html | 9 +- web/package-lock.json | 4 +- web/package.json | 2 +- web/src/basemap.ts | 43 +- web/src/basemapConfig.test.ts | 23 + web/src/buildings.ts | 32 ++ web/src/display.css | 65 +++ web/src/displayControls.ts | 77 ++++ web/src/displayPreferences.test.ts | 34 ++ web/src/displayPreferences.ts | 136 ++++++ web/src/main.ts | 68 ++- web/src/map.ts | 106 ++++- web/src/mapNotice.ts | 22 + web/src/netgraph/main.ts | 4 + web/src/netgraph/renderer.ts | 68 +-- web/src/overlayPalette.ts | 17 + web/src/packetAnimator.ts | 53 ++- web/src/preferences.ts | 18 +- web/src/routeLayer.test.ts | 14 +- web/src/routeLayer.ts | 431 +++++++++--------- web/src/styles.css | 2 +- web/src/terrainProjection.test.ts | 15 +- web/src/terrainProjection.ts | 115 +++-- web/src/trafficVisuals.ts | 7 +- web/src/types.ts | 5 + 43 files changed, 1541 insertions(+), 459 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 backend/internal/config/basemap_test.go create mode 100644 backend/internal/httpapi/public_config_test.go create mode 100644 compose.build.yml create mode 100644 compose.carto.yml create mode 100644 scripts/compose-smoke.sh create mode 100644 web/src/basemapConfig.test.ts create mode 100644 web/src/buildings.ts create mode 100644 web/src/display.css create mode 100644 web/src/displayControls.ts create mode 100644 web/src/displayPreferences.test.ts create mode 100644 web/src/displayPreferences.ts create mode 100644 web/src/mapNotice.ts create mode 100644 web/src/overlayPalette.ts diff --git a/.env.example b/.env.example index a02ba7c..1d8a9f8 100644 --- a/.env.example +++ b/.env.example @@ -1,9 +1,10 @@ -# Copy this file to .env. Keep .env and .secrets private. -CARTOLITE_IMAGE=cartolite-server:local -CARTOLITE_VERSION=0.3.1 +# Copy this file to .env and configure your MQTT feed. No map key or build is needed. +# Keep .env and any optional .secrets files private. +CARTOLITE_IMAGE=ghcr.io/n30nex/cartolite-server:0.5.0 CARTOLITE_BIND_ADDR=127.0.0.1 CARTOLITE_PORT=8080 -CARTO_BASEMAP_API_KEY_FILE=.secrets/carto-basemap-api-key +# Optional CARTO override: see compose.carto.yml and docs/deployment.md. +# CARTO_BASEMAP_API_KEY_FILE=.secrets/carto-basemap-api-key # Your standard MeshCore MQTT feed. MQTT_BROKER_URL=tcp://broker.example.invalid:1883 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 754984c..3394423 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,12 +1,21 @@ name: CI -# Maintainer-dispatched validation for the self-hosted source distribution. on: + pull_request: + push: + branches: [main] workflow_dispatch: permissions: contents: read +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + IMAGE_NAME: ghcr.io/n30nex/cartolite-server + jobs: backend: name: Go test, vet, and race @@ -50,46 +59,66 @@ jobs: - run: node scripts/check-frontend-budget.mjs web/dist container: - name: Container integration, browser, privacy, and scan + name: Container integration, browser, privacy, and scan (${{ matrix.arch }}) needs: [backend, frontend] - runs-on: ubuntu-latest - timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + timeout-minutes: 45 + permissions: + contents: read + packages: write + attestations: write + id-token: write steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 with: node-version: 22 cache: npm cache-dependency-path: web/package-lock.json - working-directory: web + if: matrix.arch == 'amd64' run: | npm ci npm exec playwright install --with-deps chromium - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 - - name: Require and verify CARTO vector authorization - env: - CARTO_BASEMAP_API_KEY: ${{ secrets.CARTO_BASEMAP_API_KEY }} + - name: Read build identity + id: identity run: | - test -n "$CARTO_BASEMAP_API_KEY" - node scripts/check-carto-vector.mjs + version="$(tr -d '\r\n' < VERSION)" + [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] + test "$version" = "$(jq -r .version web/package.json)" + test "$version" = "$(jq -r .version web/package-lock.json)" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" - name: Build hardened image uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . - platforms: linux/amd64 + platforms: linux/${{ matrix.arch }} load: true push: false tags: cartolite-server:ci build-args: | - APP_VERSION=ci + APP_VERSION=${{ steps.identity.outputs.version }} GIT_SHA=${{ github.sha }} - BUILD_TIME=unknown - secrets: | - carto_basemap_api_key=${{ secrets.CARTO_BASEMAP_API_KEY }} + BUILD_TIME=${{ steps.identity.outputs.time }} + cache-from: type=gha,scope=world-${{ matrix.arch }} + cache-to: type=gha,scope=world-${{ matrix.arch }},mode=max - run: | bash scripts/check-image-size.sh cartolite-server:ci test "$(docker image inspect cartolite-server:ci --format '{{.Config.User}}')" = "65532:65532" - test "$(docker image inspect cartolite-server:ci --format '{{.Architecture}}')" = "amd64" + test "$(docker image inspect cartolite-server:ci --format '{{.Architecture}}')" = "${{ matrix.arch }}" + test "$(docker image inspect cartolite-server:ci --format '{{index .Config.Labels "org.opencontainers.image.revision"}}')" = "$GITHUB_SHA" - name: Start synthetic MQTT and server run: | docker network create cartolite-server-ci @@ -115,12 +144,14 @@ jobs: bash scripts/integration-smoke.sh http://127.0.0.1:39476 artifacts/integration cartolite-server-mqtt bash scripts/performance-smoke.sh http://127.0.0.1:39476 cartolite-server-mqtt - name: Worldwide and terrain regressions + if: matrix.arch == 'amd64' working-directory: web env: CARTOLITE_BASE_URL: http://127.0.0.1:39476 PLAYWRIGHT_HTML_OUTPUT_DIR: focused-playwright-report run: npm run test:e2e -- --grep '@terrain|@world' --output focused-test-results - name: Complete desktop and mobile browser suite + if: matrix.arch == 'amd64' working-directory: web env: CARTOLITE_BASE_URL: http://127.0.0.1:39476 @@ -143,13 +174,57 @@ jobs: severity: HIGH,CRITICAL ignore-unfixed: true exit-code: "1" + - name: Build the previous source release for native upgrade verification + run: | + git worktree add --detach "$RUNNER_TEMP/cartolite-upgrade-base" v0.4.1 + previous_sha="$(git -C "$RUNNER_TEMP/cartolite-upgrade-base" rev-parse HEAD)" + docker build --tag cartolite-server:upgrade-base \ + --build-arg APP_VERSION=0.4.1 --build-arg GIT_SHA="$previous_sha" \ + "$RUNNER_TEMP/cartolite-upgrade-base" + - name: Verify Compose installation and upgrade from 0.4.1 + run: bash scripts/compose-smoke.sh cartolite-server:ci "$GITHUB_SHA" cartolite-server:upgrade-base + - name: Log in to GHCR + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Publish the tested platform image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + id: publish + run: | + candidate="${IMAGE_NAME}:sha-${GITHUB_SHA}-${{ matrix.arch }}" + docker tag cartolite-server:ci "$candidate" + docker push "$candidate" + digest="$(docker buildx imagetools inspect "$candidate" | awk '$1 == "Digest:" {print $2; exit}')" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + mkdir -p artifacts/candidate + printf '%s\n' "$digest" > artifacts/candidate/${{ matrix.arch }}.txt + echo "digest=$digest" >> "$GITHUB_OUTPUT" + - name: Attest the tested platform image + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.publish.outputs.digest }} + push-to-registry: true + - name: Record platform digest + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: candidate-${{ matrix.arch }} + path: artifacts/candidate/${{ matrix.arch }}.txt + overwrite: true - name: Preserve browser and integration evidence if: always() uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: - name: cartolite-server-ci-evidence + name: cartolite-server-ci-evidence-${{ matrix.arch }} + overwrite: true path: | artifacts/integration/ + artifacts/compose/ web/test-results/ web/playwright-report/ web/focused-test-results/ @@ -159,10 +234,85 @@ jobs: docker rm -f cartolite cartolite-server-mqtt >/dev/null 2>&1 || true docker network rm cartolite-server-ci >/dev/null 2>&1 || true + publish: + name: Assemble and attest tested platform digests + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [container] + runs-on: ubuntu-latest + permissions: + contents: read + actions: read + packages: write + attestations: write + id-token: write + outputs: + image: ${{ steps.index.outputs.image }} + steps: + - uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Assemble exact tested images without rebuilding + id: index + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name candidate-amd64 --dir candidates + gh run download "$GITHUB_RUN_ID" --repo "$GITHUB_REPOSITORY" --name candidate-arm64 --dir candidates + amd64="$(cat candidates/amd64.txt)" + arm64="$(cat candidates/arm64.txt)" + [[ "$amd64" =~ ^sha256:[0-9a-f]{64}$ && "$arm64" =~ ^sha256:[0-9a-f]{64}$ ]] + gh attestation verify "oci://${IMAGE_NAME}@${amd64}" --repo "$GITHUB_REPOSITORY" + gh attestation verify "oci://${IMAGE_NAME}@${arm64}" --repo "$GITHUB_REPOSITORY" + candidate="${IMAGE_NAME}:sha-${GITHUB_SHA}" + docker buildx imagetools create --tag "$candidate" "${IMAGE_NAME}@${amd64}" "${IMAGE_NAME}@${arm64}" + docker buildx imagetools inspect "$candidate" --raw | jq -e '[.manifests[].platform | .os + "/" + .architecture] | sort == ["linux/amd64","linux/arm64"]' + digest="$(docker buildx imagetools inspect "$candidate" | awk '$1 == "Digest:" {print $2; exit}')" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + echo "digest=$digest" >> "$GITHUB_OUTPUT" + echo "image=${IMAGE_NAME}@${digest}" >> "$GITHUB_OUTPUT" + - uses: actions/attest@a1948c3f048ba23858d222213b7c278aabede763 # v4.1.1 + with: + subject-name: ${{ env.IMAGE_NAME }} + subject-digest: ${{ steps.index.outputs.digest }} + push-to-registry: true + + public-install: + name: Anonymous published-image install (${{ matrix.arch }}) + needs: [publish] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + - arch: arm64 + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - name: Pull anonymously and verify the install recipe + env: + CANDIDATE_IMAGE: ${{ needs.publish.outputs.image }} + run: | + mkdir -p "$RUNNER_TEMP/anonymous-docker" + DOCKER_CONFIG="$RUNNER_TEMP/anonymous-docker" docker pull "$CANDIDATE_IMAGE" + test "$(docker image inspect "$CANDIDATE_IMAGE" --format '{{.Architecture}}')" = "${{ matrix.arch }}" + bash scripts/compose-smoke.sh "$CANDIDATE_IMAGE" "$GITHUB_SHA" + - if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: published-install-${{ matrix.arch }} + path: artifacts/compose/ + overwrite: true + required: name: CI / Required if: always() - needs: [backend, frontend, container] + needs: [backend, frontend, container, publish, public-install] runs-on: ubuntu-latest permissions: contents: read @@ -174,9 +324,15 @@ jobs: BACKEND: ${{ needs.backend.result }} FRONTEND: ${{ needs.frontend.result }} CONTAINER: ${{ needs.container.result }} - run: test "$BACKEND" = success -a "$FRONTEND" = success -a "$CONTAINER" = success + PUBLISH: ${{ needs.publish.result }} + PUBLIC_INSTALL: ${{ needs.public-install.result }} + run: | + test "$BACKEND" = success -a "$FRONTEND" = success -a "$CONTAINER" = success + if [[ "$GITHUB_EVENT_NAME" == push ]]; then + test "$PUBLISH" = success -a "$PUBLIC_INSTALL" = success + fi - name: Attach dispatched validation to the exact commit - if: always() + if: always() && github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GATE_RESULT: ${{ steps.gate.outcome }} @@ -187,6 +343,6 @@ jobs: fi gh api --method POST "repos/$GITHUB_REPOSITORY/statuses/$GITHUB_SHA" \ -f state="$state" -f context='CI / Required' \ - -f description='Maintainer-dispatched backend, frontend and container validation' \ + -f description='Backend, frontend and native platform validation' \ -f target_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ --silent diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5de459d --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,121 @@ +name: Release + +on: + push: + tags: ["v*.*.*"] + +permissions: + contents: write + packages: write + attestations: read + actions: read + +concurrency: + group: release-${{ github.ref_name }} + cancel-in-progress: false + +env: + IMAGE_NAME: ghcr.io/n30nex/cartolite-server + +jobs: + promote: + name: Promote tested digest + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Checkout tag history + uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + with: + fetch-depth: 0 + - name: Verify version, main ancestry, and successful CI + id: release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + [[ "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + sha="$(git rev-list -n 1 "$GITHUB_REF_NAME")" + test "$sha" = "$GITHUB_SHA" + version="${GITHUB_REF_NAME#v}" + test "$version" = "$(tr -d '\r\n' < VERSION)" + git fetch origin main --depth=200 + git merge-base --is-ancestor "$sha" origin/main + + runs="$(gh api --method GET \ + "repos/$GITHUB_REPOSITORY/actions/workflows/ci.yml/runs" \ + -f head_sha="$sha" -f status=completed -f per_page=100)" + jq -e --arg sha "$sha" --arg repo "$GITHUB_REPOSITORY" ' + any(.workflow_runs[]; + .head_sha == $sha and .conclusion == "success" and .event == "push" and + .head_branch == "main" and .head_repository.full_name == $repo) + ' <<<"$runs" >/dev/null + + echo "sha=$sha" >> "$GITHUB_OUTPUT" + echo "version=$version" >> "$GITHUB_OUTPUT" + echo "series=${version%.*}" >> "$GITHUB_OUTPUT" + - name: Set up Buildx + uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0 + - name: Log in to GHCR + uses: docker/login-action@af1e73f918a031802d376d3c8bbc3fe56130a9b0 # v4.4.0 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve and verify tested candidate + id: candidate + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + candidate="${IMAGE_NAME}:sha-${{ steps.release.outputs.sha }}" + digest="$(docker buildx imagetools inspect "$candidate" | awk '$1 == "Digest:" {print $2; exit}')" + [[ "$digest" =~ ^sha256:[0-9a-f]{64}$ ]] + gh attestation verify "oci://${IMAGE_NAME}@${digest}" --repo "$GITHUB_REPOSITORY" + echo "digest=$digest" >> "$GITHUB_OUTPUT" + - name: Build release manifest and checksums + shell: bash + run: | + mkdir -p artifacts/release + cp compose.yml artifacts/release/ + cp compose.carto.yml compose.build.yml artifacts/release/ + cp .env.example artifacts/release/default.env.example + version="${{ steps.release.outputs.version }}" + git archive --format=zip --prefix="CartoLite-Server-v${version}/" "${{ steps.release.outputs.sha }}" > "artifacts/release/CartoLite-Server-v${version}.zip" + git archive --format=tar.gz --prefix="CartoLite-Server-v${version}/" "${{ steps.release.outputs.sha }}" > "artifacts/release/CartoLite-Server-v${version}.tar.gz" + jq -n \ + --arg version "${{ steps.release.outputs.version }}" \ + --arg gitSha "${{ steps.release.outputs.sha }}" \ + --arg image "${IMAGE_NAME}@${{ steps.candidate.outputs.digest }}" \ + --arg candidateTag "sha-${{ steps.release.outputs.sha }}" \ + '{schemaVersion:1,version:$version,gitSha:$gitSha,image:$image,candidateTag:$candidateTag,platforms:["linux/amd64","linux/arm64"]}' \ + > artifacts/release/release-manifest.json + (cd artifacts/release && sha256sum default.env.example compose.yml compose.carto.yml compose.build.yml release-manifest.json CartoLite-Server-v* > SHA256SUMS) + (cd artifacts/release && sha256sum --check SHA256SUMS) + - name: Promote the exact digest without rebuilding + shell: bash + run: | + source="${IMAGE_NAME}@${{ steps.candidate.outputs.digest }}" + docker buildx imagetools create --prefer-index=false \ + --tag "${IMAGE_NAME}:${{ steps.release.outputs.version }}" \ + --tag "${IMAGE_NAME}:${{ steps.release.outputs.series }}" \ + --tag "${IMAGE_NAME}:latest" \ + "$source" + for tag in "${{ steps.release.outputs.version }}" "${{ steps.release.outputs.series }}" latest; do + actual="$(docker buildx imagetools inspect "${IMAGE_NAME}:$tag" | awk '$1 == "Digest:" {print $2; exit}')" + test "$actual" = "${{ steps.candidate.outputs.digest }}" + done + - name: Create GitHub release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "$GITHUB_REF_NAME" \ + artifacts/release/default.env.example \ + artifacts/release/compose.yml \ + artifacts/release/compose.carto.yml \ + artifacts/release/compose.build.yml \ + artifacts/release/CartoLite-Server-v* \ + artifacts/release/release-manifest.json \ + artifacts/release/SHA256SUMS \ + --verify-tag \ + --title "CartoLite Server ${{ steps.release.outputs.version }}" \ + --generate-notes diff --git a/AGENTS.md b/AGENTS.md index fe75db8..266934c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,9 @@ CartoLite Server is a compact worldwide MeshCore map and Netgraph: a Go MQTT/HTT ## Delivery - Preserve the scratch, non-root, read-only, capability-free container. -- Supply browser basemap credentials only through a BuildKit secret. +- Published images must contain no operator or CI map key. The default provider is key-free; optional browser-visible CARTO keys are read from a mounted runtime secret and exposed only in the explicit public map configuration. +- Build/test/release only in GitHub Actions; do not build or run containers on the shared workstation. +- Promote tested amd64/arm64 image digests without rebuilding. The default Compose install must consume a published image. - Keep `REGION_ALLOWLIST` optional and exact when configured. - Run frontend tests/build, Go tests/vet/race, integration/privacy smoke tests, and `git diff --check` before release. - Preserve unrelated work and never force-push shared branches. diff --git a/Dockerfile b/Dockerfile index 7fb54b8..ac86e4c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,10 +5,8 @@ WORKDIR /src/web COPY web/package.json web/package-lock.json ./ RUN --mount=type=cache,target=/root/.npm npm ci COPY web/ ./ -# CARTO issues this key for direct browser tile URLs. The secret mount keeps its -# value out of source, build logs, and image metadata; it remains client-visible. -RUN --mount=type=secret,id=carto_basemap_api_key,required=false \ - VITE_CARTO_BASEMAP_API_KEY="$(cat /run/secrets/carto_basemap_api_key 2>/dev/null || true)" npm run build +# Published images contain no operator or CI browser credentials. +RUN npm run build FROM --platform=$BUILDPLATFORM golang:1.25.13-bookworm AS go-build ARG APP_VERSION=dev diff --git a/VERSION b/VERSION index 267577d..8f0916f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.1 +0.5.0 diff --git a/backend/cmd/cartolite/main.go b/backend/cmd/cartolite/main.go index e8b29a8..8eef119 100644 --- a/backend/cmd/cartolite/main.go +++ b/backend/cmd/cartolite/main.go @@ -71,7 +71,7 @@ func run() error { ClientID: cfg.MQTTClientID, Username: cfg.MQTTUsername, Password: cfg.MQTTPassword, Regions: cfg.Regions, }, log, state.Submit, state.SetFeed) broker.Start(ctx) - api, err := httpapi.New(state, hub, broker.Ready, cfg.Version, cfg.GitSHA) + api, err := httpapi.New(state, hub, broker.Ready, cfg.Version, cfg.GitSHA, httpapi.PublicBasemapConfig{Provider: cfg.MapProvider, CartoBrowserKey: cfg.CartoBrowserKey}) if err != nil { stop() state.Wait() diff --git a/backend/internal/config/basemap_test.go b/backend/internal/config/basemap_test.go new file mode 100644 index 0000000..13fa706 --- /dev/null +++ b/backend/internal/config/basemap_test.go @@ -0,0 +1,43 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRuntimeBasemapConfiguration(t *testing.T) { + t.Setenv("MQTT_ENABLED", "false") + t.Setenv("BASEMAP_PROVIDER", "openfreemap") + t.Setenv("CARTO_BASEMAP_API_KEY_FILE", filepath.Join(t.TempDir(), "absent")) + cfg, err := Load("test", "abc") + if err != nil || cfg.MapProvider != "openfreemap" || cfg.CartoBrowserKey != "" { + t.Fatal("key-free default failed", err) + } + t.Setenv("BASEMAP_PROVIDER", "carto") + if _, err := Load("test", "abc"); err == nil { + t.Fatal("missing CARTO key accepted") + } + keyPath := filepath.Join(t.TempDir(), "browser-key") + t.Setenv("CARTO_BASEMAP_API_KEY_FILE", keyPath) + for _, value := range []string{"", "two keys", strings.Repeat("x", 8193)} { + if err := os.WriteFile(keyPath, []byte(value), 0600); err != nil { + t.Fatal(err) + } + if _, err := Load("test", "abc"); err == nil { + t.Fatal("invalid browser key accepted") + } + } + if err := os.WriteFile(keyPath, []byte("synthetic-public-browser-key\n"), 0600); err != nil { + t.Fatal(err) + } + cfg, err = Load("test", "abc") + if err != nil || cfg.CartoBrowserKey != "synthetic-public-browser-key" { + t.Fatal("runtime browser key not loaded", err) + } + t.Setenv("BASEMAP_PROVIDER", "https://untrusted.invalid/style") + if _, err := Load("test", "abc"); err == nil { + t.Fatal("arbitrary map provider accepted") + } +} diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go index 9b621db..27d866f 100644 --- a/backend/internal/config/config.go +++ b/backend/internal/config/config.go @@ -2,6 +2,7 @@ package config import ( "fmt" + "io" "os" "strconv" "strings" @@ -9,18 +10,20 @@ import ( ) type Config struct { - HTTPAddr string - Checkpoint string - MQTTEnabled bool - MQTTBrokerURL string - MQTTTopic string - MQTTClientID string - MQTTUsername string - MQTTPassword string - Regions map[string]struct{} - QueueSize int - Version string - GitSHA string + HTTPAddr string + Checkpoint string + MQTTEnabled bool + MQTTBrokerURL string + MQTTTopic string + MQTTClientID string + MQTTUsername string + MQTTPassword string + Regions map[string]struct{} + QueueSize int + Version string + GitSHA string + MapProvider string + CartoBrowserKey string } func Load(version, gitSHA string) (Config, error) { @@ -36,12 +39,35 @@ func Load(version, gitSHA string) (Config, error) { QueueSize: envInt("MQTT_INGEST_QUEUE_SIZE", 4096), Version: cleanBuildValue(version, "dev"), GitSHA: cleanBuildValue(gitSHA, "unknown"), + MapProvider: strings.ToLower(env("BASEMAP_PROVIDER", "openfreemap")), } regions, err := regionAllowlist() if err != nil { return Config{}, err } c.Regions = regions + if c.MapProvider != "openfreemap" && c.MapProvider != "carto" { + return Config{}, fmt.Errorf("BASEMAP_PROVIDER must be openfreemap or carto") + } + if c.MapProvider == "carto" { + keyPath := strings.TrimSpace(os.Getenv("CARTO_BASEMAP_API_KEY_FILE")) + if keyPath == "" { + return Config{}, fmt.Errorf("CARTO_BASEMAP_API_KEY_FILE is required for the carto provider") + } + file, err := os.Open(keyPath) + if err != nil { + return Config{}, fmt.Errorf("CARTO browser key file could not be opened") + } + key, readErr := io.ReadAll(io.LimitReader(file, 8193)) + file.Close() + if readErr != nil || len(key) > 8192 { + return Config{}, fmt.Errorf("CARTO browser key file could not be read or is too large") + } + c.CartoBrowserKey = strings.TrimSpace(string(key)) + if c.CartoBrowserKey == "" || strings.ContainsAny(c.CartoBrowserKey, "\r\n\t ") { + return Config{}, fmt.Errorf("CARTO browser key file must contain one browser key") + } + } if c.MQTTEnabled && strings.TrimSpace(c.MQTTBrokerURL) == "" { return Config{}, fmt.Errorf("MQTT_BROKER_URL is required") } diff --git a/backend/internal/httpapi/public_config_test.go b/backend/internal/httpapi/public_config_test.go new file mode 100644 index 0000000..06d1b57 --- /dev/null +++ b/backend/internal/httpapi/public_config_test.go @@ -0,0 +1,27 @@ +package httpapi + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestPublicConfigHasOnlyBrowserMapSettings(t *testing.T) { + t.Setenv("MQTT_PASSWORD", "synthetic-private-password") + response := httptest.NewRecorder() + testHandler(t, true).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + want := "{\"schemaVersion\":1,\"basemap\":{\"provider\":\"openfreemap\"}}\n" + if response.Code != http.StatusOK || response.Body.String() != want { + t.Fatalf("unexpected public config: %s", response.Body.String()) + } + if response.Header().Get("Cache-Control") != "no-store" { + t.Fatal("public runtime configuration must not be cached") + } + server := &Server{basemap: PublicBasemapConfig{Provider: "carto", CartoBrowserKey: "synthetic-public-browser-key"}} + response = httptest.NewRecorder() + server.publicConfig(response, httptest.NewRequest(http.MethodGet, "/api/config", nil)) + want = "{\"schemaVersion\":1,\"basemap\":{\"provider\":\"carto\",\"cartoBrowserKey\":\"synthetic-public-browser-key\"}}\n" + if response.Body.String() != want { + t.Fatalf("unexpected CARTO configuration: %s", response.Body.String()) + } +} diff --git a/backend/internal/httpapi/server.go b/backend/internal/httpapi/server.go index 9034fcf..40b3fe4 100644 --- a/backend/internal/httpapi/server.go +++ b/backend/internal/httpapi/server.go @@ -22,6 +22,12 @@ import ( //go:embed static/* var embeddedStatic embed.FS +// Only intentionally browser-visible map settings belong in this response. +type PublicBasemapConfig struct { + Provider string `json:"provider"` + CartoBrowserKey string `json:"cartoBrowserKey,omitempty"` +} + type Server struct { engine *engine.Engine hub *Hub @@ -29,9 +35,19 @@ type Server struct { static fs.FS version string gitSHA string + basemap PublicBasemapConfig } -func New(engineState *engine.Engine, hub *Hub, mqttReady func() bool, version, gitSHA string) (*Server, error) { +func New(engineState *engine.Engine, hub *Hub, mqttReady func() bool, version, gitSHA string, basemap PublicBasemapConfig) (*Server, error) { + if basemap.Provider == "" { + basemap.Provider = "openfreemap" + } + if basemap.Provider != "openfreemap" && basemap.Provider != "carto" { + return nil, fmt.Errorf("unsupported basemap provider") + } + if basemap.Provider == "openfreemap" { + basemap.CartoBrowserKey = "" + } assets, err := fs.Sub(embeddedStatic, "static") if err != nil { return nil, fmt.Errorf("open embedded frontend: %w", err) @@ -39,7 +55,7 @@ func New(engineState *engine.Engine, hub *Hub, mqttReady func() bool, version, g if _, err := fs.Stat(assets, "index.html"); err != nil { return nil, fmt.Errorf("embedded frontend has no index.html: %w", err) } - return &Server{engine: engineState, hub: hub, mqttReady: mqttReady, static: assets, version: version, gitSHA: gitSHA}, nil + return &Server{engine: engineState, hub: hub, mqttReady: mqttReady, static: assets, version: version, gitSHA: gitSHA, basemap: basemap}, nil } func (s *Server) Handler() http.Handler { @@ -48,10 +64,18 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /readyz", s.ready) mux.HandleFunc("GET /api/state", s.state) mux.HandleFunc("GET /api/events", s.events) + mux.HandleFunc("GET /api/config", s.publicConfig) mux.HandleFunc("/", s.frontend) return securityHeaders(mux) } +func (s *Server) publicConfig(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, struct { + SchemaVersion int `json:"schemaVersion"` + Basemap PublicBasemapConfig `json:"basemap"` + }{SchemaVersion: 1, Basemap: s.basemap}) +} + func (s *Server) health(w http.ResponseWriter, _ *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"ok": true, "version": s.version, "gitSha": s.gitSHA, "bootId": s.engine.BootID()}) } @@ -270,7 +294,7 @@ func securityHeaders(next http.Handler) http.Handler { w.Header().Set("X-Frame-Options", "DENY") w.Header().Set("Strict-Transport-Security", "max-age=31536000") w.Header().Set("Permissions-Policy", "accelerometer=(), bluetooth=(), camera=(), geolocation=(), gyroscope=(), microphone=(), payment=(), serial=(), usb=()") - w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https://*.basemaps.cartocdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.basemaps.cartocdn.com https://tiles.mapterhorn.com; worker-src 'self' blob:; child-src blob:") + w.Header().Set("Content-Security-Policy", "default-src 'self'; img-src 'self' data: https://*.basemaps.cartocdn.com; style-src 'self' 'unsafe-inline'; connect-src 'self' https://*.basemaps.cartocdn.com https://tiles.mapterhorn.com https://tiles.openfreemap.org; worker-src 'self' blob:; child-src blob:") next.ServeHTTP(w, r) }) } diff --git a/backend/internal/httpapi/server_test.go b/backend/internal/httpapi/server_test.go index c5e08c7..fb6debd 100644 --- a/backend/internal/httpapi/server_test.go +++ b/backend/internal/httpapi/server_test.go @@ -20,7 +20,7 @@ func testHandler(t *testing.T, ready bool) http.Handler { if err != nil { t.Fatal(err) } - server, err := New(state, NewHub(state.BootID()), func() bool { return ready }, "test", "abc") + server, err := New(state, NewHub(state.BootID()), func() bool { return ready }, "test", "abc", PublicBasemapConfig{Provider: "openfreemap"}) if err != nil { t.Fatal(err) } @@ -166,7 +166,7 @@ func TestSSESubscribesBeforeHelloFlush(t *testing.T) { t.Fatal(err) } hub := NewHub(state.BootID()) - server, err := New(state, hub, func() bool { return true }, "test", "abc") + server, err := New(state, hub, func() bool { return true }, "test", "abc", PublicBasemapConfig{Provider: "openfreemap"}) if err != nil { t.Fatal(err) } @@ -217,7 +217,7 @@ func TestSSEHelloHasNoIDAndReplayIsOrdered(t *testing.T) { hub := NewHub(state.BootID()) hub.Publish(engine.Event{Name: "node", Seq: 1, Data: map[string]any{"seq": 1}}) hub.Publish(engine.Event{Name: "packet", Seq: 2, Data: map[string]any{"seq": 2}}) - server, err := New(state, hub, func() bool { return true }, "test", "abc") + server, err := New(state, hub, func() bool { return true }, "test", "abc", PublicBasemapConfig{Provider: "openfreemap"}) if err != nil { t.Fatal(err) } @@ -248,7 +248,7 @@ func TestSSEBootMismatchResetsAndCloses(t *testing.T) { } hub := NewHub(state.BootID()) hub.Publish(engine.Event{Name: "status", Seq: 1, Data: map[string]any{"seq": 1}}) - server, err := New(state, hub, func() bool { return true }, "test", "abc") + server, err := New(state, hub, func() bool { return true }, "test", "abc", PublicBasemapConfig{Provider: "openfreemap"}) if err != nil { t.Fatal(err) } diff --git a/compose.build.yml b/compose.build.yml new file mode 100644 index 0000000..71035dc --- /dev/null +++ b/compose.build.yml @@ -0,0 +1,11 @@ +# Optional source build. Published-image installs only need compose.yml. +services: + cartolite: + image: ${CARTOLITE_BUILD_IMAGE:-cartolite-server:local} + pull_policy: never + build: + context: . + args: + APP_VERSION: ${CARTOLITE_VERSION:-0.5.0} + GIT_SHA: ${CARTOLITE_GIT_SHA:-local} + BUILD_TIME: ${CARTOLITE_BUILD_TIME:-unknown} diff --git a/compose.carto.yml b/compose.carto.yml new file mode 100644 index 0000000..e7267de --- /dev/null +++ b/compose.carto.yml @@ -0,0 +1,11 @@ +# Optional: docker compose -f compose.yml -f compose.carto.yml up -d +services: + cartolite: + environment: + BASEMAP_PROVIDER: carto + CARTO_BASEMAP_API_KEY_FILE: /run/secrets/carto_basemap_api_key + secrets: + - carto_basemap_api_key +secrets: + carto_basemap_api_key: + file: ${CARTO_BASEMAP_API_KEY_FILE:-./.secrets/carto-basemap-api-key} diff --git a/compose.yml b/compose.yml index 7efc195..5d8831f 100644 --- a/compose.yml +++ b/compose.yml @@ -2,15 +2,7 @@ name: cartolite-server services: cartolite: - image: ${CARTOLITE_IMAGE:-cartolite-server:local} - build: - context: . - args: - APP_VERSION: ${CARTOLITE_VERSION:-dev} - GIT_SHA: ${CARTOLITE_GIT_SHA:-local} - BUILD_TIME: ${CARTOLITE_BUILD_TIME:-unknown} - secrets: - - carto_basemap_api_key + image: ${CARTOLITE_IMAGE:-ghcr.io/n30nex/cartolite-server:0.5.0} restart: unless-stopped ports: - "${CARTOLITE_BIND_ADDR:-127.0.0.1}:${CARTOLITE_PORT:-8080}:8080" @@ -26,6 +18,7 @@ services: MQTT_INGEST_QUEUE_SIZE: ${MQTT_INGEST_QUEUE_SIZE:-4096} STATE_PATH: ${STATE_PATH:-/data/state-v1.json} LOG_LEVEL: ${LOG_LEVEL:-info} + BASEMAP_PROVIDER: openfreemap volumes: - cartolite-data:/data read_only: true @@ -50,7 +43,3 @@ services: volumes: cartolite-data: - -secrets: - carto_basemap_api_key: - file: ${CARTO_BASEMAP_API_KEY_FILE:-./.secrets/carto-basemap-api-key} diff --git a/scripts/compose-smoke.sh b/scripts/compose-smoke.sh new file mode 100644 index 0000000..e1dbd8e --- /dev/null +++ b/scripts/compose-smoke.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +set -euo pipefail + +image="${1:?candidate image required}" +revision="${2:?candidate revision required}" +previous_image="${3:-$image}" +[[ "$image" =~ ^[a-zA-Z0-9._/:@-]+$ && "$previous_image" =~ ^[a-zA-Z0-9._/:@-]+$ && "$revision" =~ ^[0-9a-f]{40}$ ]] +project=cartolite-install-ci +broker=cartolite-install-mqtt +root="$(pwd)" +scratch="$(mktemp -d "${RUNNER_TEMP:-/tmp}/cartolite-install-XXXXXX")" +output="${COMPOSE_SMOKE_OUTPUT:-artifacts/compose}" +mkdir -p "$output" +compose=(docker compose --env-file "$scratch/install.env" -f "$root/compose.yml" -p "$project") +write_env() { + cat > "$scratch/install.env" </dev/null 2>&1 || true + "${compose[@]}" down -v >/dev/null 2>&1 || true + case "$scratch" in "${RUNNER_TEMP:-/tmp}"/cartolite-install-*) rm -rf -- "$scratch" ;; esac +} +trap cleanup EXIT +wait_ready() { + for _ in $(seq 1 60); do + if curl --fail --silent http://127.0.0.1:39479/readyz | jq -e '.ready and .mqtt and .checkpoint and .dropped == 0' >/dev/null; then return; fi + sleep 1 + done + echo 'Compose instance did not become ready' >&2 + return 1 +} +write_env "$previous_image" +"${compose[@]}" up -d --no-build +docker run -d --name "$broker" --network "${project}_default" \ + --mount type=bind,source="$root/testdata/mosquitto.conf",target=/mosquitto/config/mosquitto.conf,readonly \ + eclipse-mosquitto:2.0.22 >/dev/null +wait_ready +bash scripts/publish-fixture.sh "$broker" testdata/synthetic-live.ndjson +for _ in $(seq 1 60); do + curl --fail --silent http://127.0.0.1:39479/api/state > "$scratch/before.json" + jq -e '(.nodes | length) >= 2 and (.routes | length) >= 1' "$scratch/before.json" >/dev/null && break + sleep 1 +done +jq -e '(.nodes | length) >= 2 and (.routes | length) >= 1' "$scratch/before.json" >/dev/null +# Graceful stop exercises the actual checkpoint/upgrade path, with no replacement fixture after restart. +"${compose[@]}" stop +write_env "$image" +"${compose[@]}" up -d --no-build --force-recreate +wait_ready +curl --fail --silent http://127.0.0.1:39479/api/state > "$scratch/after.json" +curl --fail --silent http://127.0.0.1:39479/api/config | jq -e '. == {schemaVersion:1,basemap:{provider:"openfreemap"}}' >/dev/null +jq -n -e --arg sha "$revision" --slurpfile before "$scratch/before.json" --slurpfile after "$scratch/after.json" ' + $after[0].status.gitSha == $sha and $after[0].schemaVersion == 2 and + ($before[0].nodes | map(.id) | sort) == ($after[0].nodes | map(.id) | sort) and + ($before[0].routes | map(.id) | sort) == ($after[0].routes | map(.id) | sort) +' >/dev/null +container="$("${compose[@]}" ps -q cartolite)" +docker inspect "$container" | jq -e --arg sha "$revision" '.[0] | + .Config.User == "65532:65532" and .HostConfig.ReadonlyRootfs and + (.HostConfig.CapDrop | index("ALL")) != null and + .Config.Labels["org.opencontainers.image.revision"] == $sha and + .HostConfig.PortBindings["8080/tcp"][0].HostIp == "127.0.0.1" +' >/dev/null +jq --arg image "$image" --arg previous "$previous_image" '{revision:.status.gitSha,version:.status.version,image:$image,previousImage:$previous,nodes:(.nodes|length),routes:(.routes|length),checkpointPreserved:true,keyFreeConfiguration:true,hardeningVerified:true}' "$scratch/after.json" > "$output/verification.json" +echo 'Compose install and checkpoint-preserving upgrade passed.' diff --git a/web/e2e/terrain.spec.ts b/web/e2e/terrain.spec.ts index 283a3a7..bffede9 100644 --- a/web/e2e/terrain.spec.ts +++ b/web/e2e/terrain.spec.ts @@ -41,7 +41,8 @@ test('3D enables Topo and projects live and reduced-motion traffic over syntheti await expect(map).toHaveAttribute('data-render-state', 'idle', { timeout: 15_000 }); await emitPacket(page); await expect(packets).toHaveAttribute('data-projection-mode', 'terrain'); - await expect(packets).toHaveAttribute('data-projection-samples', '17'); + await expect.poll(async () => Number(await packets.getAttribute('data-projection-samples'))).toBeGreaterThanOrEqual(5); + expect(Number(await packets.getAttribute('data-projection-samples'))).toBeLessThanOrEqual(65); await expect.poll(() => packets.getAttribute('data-test-ground-rings').then(Number)).toBeGreaterThan(0); await page.screenshot({ path: testInfo.outputPath('synthetic-relief-3d.png') }); @@ -52,7 +53,8 @@ test('3D enables Topo and projects live and reduced-motion traffic over syntheti await page.mouse.up({ button: 'right' }); await expect(map).toHaveAttribute('data-render-state', 'idle', { timeout: 15_000 }); await emitPacket(page); - await expect(packets).toHaveAttribute('data-projection-samples', '17'); + await expect.poll(async () => Number(await packets.getAttribute('data-projection-samples'))).toBeGreaterThanOrEqual(5); + expect(Number(await packets.getAttribute('data-projection-samples'))).toBeLessThanOrEqual(65); const wakesBeforeStatic = Number(await packets.getAttribute('data-wakes-scheduled')); await page.emulateMedia({ reducedMotion: 'reduce' }); await emitPacket(page); diff --git a/web/e2e/visual-smoke.spec.ts b/web/e2e/visual-smoke.spec.ts index b51ad11..ddb040b 100644 --- a/web/e2e/visual-smoke.spec.ts +++ b/web/e2e/visual-smoke.spec.ts @@ -12,7 +12,7 @@ test('renders the live route map and privacy-safe state', async ({ page }, testI await instrumentAudioContext(page); const mapStyleErrors = captureMapStyleErrors(page); const consoleErrors = captureConsoleErrors(page); - const cartoResponses = { tileJSON: 0, vector: 0, glyph: 0 }; + const basemapResponses = { tileJSON: 0, vector: 0, glyph: 0 }; const terrainResponses = { tileJSON: 0, dem: 0 }; const rasterRequests: string[] = []; page.on('request', (request) => { @@ -21,9 +21,9 @@ test('renders the live route map and privacy-safe state', async ({ page }, testI page.on('response', (response) => { if (!response.ok()) return; const url = response.url(); - if (url.includes('/vector/carto.streets/v1/tiles.json')) cartoResponses.tileJSON += 1; - else if (url.includes('/vectortiles/carto.streets/') && url.includes('.mvt')) cartoResponses.vector += 1; - else if (url.includes('/fonts/') && url.includes('.pbf')) cartoResponses.glyph += 1; + if (new URL(url).hostname === 'tiles.openfreemap.org' && new URL(url).pathname === '/planet') basemapResponses.tileJSON += 1; + else if (new URL(url).hostname === 'tiles.openfreemap.org' && new URL(url).pathname.startsWith('/planet/')) basemapResponses.vector += 1; + else if (url.includes('/fonts/') && url.includes('.pbf')) basemapResponses.glyph += 1; else if (url.includes('tiles.mapterhorn.com/tilejson.json')) terrainResponses.tileJSON += 1; else if (url.includes('tiles.mapterhorn.com/') && !url.includes('/tilejson.json')) terrainResponses.dem += 1; }); @@ -37,9 +37,9 @@ test('renders the live route map and privacy-safe state', async ({ page }, testI await expect(page.locator('.map-grade')).toHaveCSS('pointer-events', 'none'); await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle', { timeout: 10_000 }); expect(mapStyleErrors, 'MapLibre should accept every installed layer expression').toEqual([]); - await expect.poll(() => cartoResponses.tileJSON, { message: 'CARTO vector TileJSON should load' }).toBeGreaterThan(0); - await expect.poll(() => cartoResponses.vector, { message: 'CARTO vector PBF tiles should load' }).toBeGreaterThan(0); - await expect.poll(() => cartoResponses.glyph, { message: 'CARTO glyph PBFs should load' }).toBeGreaterThan(0); + await expect.poll(() => basemapResponses.tileJSON, { message: 'OpenFreeMap vector TileJSON should load' }).toBeGreaterThan(0); + await expect.poll(() => basemapResponses.vector, { message: 'OpenFreeMap vector PBF tiles should load' }).toBeGreaterThan(0); + await expect.poll(() => basemapResponses.glyph, { message: 'OpenFreeMap glyph PBFs should load' }).toBeGreaterThan(0); expect(rasterRequests, 'the vector-only release must not request a raster basemap').toEqual([]); await expect(page.locator('#route-canvas')).toHaveCount(0); await expect(page.locator('#map')).toHaveAttribute('data-route-renderer', 'maplibre-webgl'); @@ -154,7 +154,7 @@ test('renders the live route map and privacy-safe state', async ({ page }, testI await openMapOptions(page); await terrainButton.click(); await expect(page.locator('#map')).toHaveAttribute('data-terrain3d', 'true'); - await expect(page.locator('#map')).toHaveAttribute('data-camera-pitch', '52'); + await expect(page.locator('#map')).toHaveAttribute('data-camera-pitch', '50'); await openMapOptions(page); await terrainButton.click(); await expect(page.locator('#map')).toHaveAttribute('data-terrain3d', 'false'); @@ -362,6 +362,7 @@ test('keeps mobile awake and refreshes live state after the page resumes', async test('keeps a recent packet trail after stable routes are hidden', async ({ page }, testInfo) => { test.skip(isMobileProject(testInfo.project.name), 'trail lifetime is covered once on desktop'); + await page.addInitScript(() => localStorage.setItem('cartolite-server:display:v1', JSON.stringify({ preset: 'custom', residueSeconds: 45 }))); const now = Date.now(); const from = { id: 'a', label: 'Alpha', lat: 43.45, lng: -80.42 }; const to = { id: 'b', label: 'Bravo', lat: 43.5, lng: -80.28 }; diff --git a/web/index.html b/web/index.html index 1af1cfb..1d3b2f0 100644 --- a/web/index.html +++ b/web/index.html @@ -98,6 +98,7 @@ 3D + @@ -113,7 +114,13 @@ - + +
Desktop 3D camera + + + +

Height changes the terrain’s visual scale. Buildings use approximate mapped heights, not antenna heights.

+
diff --git a/web/package-lock.json b/web/package-lock.json index c0655d9..b4a53ae 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "cartolite-server-web", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cartolite-server-web", - "version": "0.4.1", + "version": "0.5.0", "dependencies": { "maplibre-gl": "6.4.1" }, diff --git a/web/package.json b/web/package.json index 5347fb5..8fc3658 100644 --- a/web/package.json +++ b/web/package.json @@ -1,6 +1,6 @@ { "name": "cartolite-server-web", - "version": "0.4.1", + "version": "0.5.0", "private": true, "type": "module", "scripts": { diff --git a/web/src/basemap.ts b/web/src/basemap.ts index 8d10fd1..e871c66 100644 --- a/web/src/basemap.ts +++ b/web/src/basemap.ts @@ -1,21 +1,42 @@ import type { StyleSpecification } from 'maplibre-gl'; import type { BasemapStyle } from './preferences'; +import type { PublicMapConfig } from './types'; +import { OPENFREEMAP_TILEJSON } from './buildings'; const CARTO_VECTOR_TILEJSON = 'https://tiles.basemaps.cartocdn.com/vector/carto.streets/v1/tiles.json'; const CARTO_GLYPHS = 'https://tiles.basemaps.cartocdn.com/fonts/{fontstack}/{range}.pbf'; -const CARTO_BASEMAP_API_KEY = import.meta.env.VITE_CARTO_BASEMAP_API_KEY?.trim() ?? ''; +let basemap: PublicMapConfig['basemap'] = { provider: 'openfreemap' }; +export function basemapProvider(): 'openfreemap' | 'carto' { return basemap.provider; } +export function basemapFontStack(): string[] { return [basemap.provider === 'carto' ? 'Open Sans Regular' : 'Noto Sans Regular']; } -export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapStyle = 'dark'): StyleSpecification { +export async function loadBasemapConfiguration(): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 8_000); + try { + const response = await fetch('/api/config', { cache: 'no-store', credentials: 'same-origin', signal: controller.signal }); + if (!response.ok) throw new Error('Map configuration is unavailable'); + const value = await response.json() as Partial | null; + if (value?.schemaVersion !== 1 || !value.basemap || !['openfreemap', 'carto'].includes(value.basemap.provider)) throw new Error('Map configuration is invalid'); + const key = value.basemap.cartoBrowserKey; + if (value.basemap.provider === 'carto' && (typeof key !== 'string' || key.length === 0 || key.length > 8192 || /\s/.test(key))) throw new Error('CARTO browser configuration is invalid'); + basemap = value.basemap.provider === 'carto' ? { provider: 'carto', cartoBrowserKey: key } : { provider: 'openfreemap' }; + } finally { clearTimeout(timeout); } +} + +export function cartoVectorStyle(apiKey?: string, style: BasemapStyle = 'dark'): StyleSpecification { + const carto = apiKey !== undefined || basemap.provider === 'carto'; + const key = apiKey ?? basemap.cartoBrowserKey ?? ''; + const fonts = [carto ? 'Open Sans Regular' : 'Noto Sans Regular']; const color = (value: string): string => BASEMAP_COLORS[style][value] ?? value; return { version: 8, name: `CartoLite ${style}`, - glyphs: withKey(CARTO_GLYPHS, apiKey), + glyphs: carto ? withKey(CARTO_GLYPHS, key) : 'https://tiles.openfreemap.org/fonts/{fontstack}/{range}.pbf', sources: { carto: { type: 'vector', - url: withKey(CARTO_VECTOR_TILEJSON, apiKey), - attribution: '© OpenStreetMap contributors © CARTO' + url: carto ? withKey(CARTO_VECTOR_TILEJSON, key) : OPENFREEMAP_TILEJSON, + attribution: carto ? '© OpenStreetMap contributors © CARTO' : 'OpenFreeMap © OpenMapTiles Data from OpenStreetMap' } }, layers: [ @@ -146,7 +167,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS filter: ['has', 'name'], layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], + 'text-font': fonts, 'text-size': ['interpolate', ['linear'], ['zoom'], 5.5, 9.5, 10, 11.5, 14, 13], 'text-letter-spacing': 0.04, 'text-max-width': 10, @@ -171,7 +192,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS filter: ['==', ['get', 'class'], 'country'], layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], + 'text-font': fonts, 'text-size': ['interpolate', ['linear'], ['zoom'], 2, 10, 6, 13.5], 'text-letter-spacing': 0.1, 'text-transform': 'uppercase', @@ -196,7 +217,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS filter: ['==', ['get', 'class'], 'state'], layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], + 'text-font': fonts, 'text-size': ['interpolate', ['linear'], ['zoom'], 3.5, 8.5, 9, 11], 'text-letter-spacing': 0.05, 'text-max-width': 9, @@ -220,7 +241,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS filter: ['==', ['get', 'class'], 'city'], layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], + 'text-font': fonts, 'text-size': [ 'interpolate', ['linear'], ['zoom'], 3.25, 9.5, @@ -250,7 +271,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS filter: ['in', ['get', 'class'], ['literal', ['town', 'village']]], layout: { 'text-field': ['coalesce', ['get', 'name_en'], ['get', 'name']], - 'text-font': ['Open Sans Regular'], + 'text-font': fonts, 'text-size': ['interpolate', ['linear'], ['zoom'], 6, 8.5, 11, 10.5, 15, 12], 'text-max-width': 9, 'text-padding': 6, @@ -270,7 +291,7 @@ export function cartoVectorStyle(apiKey = CARTO_BASEMAP_API_KEY, style: BasemapS } as StyleSpecification; } -export function cartoVectorRequestURL(url: string, apiKey = CARTO_BASEMAP_API_KEY): string { +export function cartoVectorRequestURL(url: string, apiKey = basemap.cartoBrowserKey ?? ''): string { const key = apiKey.trim(); if (!key) return url; try { diff --git a/web/src/basemapConfig.test.ts b/web/src/basemapConfig.test.ts new file mode 100644 index 0000000..a5d4021 --- /dev/null +++ b/web/src/basemapConfig.test.ts @@ -0,0 +1,23 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { basemapFontStack, basemapProvider, cartoVectorRequestURL, cartoVectorStyle, loadBasemapConfiguration } from './basemap'; + +afterEach(() => vi.unstubAllGlobals()); +it('loads only supported public runtime map settings without a compiled key', async () => { + const response = (basemap: unknown) => ({ ok: true, json: async () => ({ schemaVersion: 1, basemap }) }); + const request = vi.fn().mockResolvedValue(response({ provider: 'openfreemap' })); + vi.stubGlobal('fetch', request); + await loadBasemapConfiguration(); + expect(cartoVectorStyle().sources.carto).toMatchObject({ url: 'https://tiles.openfreemap.org/planet' }); + expect(basemapFontStack()).toEqual(['Noto Sans Regular']); + request.mockResolvedValue(response({ provider: 'carto', cartoBrowserKey: 'synthetic-public-key' })); + await loadBasemapConfiguration(); + expect(basemapProvider()).toBe('carto'); + expect(basemapFontStack()).toEqual(['Open Sans Regular']); + expect(cartoVectorStyle().glyphs).toContain('key=synthetic-public-key'); + expect(cartoVectorRequestURL('https://tiles.openfreemap.org/planet')).toBe('https://tiles.openfreemap.org/planet'); + request.mockResolvedValue(response({ provider: 'https://untrusted.invalid/' })); + await expect(loadBasemapConfiguration()).rejects.toThrow('invalid'); + request.mockResolvedValue(response({ provider: 'openfreemap' })); + await loadBasemapConfiguration(); + expect(JSON.stringify(cartoVectorStyle())).not.toContain('key='); +}); diff --git a/web/src/buildings.ts b/web/src/buildings.ts new file mode 100644 index 0000000..9ff1c4f --- /dev/null +++ b/web/src/buildings.ts @@ -0,0 +1,32 @@ +import type { Map as MapLibreMap } from 'maplibre-gl'; + +export const BUILDING_SOURCE = 'building-tiles'; +export const BUILDING_FOOTPRINTS = 'building-footprints'; +export const BUILDING_EXTRUSIONS = 'building-extrusions'; +export const OPENFREEMAP_TILEJSON = 'https://tiles.openfreemap.org/planet'; + +export function updateBuildings(map: MapLibreMap, visible: boolean, terrain: boolean, desktop: boolean, light: boolean, existingSource?: string): void { + if (!visible && !map.getLayer(BUILDING_FOOTPRINTS)) return; + const source = existingSource ?? BUILDING_SOURCE; + if (!map.getSource(source)) map.addSource(source, { type: 'vector', url: OPENFREEMAP_TILEJSON }); + const before = map.getLayer('basemap-water-labels') ? 'basemap-water-labels' : undefined; + if (!map.getLayer(BUILDING_FOOTPRINTS)) map.addLayer({ + id: BUILDING_FOOTPRINTS, source, 'source-layer': 'building', type: 'fill', minzoom: 13, + paint: { 'fill-color': '#405b61', 'fill-opacity': 0.3 }, + }, before); + if (!map.getLayer(BUILDING_EXTRUSIONS)) map.addLayer({ + id: BUILDING_EXTRUSIONS, source, 'source-layer': 'building', type: 'fill-extrusion', minzoom: 13, + filter: ['all', ['!=', ['get', 'hide_3d'], true], ['>', ['number', ['get', 'render_height'], 0], 0]], + paint: { + 'fill-extrusion-color': '#506c73', + 'fill-extrusion-height': ['interpolate', ['linear'], ['zoom'], 13, 0, 15, ['max', 0, ['number', ['get', 'render_height'], 0]]], + 'fill-extrusion-base': ['interpolate', ['linear'], ['zoom'], 13, 0, 15, ['max', 0, ['min', ['number', ['get', 'render_min_height'], 0], ['number', ['get', 'render_height'], 0]]]], + 'fill-extrusion-opacity': 0.78, + 'fill-extrusion-vertical-gradient': true, + }, + }, before); + map.setLayoutProperty(BUILDING_FOOTPRINTS, 'visibility', visible ? 'visible' : 'none'); + map.setLayoutProperty(BUILDING_EXTRUSIONS, 'visibility', visible && terrain && desktop ? 'visible' : 'none'); + map.setPaintProperty(BUILDING_FOOTPRINTS, 'fill-color', light ? '#a8b8b2' : '#405b61'); + map.setPaintProperty(BUILDING_EXTRUSIONS, 'fill-extrusion-color', light ? '#bbc9c1' : '#506c73'); +} diff --git a/web/src/display.css b/web/src/display.css new file mode 100644 index 0000000..0739f7c --- /dev/null +++ b/web/src/display.css @@ -0,0 +1,65 @@ +.display-settings { display: grid; gap: 10px; min-width: 0; color: var(--ink, #d9e8ee); } +.display-settings label { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; font-size: 12px; } +.display-settings select { min-width: 130px; max-width: 58%; min-height: 44px; border: 1px solid var(--panel-border, #41636a); border-radius: 8px; padding: 6px; background: var(--field-bg, #07141a); color: var(--ink-strong, #efffff); font: inherit; } +.display-settings .display-slider { display: grid; grid-template-columns: 1fr auto; gap: 5px; padding-block: 4px; } +.display-slider input { grid-column: 1 / -1; width: 100%; margin: 0; min-height: 25px; accent-color: var(--kind-advert, #4de7c4); } +.display-slider output { font-variant-numeric: tabular-nums; color: var(--ink-strong, #efffff); } +.display-advanced { border-top: 1px solid var(--panel-border, #41636a); } +.display-advanced summary { padding: 14px 0; cursor: pointer; font-size: 12px; font-weight: 650; } +.display-reset { width: 100%; min-height: 44px; margin-top: 8px; border: 1px solid var(--panel-border, #41636a); border-radius: 8px; background: var(--field-bg, #07141a); color: var(--ink-strong, #efffff); cursor: pointer; } +.display-note { margin: 0; font-size: 11px; line-height: 1.5; color: var(--muted, #9fb2bf); } +.route-preview { position: relative; height: 54px; overflow: hidden; border-radius: 8px; background: var(--scene-background, #071319); color: var(--kind-text, #ff75b5); } +.route-preview i { position: absolute; left: 12%; right: 12%; top: 18px; border-top: var(--preview-width) solid currentColor; opacity: var(--preview-opacity); filter: drop-shadow(0 0 var(--preview-glow) currentColor); } +[data-pattern="dashed"] .route-preview i { border-top-style: dashed; } +[data-pattern="dotted"] .route-preview i { border-top-style: dotted; } +.route-preview b { position: absolute; width: var(--preview-head); height: var(--preview-head); top: calc(18px - var(--preview-head) / 2); left: 62%; border-radius: 50%; background: currentColor; box-shadow: 0 0 var(--preview-glow) currentColor; } +.route-preview b::before { content: ''; position: absolute; right: 0; top: 35%; height: 30%; width: var(--preview-tail); background: linear-gradient(90deg, transparent, currentColor); } +.route-preview span { position: absolute; left: 12px; bottom: 5px; font-size: 10px; color: var(--kind-other, #9caebd); } +.display-disclosure { position: relative; } +.display-panel { position: absolute; top: calc(100% + 8px); right: 0; width: min(320px, calc(100vw - 32px)); max-height: calc(100dvh - 150px); padding: 12px; border-radius: 12px; overflow: auto; z-index: 10; } +.display-panel[hidden] { display: none; } +.display-panel header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; } +.display-panel header button { min-width: 44px; min-height: 44px; border: 0; color: inherit; background: transparent; cursor: pointer; font-size: 22px; } +:where(button, select, input, summary, a):focus-visible { outline: 2px solid var(--focus-ring, #69d1ca) !important; outline-offset: 2px; } +@media (max-width: 900px), (max-height: 520px) { .display-panel { position: fixed; top: 122px; left: auto; right: 8px; max-height: calc(100dvh - 140px); } } + +:root[data-theme="dark"] { --ink: #d9e8ee; --ink-strong: #efffff; --muted: #a8bcc7; --field-bg: #07141a; --panel-solid: #08141a; --panel-border: #36545d; --focus-ring: #69d1ca; --selected: #193c3e; color-scheme: dark; } +:root[data-theme="light"] { --ink: #28464c; --ink-strong: #15383f; --muted: #49656a; --field-bg: #f8faf4; --panel-solid: #f3f6ef; --panel-border: #9daeb0; --focus-ring: #006b60; --selected: #d5e7df; color-scheme: light; } +:root[data-basemap] .netgraph-app, :root[data-basemap] .netgraph-stage { background: var(--scene-background); } +:root[data-basemap="light"] .graph-grade, :root[data-basemap="streets"] .graph-grade { background: none; box-shadow: inset 0 0 80px rgba(72, 93, 85, 0.05); } +.netgraph-app .glass { color: var(--ink); } +.netgraph-app .controls { z-index: 8; } +#map .maplibregl-ctrl-attrib { color: var(--muted); background: var(--panel-solid); } +#map .maplibregl-ctrl-attrib a { color: var(--ink); } +:root[data-theme="dark"] #map .maplibregl-ctrl-attrib-button { filter: invert(1); } +.netgraph-app .packet-legend i[data-kind] { background: var(--route-color); box-shadow: none; } +.netgraph-app .role-legend i[data-role="repeater"] { background: var(--ui-kind-advert); } +.netgraph-app .role-legend i[data-role="companion"] { background: var(--ui-kind-ack); } +.netgraph-app .role-legend i[data-role="room_server"] { background: var(--ui-kind-control); } +.netgraph-app .role-legend i[data-role="sensor"] { background: var(--ui-kind-trace); } +:root[data-theme="light"] .netgraph-app .glass { background: rgba(246, 249, 242, 0.97); border-color: var(--panel-border); box-shadow: 0 6px 24px rgba(25, 49, 51, 0.12); } +:root[data-theme="light"] :is(.netgraph-app, #app) :is(.brand, .workspace-link, .view-title, .status, .control-button, .reset-button, .scene-control, .volume-control, .route-window, .sound-state, .sound-description, .node-search-results, .node-inspector, .neighbor-row, .graph-summary, .legend, .empty-state, .fatal) { color: var(--ink); } +:root[data-theme="light"] .netgraph-app :is(button, a, label, strong, h2, h3, dt, dd, p, small, .node-search-name, .node-search-meta, .node-role, .neighbor-label, .neighbor-meta) { color: var(--ink); } +:root[data-theme="light"] .netgraph-app :is(input, select, .sound-toggle, .neighbor-row, .node-search-result) { background: var(--field-bg); color: var(--ink-strong); border-color: var(--panel-border); } +:root[data-theme="light"] .netgraph-app :is(button, a):hover { color: var(--ink-strong); background-color: var(--selected); } +:root[data-theme="light"] :is(.netgraph-app, #app) button:disabled { color: #5e7074; opacity: 1; } +:root[data-theme="light"] :is(.sound-panel, .netgraph-app .graph-summary) :is(header span, output, strong) { color: var(--ink-strong); } +:root[data-theme="light"] .netgraph-app :is(.neighbor-list, .node-inspector dl, .node-inspector header, .node-inspector h3) { border-color: var(--panel-border); } +:root[data-theme="light"] :is(#app, .netgraph-app) { --traffic-rgb: 0, 105, 87; } +:root[data-theme="light"] :is(#app, .netgraph-app)[data-traffic-kind="trace"] { --traffic-rgb: 133, 80, 0; } +:root[data-theme="light"] :is(#app, .netgraph-app)[data-traffic-kind="text"] { --traffic-rgb: 162, 27, 88; } +:root[data-theme="light"] :is(#app, .netgraph-app)[data-traffic-kind="ack"] { --traffic-rgb: 7, 91, 152; } +:root[data-theme="light"] :is(#app, .netgraph-app)[data-traffic-kind="control"] { --traffic-rgb: 103, 64, 160; } +.camera-settings { border-top: 1px solid var(--panel-border); font-size: 12px; } +.camera-settings summary { min-height: 44px; padding-block: 14px; cursor: pointer; } +:root[data-view-class="mobile"] .camera-settings { display: none; } +.region-legend { position: relative; color: var(--ink); font-size: 11px; } +.region-legend[hidden] { display: none; } +.region-legend summary { min-height: 32px; padding: 8px; cursor: pointer; white-space: nowrap; } +.region-legend-list { position: absolute; bottom: calc(100% + 8px); left: 0; display: grid; gap: 9px; width: min(280px, calc(100vw - 32px)); max-height: min(32vh, 230px); overflow: auto; padding: 12px; border-radius: 10px; } +.region-legend-list > span { display: flex; gap: 9px; line-height: 1.4; align-items: start; } +.region-legend-list i { width: 12px; height: 12px; margin-top: 2px; flex: none; border: 1px solid var(--ink); border-radius: 3px; } +@media (orientation: landscape) and (max-height: 520px) { .display-panel { top: 62px; max-height: calc(100dvh - 80px); } } +.map-notice { position: absolute; right: 12px; bottom: 72px; z-index: 6; display: flex; gap: 10px; align-items: center; max-width: min(390px, calc(100vw - 24px)); padding: 8px 12px; border-radius: 10px; font-size: 12px; color: var(--ink); } +.map-notice[hidden] { display: none; } +.map-notice button { min-height: 44px; min-width: 44px; color: var(--ink-strong); background: var(--field-bg); border: 1px solid var(--panel-border); border-radius: 7px; cursor: pointer; } diff --git a/web/src/displayControls.ts b/web/src/displayControls.ts new file mode 100644 index 0000000..58534d6 --- /dev/null +++ b/web/src/displayControls.ts @@ -0,0 +1,77 @@ +import './display.css'; +import { DEFAULT_DISPLAY, DISPLAY_EVENT, ROUTE_PRESETS, displayPreferences, initializeDisplay, updateDisplay, type DisplayPreferences } from './displayPreferences'; + +export function mountDisplayControls(parent: HTMLElement, sceneControls = false): void { + initializeDisplay(); + const section = document.createElement('section'); + section.className = 'display-settings'; + section.setAttribute('aria-label', 'Shared display settings'); + section.innerHTML = `${sceneControls ? `` : ''} + +
Preview · silent
+
Advanced route styling + + ${([ + ['width', 'Line width', 1, 5, 0.1, 'px'], ['opacity', 'Line opacity', 0.2, 1, 0.05, '%'], + ['glow', 'Glow', 0, 1, 0.05, '%'], ['packetSize', 'Packet size', 0.75, 2, 0.05, '%'], + ['trailLength', 'Trail length', 0.5, 2, 0.05, '%'], ['residueSeconds', 'After-trails', 0, 45, 1, 's'], + ] as const).map(([key, label, min, max, step, unit]) => ``).join('')} + +

Shared with Map and Netgraph in this browser.

`; + const oldOpacity = parent.querySelector('#route-opacity'); + const oldOpacityLabel = oldOpacity?.closest('label'); + const duplicateOpacity = section.querySelector('[data-display="opacity"]')?.closest('label'); + if (oldOpacityLabel && duplicateOpacity) { + oldOpacityLabel.querySelector('span')!.textContent = 'Line opacity'; + duplicateOpacity.replaceWith(oldOpacityLabel); + } + const layersHeading = parent.querySelector('.map-options-label'); + if (layersHeading?.parentElement === parent) parent.insertBefore(section, layersHeading); + else parent.append(section); + const sync = (): void => { + const settings = displayPreferences(); + for (const input of section.querySelectorAll('[data-display]')) input.value = String(settings[input.dataset.display as keyof DisplayPreferences]); + for (const output of section.querySelectorAll('[data-output]')) { + const value = Number(settings[output.dataset.output as keyof DisplayPreferences]); + output.value = `${output.dataset.unit === '%' ? Math.round(value * 100) : Number(value.toFixed(1))}${output.dataset.unit}`; + } + section.style.setProperty('--preview-width', `${settings.width}px`); + section.style.setProperty('--preview-glow', `${settings.glow * 10}px`); + section.style.setProperty('--preview-opacity', String(settings.opacity)); + section.style.setProperty('--preview-head', `${5 * settings.packetSize}px`); + section.style.setProperty('--preview-tail', `${30 * settings.trailLength}px`); + section.dataset.pattern = settings.pattern; + }; + section.addEventListener('input', (event) => { + const input = event.target; + if (!(input instanceof HTMLInputElement) || !input.dataset.display) return; + updateDisplay({ [input.dataset.display]: Number(input.value), preset: 'custom' }); + }); + section.addEventListener('change', (event) => { + const input = event.target; + if (!(input instanceof HTMLSelectElement) || !input.dataset.display) return; + const key = input.dataset.display; + if (key === 'preset') { + if (input.value !== 'custom') updateDisplay({ preset: input.value as DisplayPreferences['preset'], ...ROUTE_PRESETS[input.value as keyof typeof ROUTE_PRESETS] }); + } else updateDisplay({ [key]: input.value, ...(key === 'pattern' ? { preset: 'custom' as const } : {}) }); + }); + section.querySelector('.display-reset')!.addEventListener('click', () => updateDisplay({ preset: DEFAULT_DISPLAY.preset, ...ROUTE_PRESETS.crisp })); + window.addEventListener(DISPLAY_EVENT, sync); + sync(); +} + +export function mountNetgraphDisplay(): void { + const controls = document.querySelector('.controls')!; + const host = document.createElement('div'); + host.className = 'display-disclosure popover-control'; + host.innerHTML = ''; + controls.prepend(host); + const button = host.querySelector('#display-button')!; + const panel = host.querySelector('#display-panel')!; + const close = (restore = false): void => { panel.hidden = true; button.setAttribute('aria-expanded', 'false'); if (restore) button.focus(); }; + button.addEventListener('click', () => { panel.hidden = !panel.hidden; button.setAttribute('aria-expanded', String(!panel.hidden)); }); + panel.querySelector('button')!.addEventListener('click', () => close(true)); + document.addEventListener('pointerdown', (event) => { if (event.target instanceof Node && !host.contains(event.target)) close(); }); + document.addEventListener('keydown', (event) => { if (event.key === 'Escape' && !panel.hidden) close(true); }); + mountDisplayControls(panel, true); +} diff --git a/web/src/displayPreferences.test.ts b/web/src/displayPreferences.test.ts new file mode 100644 index 0000000..020ecc2 --- /dev/null +++ b/web/src/displayPreferences.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { DEFAULT_DISPLAY, DISPLAY_STORAGE_KEY, ROUTE_PRESETS, displayColor, loadDisplayPreferences, normalizeDisplay, packetPalette } from './displayPreferences'; + +describe('shared display preferences', () => { + it('migrates the map appearance without importing layers or audio settings', () => { + const storage = { getItem: (key: string) => key === DISPLAY_STORAGE_KEY ? null : JSON.stringify({ basemap: 'streets', theme: 'dark', routeOpacity: 0.55, routes: true, scene: 'choir' }) }; + expect(loadDisplayPreferences(storage)).toEqual({ ...DEFAULT_DISPLAY, basemap: 'streets', theme: 'dark', opacity: 0.55 }); + }); + it('bounds custom effects and rejects corrupt settings', () => { + expect(normalizeDisplay({ width: Infinity, glow: 9, packetSize: -4, residueSeconds: 500, pattern: 'unknown' })).toMatchObject({ width: 1.6, glow: 1, packetSize: 0.75, residueSeconds: 45, pattern: 'solid' }); + expect(loadDisplayPreferences({ getItem: () => '{bad json' })).toEqual(DEFAULT_DISPLAY); + expect(new Set(Object.values(ROUTE_PRESETS).map((preset) => JSON.stringify(preset))).size).toBe(6); + }); + it('uses distinct dark ink on light maps, preserving packet-kind identity', () => { + const light = packetPalette(true); + const dark = packetPalette(false); + expect(new Set(Object.values(light)).size).toBe(6); + for (const kind of Object.keys(dark) as Array) { + expect(displayColor(dark[kind], true)).toBe(light[kind]); + expect(contrast(light[kind], '#eef1ee')).toBeGreaterThan(4.5); + expect(contrast(light[kind], '#a4cddd')).toBeGreaterThan(3); + expect(contrast(dark[kind], '#071319')).toBeGreaterThan(4.5); + } + }); +}); + +function contrast(a: string, b: string): number { + const luminance = (hex: string): number => [1, 3, 5].map((offset) => { + const channel = Number.parseInt(hex.slice(offset, offset + 2), 16) / 255; + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + }).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0); + const values = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (values[0]! + 0.05) / (values[1]! + 0.05); +} diff --git a/web/src/displayPreferences.ts b/web/src/displayPreferences.ts new file mode 100644 index 0000000..a3aaf47 --- /dev/null +++ b/web/src/displayPreferences.ts @@ -0,0 +1,136 @@ +import { UI_STORAGE_KEY, type BasemapStyle, type InterfaceTheme } from './preferences'; +import { PACKET_KIND_COLORS as DARK_KINDS } from './trafficVisuals'; + +export type LinePattern = 'solid' | 'dashed' | 'dotted'; +export type RoutePreset = 'crisp' | 'neon' | 'dashed' | 'dotted' | 'ribbon' | 'comet' | 'custom'; +export interface DisplayPreferences { + basemap: BasemapStyle; + theme: InterfaceTheme; + preset: RoutePreset; + pattern: LinePattern; + width: number; + opacity: number; + glow: number; + packetSize: number; + trailLength: number; + residueSeconds: number; +} + +export const DISPLAY_STORAGE_KEY = UI_STORAGE_KEY.replace(':ui:', ':display:'); +export const DISPLAY_EVENT = 'cartolite:display-change'; +export const ROUTE_PRESETS = { + crisp: { pattern: 'solid', width: 1.6, opacity: 0.8, glow: 0.15, packetSize: 1, trailLength: 1, residueSeconds: 15 }, + neon: { pattern: 'solid', width: 2, opacity: 0.9, glow: 0.85, packetSize: 1.15, trailLength: 1.3, residueSeconds: 25 }, + dashed: { pattern: 'dashed', width: 1.8, opacity: 0.85, glow: 0.1, packetSize: 1, trailLength: 0.8, residueSeconds: 12 }, + dotted: { pattern: 'dotted', width: 2.4, opacity: 0.9, glow: 0.1, packetSize: 1.2, trailLength: 0.6, residueSeconds: 10 }, + ribbon: { pattern: 'solid', width: 3.5, opacity: 0.75, glow: 0.1, packetSize: 1.35, trailLength: 1, residueSeconds: 12 }, + comet: { pattern: 'solid', width: 1.4, opacity: 0.8, glow: 0.5, packetSize: 1.4, trailLength: 2, residueSeconds: 30 }, +} as const; +export const DEFAULT_DISPLAY: DisplayPreferences = { basemap: 'dark', theme: 'map', preset: 'crisp', ...ROUTE_PRESETS.crisp }; + +const LIGHT_KINDS = { Advert: '#006957', Trace: '#855000', Text: '#a21b58', ACK: '#075b98', Control: '#6740a0', Other: '#445760' }; +const LIGHT_COLORS: Record = { + ...Object.fromEntries(Object.keys(DARK_KINDS).map((kind) => [DARK_KINDS[kind as keyof typeof DARK_KINDS], LIGHT_KINDS[kind as keyof typeof LIGHT_KINDS]])), + '#63d9cc': '#006957', '#69d1ca': '#006957', '#73d9cf': '#006957', '#8ec5c1': '#254f50', + '#78d5a3': '#176342', '#68b4f6': '#075b98', '#c184f6': '#6740a0', + '#67ead2': '#006957', '#d694ff': '#6740a0', '#ffd06c': '#855000', '#a6b4bf': '#445760', + '#eaffff': '#25454b', '#f2ffff': '#17353c', + '#45c27f': '#176342', '#53a7e8': '#075b98', '#ab76dc': '#6740a0', '#a2ad57': '#596500', '#8794a6': '#445760', + '#32c8bb': '#117066', '#08272c': '#e2eee6', '#48d5c7': '#006957', '#dffffb': '#194950', + '#f3b844': '#805000', '#f2bd55': '#805000', '#fff0b8': '#805000', '#f5cf76': '#805000', '#bce9e5': '#2a6362', + '#e5fffc': '#183d40', '#edfffd': '#17464a', '#ffffff': '#172f38', + '#d2e0ef': '#284651', '#f6d77f': '#735000', '#c8d9df': '#284651', + '#02070b': '#f6f7f1', '#061216': '#f6f7f1', '#07121a': '#f6f7f1', + '#031015': '#f6f7f1', +}; + +let current: DisplayPreferences = { ...DEFAULT_DISPLAY }; +let initialized = false; +export function displayPreferences(): Readonly { return current; } +export function lightScene(): boolean { return current.basemap !== 'dark'; } +export function displayColor(color: string, light = lightScene()): string { + if (!light) return color; + const direct = LIGHT_COLORS[color.toLowerCase()]; + if (direct) return direct; + const rgba = /^rgba?\(\s*(\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?\s*\)$/i.exec(color); + if (!rgba) return color; + const hex = '#' + rgba.slice(1, 4).map((channel) => Number(channel).toString(16).padStart(2, '0')).join(''); + const mapped = LIGHT_COLORS[hex]; + if (!mapped) return color; + const value = Number.parseInt(mapped.slice(1), 16); + return `rgba(${(value >> 16) & 255}, ${(value >> 8) & 255}, ${value & 255}, ${rgba[4] ?? 1})`; +} +export function recolorPaint(value: unknown, light: boolean): unknown { + if (typeof value === 'string') return displayColor(value, light); + if (!Array.isArray(value)) return value; + if (light && value[0] === 'get' && typeof value[1] === 'string' && /^color(?:\d|$)/.test(value[1])) { + return ['match', value, ...Object.entries(LIGHT_COLORS).flat(), value]; + } + return value.map((item) => recolorPaint(item, light)); +} +export function packetPalette(light = lightScene()): typeof DARK_KINDS { return light ? LIGHT_KINDS : DARK_KINDS; } +export function canvasColorWithAlpha(color: string, alpha: number): string { + const resolved = displayColor(color); + const value = Number.parseInt(resolved.startsWith('#') ? resolved.slice(1) : 'ffffff', 16); + return `rgba(${(value >> 16) & 255},${(value >> 8) & 255},${value & 255},${Math.max(0, Math.min(1, alpha))})`; +} +export function lineDash(width = current.width): number[] { + return current.pattern === 'dashed' ? [width * 4, width * 3] : current.pattern === 'dotted' ? [0.01, width * 2.7] : []; +} +export function residueLifetime(): number { return current.residueSeconds * 1000; } +export function displayResidueAge(age: number): number { return age * 45_000 / Math.max(1, residueLifetime()); } + +export function normalizeDisplay(value: unknown): DisplayPreferences { + const v = value && typeof value === 'object' ? value as Partial : {}; + const amount = (key: keyof DisplayPreferences, min: number, max: number): number => { + const raw = v[key]; + return typeof raw === 'number' && Number.isFinite(raw) ? Math.max(min, Math.min(max, raw)) : Number(DEFAULT_DISPLAY[key]); + }; + return { + basemap: v.basemap === 'light' || v.basemap === 'streets' ? v.basemap : 'dark', + theme: v.theme === 'light' || v.theme === 'dark' ? v.theme : 'map', + preset: v.preset === 'custom' || (typeof v.preset === 'string' && Object.hasOwn(ROUTE_PRESETS, v.preset)) ? v.preset as RoutePreset : 'crisp', + pattern: v.pattern === 'dashed' || v.pattern === 'dotted' ? v.pattern : 'solid', + width: amount('width', 1, 5), opacity: amount('opacity', 0.2, 1), glow: amount('glow', 0, 1), + packetSize: amount('packetSize', 0.75, 2), trailLength: amount('trailLength', 0.5, 2), residueSeconds: amount('residueSeconds', 0, 45), + }; +} +export function loadDisplayPreferences(storage: Pick): DisplayPreferences { + try { + const saved = storage.getItem(DISPLAY_STORAGE_KEY); + if (saved) return normalizeDisplay(JSON.parse(saved)); + const legacy = JSON.parse(storage.getItem(UI_STORAGE_KEY) ?? 'null'); + return normalizeDisplay({ ...DEFAULT_DISPLAY, ...legacy, opacity: legacy?.routeOpacity ?? DEFAULT_DISPLAY.opacity }); + } catch { return { ...DEFAULT_DISPLAY }; } +} +export function initializeDisplay(): void { + if (initialized) return; + initialized = true; + try { current = loadDisplayPreferences(localStorage); } catch { /* Browser storage is optional. */ } + applyDisplayChrome(); + window.addEventListener('storage', (event) => { + if (event.key !== DISPLAY_STORAGE_KEY && event.key !== null) return; + try { current = loadDisplayPreferences(localStorage); } catch { current = { ...DEFAULT_DISPLAY }; } + applyDisplayChrome(); + window.dispatchEvent(new Event(DISPLAY_EVENT)); + }); +} +export function updateDisplay(update: Partial): void { + current = normalizeDisplay({ ...current, ...update }); + try { localStorage.setItem(DISPLAY_STORAGE_KEY, JSON.stringify(current)); } catch { /* Browser storage is optional. */ } + applyDisplayChrome(); + window.dispatchEvent(new Event(DISPLAY_EVENT)); +} +export function applyDisplayChrome(): void { + const root = document.documentElement; + root.dataset.theme = current.theme === 'map' ? (lightScene() ? 'light' : 'dark') : current.theme; + root.dataset.basemap = current.basemap; + root.dataset.routePreset = current.preset; + for (const [kind, color] of Object.entries(packetPalette())) root.style.setProperty(`--kind-${kind.toLowerCase()}`, color); + for (const [kind, color] of Object.entries(packetPalette(root.dataset.theme === 'light'))) root.style.setProperty(`--ui-kind-${kind.toLowerCase()}`, color); + root.style.setProperty('--scene-background', current.basemap === 'dark' ? '#071319' : current.basemap === 'streets' ? '#f0eadb' : '#eef1ee'); + for (const el of document.querySelectorAll('[data-kind]')) { + const color = packetPalette(root.dataset.theme === 'light')[el.dataset.kind as keyof typeof DARK_KINDS]; + if (color) el.style.setProperty('--route-color', color); + } +} diff --git a/web/src/main.ts b/web/src/main.ts index 904dac8..06b8b6c 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,4 +1,8 @@ import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from './soundScenes'; +import { mountDisplayControls } from './displayControls'; +import { loadBasemapConfiguration } from './basemap'; +import { attachMapNotice } from './mapNotice'; +import { DEFAULT_DISPLAY, DISPLAY_EVENT, displayPreferences, initializeDisplay, updateDisplay, applyDisplayChrome } from './displayPreferences'; import 'maplibre-gl/dist/maplibre-gl.css'; import './styles.css'; import { fetchState, LiveFeed } from './api'; @@ -41,6 +45,9 @@ const heatmapButton = required('heatmap-button'); const clustersButton = required('clusters-button'); const hillshadeButton = required('hillshade-button'); const terrainButton = required('terrain-button'); +const buildingsButton = required('buildings-button'); +const cameraPitch = required('camera-pitch'); +const terrainHeight = required('terrain-height'); const soundButton = required('sound-button'); const soundControl = required('sound-button').parentElement as HTMLElement; const soundPanel = required('sound-panel'); @@ -83,7 +90,9 @@ const followCountdown = required('follow-countdown'); const followProgress = required('follow-progress'); const followPause = required('follow-pause'); -let uiPreferences: UiPreferences = loadUiPreferences(localStorage); +initializeDisplay(); +let uiPreferences: UiPreferences = { ...loadUiPreferences(localStorage), basemap: displayPreferences().basemap, theme: displayPreferences().theme, routeOpacity: displayPreferences().opacity }; +mountDisplayControls(layersPanel); let legendExpanded = uiPreferences.legendExpanded; let lastTrafficPulseAt = -Infinity; let soundPulseTimer: number | undefined; @@ -212,6 +221,8 @@ async function start(): Promise { let followTimer: number | undefined; let pauseLiveFollow = (): void => {}; try { + let configUnavailable = false; + await loadBasemapConfiguration().catch(() => { configUnavailable = true; }); // Construct MapLibre before the state request so the basemap can paint while // the initial snapshot is in flight. const liveMap = new LiveMap( @@ -228,6 +239,8 @@ async function start(): Promise { }, ); mapView = liveMap; + const showMapNotice = attachMapNotice(liveMap.map, mapElement); + if (configUnavailable) showMapNotice('Map settings could not load. Showing the default map.'); const packetCanvas = required('packet-canvas'); const liveAnimator = new PacketAnimator(liveMap.map, packetCanvas); animator = liveAnimator; @@ -266,14 +279,20 @@ async function start(): Promise { liveMap.setHillshadeVisible(visible); persistUiPreference({ hillshade: visible }); }); + wireLayerToggle(buildingsButton, uiPreferences.buildings, 'buildings', (visible) => { + liveMap.setBuildingsVisible(visible); + persistUiPreference({ buildings: visible }); + }); wireLayerToggle(terrainButton, uiPreferences.terrain3D, '3D terrain', (visible) => { if (visible && !uiPreferences.hillshade) hillshadeButton.click(); + if (visible && !uiPreferences.buildings) buildingsButton.click(); liveMap.setTerrain3D(visible); persistUiPreference({ terrain3D: visible }); }); const applyAppearance = (): void => { applyAppearanceChrome(); liveMap.setAppearance(uiPreferences); + liveAnimator.refreshAppearance(); liveAnimator.setPaused(document.hidden || !uiPreferences.livePackets); routeSonifier.setPaused(document.hidden || !uiPreferences.livePackets); packetCanvas.hidden = !uiPreferences.livePackets; @@ -290,24 +309,42 @@ async function start(): Promise { applyAppearance(); }); basemapStyle.addEventListener('change', () => { - persistUiPreference({ basemap: basemapStyle.value as BasemapStyle }); - applyAppearance(); + updateDisplay({ basemap: basemapStyle.value as BasemapStyle }); }); interfaceTheme.addEventListener('change', () => { - persistUiPreference({ theme: interfaceTheme.value as InterfaceTheme }); - applyAppearance(); + updateDisplay({ theme: interfaceTheme.value as InterfaceTheme }); }); routeOpacity.addEventListener('input', () => { - persistUiPreference({ routeOpacity: Number(routeOpacity.value) / 100 }); + updateDisplay({ opacity: Number(routeOpacity.value) / 100, preset: 'custom' }); + }); + window.addEventListener(DISPLAY_EVENT, () => { + const display = displayPreferences(); + persistUiPreference({ basemap: display.basemap, theme: display.theme, routeOpacity: display.opacity }); applyAppearance(); + renderRouteLegend(routeLegend); }); terrainRelief.addEventListener('input', () => { persistUiPreference({ relief: Number(terrainRelief.value) / 100 }); applyAppearance(); }); + cameraPitch.addEventListener('input', () => { + pauseLiveFollow(); + persistUiPreference({ terrainPitch: Number(cameraPitch.value) }); + liveMap.setCameraPitch(uiPreferences.terrainPitch); + applyAppearanceChrome(); + }); + terrainHeight.addEventListener('input', () => { + persistUiPreference({ terrainExaggeration: Number(terrainHeight.value) }); + applyAppearance(); + }); + required('north-button').addEventListener('click', () => { + pauseLiveFollow(); + liveMap.resetNorth(); + persistUiPreference({ terrainBearing: 0 }); + }); required('reset-appearance').addEventListener('click', () => { const toggles = [ - ['terrain-button', 'terrain3D'], ['hillshade-button', 'hillshade'], ['routes-button', 'routes'], + ['terrain-button', 'terrain3D'], ['hillshade-button', 'hillshade'], ['buildings-button', 'buildings'], ['routes-button', 'routes'], ['heatmap-button', 'heatmap'], ['clusters-button', 'clusters'], ...extraLayers.map(([id, key]) => [id, key] as const), ] as const; @@ -316,7 +353,8 @@ async function start(): Promise { if (button && uiPreferences[key] !== DEFAULT_UI_PREFERENCES[key]) button.click(); } if (legendExpanded) legendToggle.click(); - persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, relief: 0.75, routeWindow: 'auto' }); + persistUiPreference({ basemap: 'dark', theme: 'map', routeOpacity: 0.8, relief: 0.75, routeWindow: 'auto', terrainExaggeration: 1, terrainPitch: 50, terrainBearing: 0 }); + updateDisplay({ ...DEFAULT_DISPLAY }); routeWindow.value = 'auto'; liveMap.setRouteWindow('auto'); applyAppearance(); @@ -498,6 +536,10 @@ async function start(): Promise { liveMap.map.on('moveend', () => { if (!liveFollow) saveView(localStorage, activeViewClass, liveMap.view()); + if (uiPreferences.terrain3D && activeViewClass === 'desktop') { + persistUiPreference({ terrainPitch: liveMap.map.getPitch(), terrainBearing: liveMap.map.getBearing() }); + applyAppearanceChrome(); + } }); let resizeTimer: number | undefined; window.addEventListener('resize', () => { @@ -693,13 +735,15 @@ function required(id: string): T { } function applyAppearanceChrome(): void { - const theme = uiPreferences.theme === 'map' ? (uiPreferences.basemap === 'dark' ? 'dark' : 'light') : uiPreferences.theme; - document.documentElement.dataset.theme = theme; - document.documentElement.dataset.basemap = uiPreferences.basemap; + applyDisplayChrome(); basemapStyle.value = uiPreferences.basemap; interfaceTheme.value = uiPreferences.theme; routeOpacity.value = String(Math.round(uiPreferences.routeOpacity * 100)); terrainRelief.value = String(Math.round(uiPreferences.relief * 100)); + cameraPitch.value = String(Math.round(uiPreferences.terrainPitch)); + terrainHeight.value = String(uiPreferences.terrainExaggeration); + required('camera-pitch-output').value = `${cameraPitch.value}°`; + required('terrain-height-output').value = `${terrainHeight.value}×`; required('route-opacity-output').value = `${routeOpacity.value}%`; required('terrain-relief-output').value = `${terrainRelief.value}%`; } @@ -742,7 +786,7 @@ function renderRouteLegend(container: HTMLElement): void { const swatch = document.createElement('i'); swatch.className = 'route-legend-swatch'; swatch.setAttribute('aria-hidden', 'true'); - swatch.style.setProperty('--route-color', PACKET_KIND_COLORS[item.kind]); + swatch.style.setProperty('--route-color', `var(--ui-kind-${item.kind.toLowerCase()}, ${PACKET_KIND_COLORS[item.kind]})`); const label = document.createElement('span'); label.className = 'route-legend-label'; diff --git a/web/src/map.ts b/web/src/map.ts index 6c79ce9..e3d8857 100644 --- a/web/src/map.ts +++ b/web/src/map.ts @@ -13,9 +13,9 @@ import type { Point } from 'geojson'; import { colorWithAlpha as alphaColor } from './trafficVisuals'; -import { cartoVectorRequestURL, cartoVectorStyle } from './basemap'; +import { basemapFontStack, basemapProvider, cartoVectorRequestURL, cartoVectorStyle } from './basemap'; import { longitudeDelta } from './worldGeometry'; -import { DEFAULT_UI_PREFERENCES, type UiPreferences } from './preferences'; +import { DEFAULT_UI_PREFERENCES, viewClass, type UiPreferences } from './preferences'; import { FOLLOW_DWELL_MS, followEndpoints } from './liveFollow'; import { buildNodeInspectorModel, @@ -25,6 +25,9 @@ import { type NodeSearchResult, } from './nodeInspector'; import { HistoricalRouteLayer, ROUTE_WEBGL_LAYER_ID } from './routeLayer'; +import { applyOverlayPalette } from './overlayPalette'; +import { updateBuildings } from './buildings'; +import { displayPreferences } from './displayPreferences'; import { isRecentNeighborRoute, recentNeighborRoutes } from './routeFocus'; import type { MapChanges } from './state'; import { @@ -100,7 +103,7 @@ const UNCLUSTERED_NODE_LAYER_IDS = [ NODE_HIT_LAYER_ID, ] as const; const NODE_BASE_FILTER = ['!', ['has', 'point_count']] as ActiveLayerFilter; -const LOCAL_FONTS = ['Open Sans Regular']; + export const HEAT_RENDER_BUDGET = 600; const ROUTE_REPRESENTATION_EXACT = 'exact'; const ROUTE_REPRESENTATION_NATIONAL = 'national'; @@ -201,6 +204,7 @@ export class LiveMap { private followZoom = DEFAULT_ZOOM; private followedPacket?: PacketView; private appearance: UiPreferences = { ...DEFAULT_UI_PREFERENCES }; + private readonly originalOverlayColors = new Map(); private directorTimer?: number; private readonly reducedMotion = prefersReducedMotion(); private freshnessTimer: number; @@ -239,12 +243,12 @@ export class LiveMap { center: DEFAULT_CENTER, zoom: DEFAULT_ZOOM, minZoom: 1, - maxZoom: 16, + maxZoom: 18, attributionControl: false, pitchWithRotate: true, dragRotate: true, touchPitch: true, - maxPitch: 75, + maxPitch: 65, cooperativeGestures: false, reduceMotion: this.reducedMotion, pixelRatio: mapPixelRatio(window.devicePixelRatio, lowPower), @@ -257,6 +261,12 @@ export class LiveMap { this.updateRouteRepresentation(); this.map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right'); this.map.on('load', () => this.installLayers()); + this.map.on('moveend', () => { + if (this.terrain3D && viewClass() === 'desktop') { + this.appearance.terrainPitch = this.map.getPitch(); + this.appearance.terrainBearing = this.map.getBearing(); + } + }); this.map.on('zoom', this.updateRouteRepresentation); this.map.on('zoomend', this.handleZoomEnd); this.map.on('resize', this.handleInspectorResize); @@ -692,8 +702,8 @@ export class LiveMap { } const light = preferences.basemap !== 'dark'; this.historicalRouteLayer.setLightBackground(light); - this.map.setPaintProperty(NODE_LABEL_LAYER_ID, 'text-color', light ? '#27464c' : '#c8d9df'); - this.map.setPaintProperty(NODE_LABEL_LAYER_ID, 'text-halo-color', light ? '#f4f6ee' : '#07121a'); + applyOverlayPalette(this.map, this.originalOverlayColors, light); + if (this.terrain3D) this.updateSky(); } if (force || previous.basemap !== preferences.basemap || previous.mapLabels !== preferences.mapLabels || previous.roads !== preferences.roads) { for (const layer of cartoVectorStyle().layers) { @@ -703,9 +713,15 @@ export class LiveMap { } this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'visibility', preferences.nodeLabels ? 'visible' : 'none'); this.historicalRouteLayer.setOpacity(preferences.routeOpacity); + this.historicalRouteLayer.refreshAppearance(); + this.container.dataset.routePreset = displayPreferences().preset; + if (this.terrain3D && previous.terrainExaggeration !== preferences.terrainExaggeration) { + this.map.setTerrain({ source: TERRAIN_SOURCE_ID, exaggeration: preferences.terrainExaggeration }); + } + this.updateBuildingLayer(); this.map.setPaintProperty(ROUTE_TERRAIN_LAYER_ID, 'line-opacity', ['*', ['get', 'opacity'], preferences.routeOpacity]); if (this.map.getLayer(HILLSHADE_LAYER_ID)) { - this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-exaggeration', preferences.relief); + this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-exaggeration', this.hillshadeStrength()); const light = preferences.basemap !== 'dark'; this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-shadow-color', light ? 'rgba(47, 64, 61, 0.55)' : 'rgba(2, 9, 18, 0.86)'); this.map.setPaintProperty(HILLSHADE_LAYER_ID, 'hillshade-highlight-color', light ? 'rgba(255, 255, 244, 0.6)' : 'rgba(177, 211, 175, 0.64)'); @@ -740,7 +756,7 @@ export class LiveMap { const started = performance.now(); this.routesVisible = visible; this.container.dataset.routesVisible = String(visible); - this.historicalRouteLayer.setVisible(visible && !this.terrain3D); + this.historicalRouteLayer.setVisible(visible); if (!this.layersReady) { this.container.dataset.routeToggleApplyMs = (performance.now() - started).toFixed(1); return; @@ -800,7 +816,10 @@ export class LiveMap { this.container.dataset.terrain3d = String(enabled); if (!this.layersReady) return; if (enabled) this.ensureTerrainLayers(); - this.map.setTerrain(enabled ? { source: TERRAIN_SOURCE_ID, exaggeration: 1.35 } : null); + this.map.setTerrain(enabled ? { source: TERRAIN_SOURCE_ID, exaggeration: this.appearance.terrainExaggeration } : null); + this.updateSky(); + this.map.setLight({ anchor: 'map', color: '#ffffff', intensity: 0.3, position: [1.5, 195, 50] }); + this.updateBuildingLayer(); this.updateTerrainRoutes(true); this.setTerrainGestures(enabled); const camera = this.cameraOrientation(); @@ -810,6 +829,39 @@ export class LiveMap { this.markRendering(); } + setBuildingsVisible(visible: boolean): void { + this.appearance.buildings = visible; + this.updateBuildingLayer(); + } + + setCameraPitch(pitch: number): void { + this.appearance.terrainPitch = clamp(pitch, 0, 65); + if (this.terrain3D) this.map.jumpTo({ pitch: this.appearance.terrainPitch }); + } + + resetNorth(): void { + this.appearance.terrainBearing = 0; + if (this.reducedMotion) this.map.jumpTo({ bearing: 0 }); + else this.map.easeTo({ bearing: 0, duration: 400 }); + } + + private updateBuildingLayer(): void { + this.container.dataset.buildingsVisible = String(this.appearance.buildings); + if (!this.layersReady) return; + updateBuildings(this.map, this.appearance.buildings, this.terrain3D, viewClass() === 'desktop', this.appearance.basemap !== 'dark', basemapProvider() === 'openfreemap' ? 'carto' : undefined); + } + + private updateSky(): void { + const light = this.appearance.basemap !== 'dark'; + this.map.setSky(this.terrain3D ? { 'sky-color': light ? '#cfe1e8' : '#14242e', 'horizon-color': light ? '#eef1ee' : '#29424a', 'fog-color': light ? '#dce7e5' : '#14242e', 'horizon-fog-blend': 0.15 } : {}); + } + + + private hillshadeStrength(): ExpressionSpecification { + const strength = this.appearance.relief; + return ['interpolate', ['linear'], ['zoom'], 3, strength * 0.8, 10, strength * 0.85, 14, strength * 0.42, 18, strength * 0.28]; + } + setRouteWindow(window: RouteWindow): void { if (this.routeWindow === window) return; const started = performance.now(); @@ -871,7 +923,7 @@ export class LiveMap { 'hillshade-method': 'multidirectional', 'hillshade-illumination-direction': [270, 315, 0, 45], 'hillshade-illumination-altitude': [35, 35, 35, 35], - 'hillshade-exaggeration': this.appearance.relief, + 'hillshade-exaggeration': this.hillshadeStrength(), 'hillshade-shadow-color': this.appearance.basemap === 'dark' ? 'rgba(2, 9, 18, 0.86)' : 'rgba(47, 64, 61, 0.55)', 'hillshade-highlight-color': this.appearance.basemap === 'dark' ? 'rgba(177, 211, 175, 0.64)' : 'rgba(255, 255, 244, 0.6)', 'hillshade-accent-color': 'rgba(47, 86, 75, 0.54)' @@ -895,7 +947,7 @@ export class LiveMap { } private cameraOrientation(): { bearing: number; pitch: number } { - return this.terrain3D ? { bearing: -12, pitch: 52 } : { bearing: 0, pitch: 0 }; + return this.terrain3D ? { bearing: this.appearance.terrainBearing, pitch: this.appearance.terrainPitch } : { bearing: 0, pitch: 0 }; } private updateTerrainRoutes(refreshData = false): void { @@ -904,8 +956,8 @@ export class LiveMap { this.container.dataset.routeSurface = this.terrain3D ? 'terrain' : 'flat'; if (!visible && !this.terrainRoutesVisible) return; this.terrainRoutesVisible = visible; - this.historicalRouteLayer.setVisible(this.routesVisible && !this.terrain3D); - this.map.setLayoutProperty(ROUTE_TERRAIN_LAYER_ID, 'visibility', visible ? 'visible' : 'none'); + this.historicalRouteLayer.setVisible(this.routesVisible); + this.map.setLayoutProperty(ROUTE_TERRAIN_LAYER_ID, 'visibility', 'none'); this.map.setFilter(ROUTE_TERRAIN_LAYER_ID, ['<=', ['get', 'windowBand'], routeWindowBand(this.effectiveRouteAgeMS())]); if (refreshData) { (this.map.getSource(ROUTE_DETAIL_SOURCE_ID) as GeoJSONSource).setData( @@ -1020,7 +1072,7 @@ export class LiveMap { this.map.addSource(ROUTE_FOCUS_SOURCE_ID, { type: 'geojson', data: EMPTY_LINES, maxzoom: 16 }); this.applyRouteTimeState(Date.now(), true); const exactVisibility = this.routesVisible ? 'visible' : 'none'; - this.historicalRouteLayer.setVisible(this.routesVisible && !this.terrain3D); + this.historicalRouteLayer.setVisible(this.routesVisible); this.historicalRouteLayer.setMaximumBand(routeWindowBand(this.effectiveRouteAgeMS())); this.map.addLayer(this.historicalRouteLayer); this.map.addLayer({ @@ -1151,7 +1203,7 @@ export class LiveMap { filter: ['has', 'point_count'], layout: { 'text-field': ['get', 'point_count_abbreviated'], - 'text-font': LOCAL_FONTS, + 'text-font': basemapFontStack(), 'text-size': ['interpolate', ['linear'], ['zoom'], 3, 8.5, DETAIL_ZOOM, 10.5] }, paint: { @@ -1256,7 +1308,7 @@ export class LiveMap { filter: NODE_BASE_FILTER, layout: { 'text-field': ['get', 'mapLabel'], - 'text-font': LOCAL_FONTS, + 'text-font': basemapFontStack(), 'text-size': ['interpolate', ['linear'], ['zoom'], DETAIL_ZOOM, 8.6, 9, 9.8, 12, 11.2, 16, 12.4], 'text-variable-anchor': ['top', 'bottom', 'left', 'right'], 'text-radial-offset': 0.82, @@ -1274,9 +1326,9 @@ export class LiveMap { 'text-halo-blur': 0.3, 'text-opacity': [ 'interpolate', ['linear'], ['zoom'], - DETAIL_ZOOM, ['*', ['get', 'opacity'], 0.42], - 9.5, ['*', ['get', 'opacity'], 0.82], - 11, ['get', 'opacity'] + DETAIL_ZOOM, 0.78, + 9.5, 0.9, + 11, 0.96 ] } }); @@ -1314,6 +1366,10 @@ export class LiveMap { if (this.clusterFlashTimer === undefined) this.setHighlightedCluster(null); }); this.map.on('click', (event) => this.handleMapClick(event)); + this.map.on('mousemove', (event) => { + if (!this.terrain3D || !this.selectedNodeID) return; + if (!this.showRouteTooltip(event) && this.tooltip.dataset.kind === 'route' && !this.routeInspectionPinned) this.clearRouteInspection(); + }); this.map.on('movestart', () => { this.hideTooltip(); this.clearRouteInspection(); @@ -1492,9 +1548,10 @@ export class LiveMap { if (this.map.queryRenderedFeatures(event.point, { layers: [NODE_HIT_LAYER_ID] }).length > 0) return false; const feature = this.map.queryRenderedFeatures(event.point, { layers: [ROUTE_HIT_LAYER_ID] }) .find((candidate) => this.isSelectedRouteInspectable(String(candidate.properties?.id ?? candidate.id ?? ''))); - if (!feature) return false; - const properties = feature.properties ?? {}; - const route = this.routesByID.get(String(properties.id ?? feature.id ?? '')); + const terrainRouteID = this.historicalRouteLayer.pick(event.point, (id) => this.isSelectedRouteInspectable(id)); + if (!feature && !terrainRouteID) return false; + const properties = feature?.properties ?? {}; + const route = this.routesByID.get(terrainRouteID ?? String(properties.id ?? feature?.id ?? '')); if (!route) return false; const from = this.nodesByID.get(route.fromId); const to = this.nodesByID.get(route.toId); @@ -1744,7 +1801,7 @@ export class LiveMap { layer.setOpacity(this.appearance.routeOpacity); layer.setLightBackground(this.appearance.basemap !== 'dark'); layer.setRoutes([...this.routeDetailFeatures.values()]); - layer.setVisible(this.routesVisible && !this.terrain3D); + layer.setVisible(this.routesVisible); layer.setMaximumBand(routeWindowBand(this.effectiveRouteAgeMS())); this.historicalRouteLayer = layer; const before = this.map.getLayer(ROUTE_FOCUS_LAYER_IDS[0]) ? ROUTE_FOCUS_LAYER_IDS[0] : undefined; @@ -2609,6 +2666,7 @@ function roleColor(role: NodeV2['role']): string { return '#8794a6'; } + function freshness(timestamp: number, now: number): number { const age = Math.max(0, now - timestamp); if (age < 15 * 60_000) return 1; diff --git a/web/src/mapNotice.ts b/web/src/mapNotice.ts new file mode 100644 index 0000000..dae4e6f --- /dev/null +++ b/web/src/mapNotice.ts @@ -0,0 +1,22 @@ +import type { Map as MapLibreMap } from 'maplibre-gl'; + +export function attachMapNotice(map: MapLibreMap, container: HTMLElement): (message: string) => void { + const notice = document.createElement('aside'); + notice.id = 'map-notice'; + notice.className = 'map-notice glass'; + notice.setAttribute('role', 'status'); + notice.hidden = true; + const text = document.createElement('span'); + const retry = document.createElement('button'); + retry.type = 'button'; retry.textContent = 'Retry'; + retry.addEventListener('click', () => location.reload()); + const close = document.createElement('button'); + close.type = 'button'; close.textContent = '×'; close.setAttribute('aria-label', 'Dismiss map notice'); + close.addEventListener('click', () => { notice.hidden = true; }); + notice.append(text, retry, close); + container.parentElement?.append(notice); + const show = (message: string): void => { text.textContent = message; notice.hidden = false; }; + // Never forward provider error text: tile URLs can include browser credentials. + map.on('error', () => show('Some map details could not load. Refresh to retry.')); + return show; +} diff --git a/web/src/netgraph/main.ts b/web/src/netgraph/main.ts index 0845077..79ad945 100644 --- a/web/src/netgraph/main.ts +++ b/web/src/netgraph/main.ts @@ -1,4 +1,6 @@ import { populateSoundScenes, syncSoundScene, SOUND_SCENES } from '../soundScenes'; +import { initializeDisplay } from '../displayPreferences'; +import { mountNetgraphDisplay } from '../displayControls'; import './styles.css'; import { fetchState, LiveFeed } from '../api'; import { RouteSonifier, type SoundScene, type SoundStatus } from '../audio'; @@ -11,6 +13,8 @@ import { type NetgraphWindow } from './layout'; import { NetgraphRenderer } from './renderer'; const SETTINGS_KEY = 'cartolite-server:netgraph:v1'; +initializeDisplay(); +mountNetgraphDisplay(); const app = required('netgraph-app'); const stage = required('netgraph-stage'); const graphCanvas = required('graph-canvas'); diff --git a/web/src/netgraph/renderer.ts b/web/src/netgraph/renderer.ts index 0ebc9d2..b6462b4 100644 --- a/web/src/netgraph/renderer.ts +++ b/web/src/netgraph/renderer.ts @@ -1,9 +1,8 @@ -import { colorWithAlpha } from '../trafficVisuals'; +import { canvasColorWithAlpha as colorWithAlpha, DISPLAY_EVENT, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime } from '../displayPreferences'; import type { ViewportProjector } from '../audio'; import { DESTINATION_BLOOM_MS, RELAY_SPARK_MS, - RESIDUE_MS, SOURCE_IGNITION_MS, capNewest, interpolateScreenPoint, @@ -204,8 +203,20 @@ export class NetgraphRenderer implements ViewportProjector { window.addEventListener('resize', this.handleResize); } this.handleResize(); + window.addEventListener(DISPLAY_EVENT, this.refreshAppearance); } + private refreshAppearance = (): void => { + this.glowSprites.clear(); + this.regionLabelSprites.clear(); + this.nodesDirty = true; + this.residueDirty = true; + this.residueProjectionDirty = true; + this.stage.dataset.routePreset = displayPreferences().preset; + this.requestStaticDraw(); + this.requestMotionFrame(); + }; + render(state: Readonly, changes: MapChanges | null): void { if (!changes) return; const started = performance.now(); @@ -464,6 +475,7 @@ export class NetgraphRenderer implements ViewportProjector { } destroy(): void { + window.removeEventListener(DISPLAY_EVENT, this.refreshAppearance); this.clearPointers(); this.stage.removeEventListener('pointerdown', this.handlePointerDown); this.stage.removeEventListener('pointermove', this.handlePointerMove); @@ -628,17 +640,17 @@ export class NetgraphRenderer implements ViewportProjector { context.lineWidth = 0.8; context.stroke(); roundRect(context, x, y, width, height, 7); - context.fillStyle = 'rgba(4, 17, 22, 0.88)'; + context.fillStyle = lightScene() ? 'rgba(247, 249, 241, 0.95)' : 'rgba(4, 17, 22, 0.88)'; context.fill(); context.strokeStyle = 'rgba(119, 225, 219, 0.2)'; context.stroke(); context.textAlign = 'center'; context.textBaseline = 'middle'; context.font = '750 11px Inter, ui-sans-serif, system-ui, sans-serif'; - context.fillStyle = 'rgba(220, 250, 247, 0.92)'; + context.fillStyle = lightScene() ? '#24474d' : 'rgba(220, 250, 247, 0.92)'; context.fillText(title, labelCenterX, y + 11); context.font = '600 8px Inter, ui-sans-serif, system-ui, sans-serif'; - context.fillStyle = 'rgba(116, 158, 166, 0.92)'; + context.fillStyle = lightScene() ? '#3e5c61' : 'rgba(116, 158, 166, 0.92)'; context.fillText(detail, labelCenterX, y + 23); } context.restore(); @@ -673,21 +685,21 @@ export class NetgraphRenderer implements ViewportProjector { context.save(); context.lineCap = 'round'; for (const group of groups.values()) { - context.setLineDash(group.interArea ? [3, 5] : []); + context.setLineDash(lineDash()); context.beginPath(); for (const route of group.routes) this.appendRoute(context, route); - context.strokeStyle = colorWithAlpha(PACKET_KIND_COLORS[group.kind], selected ? 0.005 : group.interArea ? 0.09 : 0.16); - context.lineWidth = selected ? 0.6 : group.interArea ? 0.7 : 0.8; + context.strokeStyle = colorWithAlpha(PACKET_KIND_COLORS[group.kind], (selected ? 0.1 : lightScene() ? 0.95 : 0.5) * displayPreferences().opacity); + context.lineWidth = displayPreferences().width; context.stroke(); } for (const group of recentGroups.values()) { - context.setLineDash(group.interArea ? [4, 6] : []); + context.setLineDash(lineDash()); context.beginPath(); for (const route of group.routes) this.appendRoute(context, route); const ageStrength = 1 - (group.ageBucket + 0.5) / 4; context.strokeStyle = colorWithAlpha(PACKET_KIND_COLORS[group.kind], ageStrength * (selected ? 0.08 : group.interArea ? 0.19 : 0.3)); - context.lineWidth = (group.interArea ? 0.72 : 0.9) + group.intensity * 0.34; + context.lineWidth = displayPreferences().width + group.intensity * 0.12; context.stroke(); } @@ -699,13 +711,13 @@ export class NetgraphRenderer implements ViewportProjector { const color = PACKET_KIND_COLORS[normalizePacketKind(route.lastKind)]; context.beginPath(); this.appendRoute(context, route); - context.strokeStyle = colorWithAlpha(color, 0.18); + context.strokeStyle = colorWithAlpha(color, 0.18 * displayPreferences().glow); context.lineWidth = 7; context.stroke(); context.beginPath(); this.appendRoute(context, route); context.strokeStyle = colorWithAlpha(color, 0.86); - context.lineWidth = 2.1; + context.lineWidth = displayPreferences().width + 0.5; context.stroke(); } } @@ -811,10 +823,10 @@ export class NetgraphRenderer implements ViewportProjector { const width = context.measureText(label).width; const x = point.x + radius + 6; const y = point.y; - context.fillStyle = 'rgba(4, 14, 19, 0.78)'; + context.fillStyle = lightScene() ? 'rgba(247, 249, 241, 0.9)' : 'rgba(4, 14, 19, 0.78)'; roundRect(context, x - 3, y - 8, width + 6, 16, 5); context.fill(); - context.fillStyle = node.id === this.selectedNodeID ? '#f2ffff' : 'rgba(205, 229, 234, 0.78)'; + context.fillStyle = lightScene() ? '#24474d' : node.id === this.selectedNodeID ? '#f2ffff' : 'rgba(205, 229, 234, 0.9)'; context.fillText(label, x, y + 0.5); } context.restore(); @@ -864,7 +876,7 @@ export class NetgraphRenderer implements ViewportProjector { this.activeRoutes = this.activeRoutes.filter((route) => now - route.started <= route.duration + DESTINATION_BLOOM_MS); this.observerWakes = this.observerWakes.filter((wake) => now - wake.started <= 6_000); const residueCount = this.residue.length; - this.residue = this.residue.filter((item) => now - item.addedAt < RESIDUE_MS); + this.residue = this.residue.filter((item) => now - item.addedAt < residueLifetime()); if (residueCount !== this.residue.length) this.residueProjectionDirty = true; this.regionActivityCues = this.regionActivityCues.filter((cue) => now < cue.startedAt + cue.duration); this.drawResidue(context, now); @@ -984,7 +996,7 @@ export class NetgraphRenderer implements ViewportProjector { const to = this.screenPoint(residue.toId); if (!segmentNearViewport(from, to, this.width, this.height, 12)) continue; const age = now - residue.addedAt; - const style = residueStyle(age); + const style = residueStyle(displayResidueAge(age)); for (let index = 0; index < sparkleCount; index += 1) { const progress = residueSparkleProgress(residue.routeId, age, index); const spark = interpolateScreenPoint(from, to, progress); @@ -1005,11 +1017,11 @@ export class NetgraphRenderer implements ViewportProjector { const to = this.screenPoint(residue.toId); if (!segmentNearViewport(from, to, this.width, this.height, 12)) continue; const age = now - residue.addedAt; - const style = residueStyle(age); + const style = residueStyle(displayResidueAge(age)); context.beginPath(); context.moveTo(from.x, from.y); context.lineTo(to.x, to.y); - context.strokeStyle = colorWithAlpha(residue.color, style.bloomOpacity); + context.strokeStyle = colorWithAlpha(residue.color, style.bloomOpacity * displayPreferences().glow); context.lineWidth = style.bloomWidth; context.stroke(); context.beginPath(); @@ -1056,7 +1068,7 @@ export class NetgraphRenderer implements ViewportProjector { const head = interpolateScreenPoint(from, to, easeInOut(motion.localProgress)); const emphasis = this.selectedNodeID && active.packet.segments.some((hop) => hop.from.id === this.selectedNodeID || hop.to.id === this.selectedNodeID) ? 1.3 : 1; if (emphasis > 1) this.stage.dataset.focusedPacketEmphasis = String(emphasis); - const trail = packetTrail(from, head, clamp(Math.hypot(to.x - from.x, to.y - from.y) * 0.28, 18, 68)); + const trail = packetTrail(from, head, clamp(Math.hypot(to.x - from.x, to.y - from.y) * 0.28, 18, 68) * displayPreferences().trailLength); if (this.quality.mode === 'low') { context.beginPath(); context.moveTo(trail.tail.x, trail.tail.y); @@ -1065,8 +1077,8 @@ export class NetgraphRenderer implements ViewportProjector { context.lineWidth = (active.longHaul ? 3.2 : 2.2) * emphasis; context.stroke(); context.beginPath(); - context.arc(head.x, head.y, 3.25 * emphasis, 0, Math.PI * 2); - context.fillStyle = active.color; + context.arc(head.x, head.y, 3.25 * emphasis * displayPreferences().packetSize, 0, Math.PI * 2); + context.fillStyle = displayColor(active.color); context.fill(); return; } @@ -1089,7 +1101,7 @@ export class NetgraphRenderer implements ViewportProjector { context.lineTo(head.x - px, head.y - py); context.closePath(); context.fillStyle = gradient; - context.globalAlpha = 0.3; + context.globalAlpha = 0.3 * displayPreferences().glow; context.fill(); context.globalAlpha = 1; context.beginPath(); @@ -1108,10 +1120,12 @@ export class NetgraphRenderer implements ViewportProjector { context.fill(); } const glowRadius = (active.longHaul ? 15 : active.crossRegion ? 12 : 10) * emphasis; + context.globalAlpha = displayPreferences().glow; context.drawImage(this.glowSprite(active.color), head.x - glowRadius, head.y - glowRadius, glowRadius * 2, glowRadius * 2); + context.globalAlpha = 1; context.beginPath(); - context.arc(head.x, head.y, 3.25 * emphasis, 0, Math.PI * 2); - context.fillStyle = active.color; + context.arc(head.x, head.y, 3.25 * emphasis * displayPreferences().packetSize, 0, Math.PI * 2); + context.fillStyle = displayColor(active.color); context.fill(); if (this.quality.mode === 'full') this.drawSignature(context, head, from, to, active.color, active.signature, age); context.restore(); @@ -1235,7 +1249,7 @@ export class NetgraphRenderer implements ViewportProjector { if (this.residueCleanupTimer) return; const expiries = [ ...this.activeRoutes.map((route) => route.started + route.duration + DESTINATION_BLOOM_MS), - ...this.residue.map((item) => item.addedAt + RESIDUE_MS), + ...this.residue.map((item) => item.addedAt + residueLifetime()), ...this.observerWakes.map((wake) => wake.started + 6_000), ...this.regionActivityCues.map((cue) => cue.startedAt + cue.duration), ...this.regionActivityCues.filter((cue) => cue.startedAt > now).map((cue) => cue.startedAt), @@ -1309,7 +1323,7 @@ export class NetgraphRenderer implements ViewportProjector { canvas.height = Math.ceil(30 * this.dpr); context.scale(this.dpr, this.dpr); roundRect(context, 1, 1, width - 2, 28, 8); - context.fillStyle = 'rgba(3, 13, 18, 0.96)'; + context.fillStyle = lightScene() ? 'rgba(247, 249, 241, 0.96)' : 'rgba(3, 13, 18, 0.96)'; context.fill(); context.strokeStyle = colorWithAlpha(color, 0.92); context.lineWidth = 1.5; @@ -1322,7 +1336,7 @@ export class NetgraphRenderer implements ViewportProjector { context.stroke(); context.textBaseline = 'middle'; context.font = '800 11px Inter, ui-sans-serif, system-ui, sans-serif'; - context.fillStyle = color; + context.fillStyle = displayColor(color); context.fillText(title, 9, 15); context.textAlign = 'center'; context.font = '850 9px Inter, ui-sans-serif, system-ui, sans-serif'; diff --git a/web/src/overlayPalette.ts b/web/src/overlayPalette.ts new file mode 100644 index 0000000..6bfd890 --- /dev/null +++ b/web/src/overlayPalette.ts @@ -0,0 +1,17 @@ +import type { Map as MapLibreMap } from 'maplibre-gl'; +import { recolorPaint } from './displayPreferences'; + +/** Keep the original expressions, including semantic feature colors, through every theme switch. */ +export function applyOverlayPalette(map: MapLibreMap, originals: Map, light: boolean): void { + for (const layer of map.getStyle().layers) { + if (layer.id.startsWith('basemap-') || !('paint' in layer)) continue; + for (const property of Object.keys(layer.paint ?? {})) { + if (!property.endsWith('-color')) continue; + const paintProperty = property as Parameters[1]; + const key = `${layer.id}:${property}`; + if (!originals.has(key)) originals.set(key, map.getPaintProperty(layer.id, paintProperty)); + const value = recolorPaint(originals.get(key), light) as Parameters[2]; + map.setPaintProperty(layer.id, paintProperty, value); + } + } +} diff --git a/web/src/packetAnimator.ts b/web/src/packetAnimator.ts index 9102787..842ed4f 100644 --- a/web/src/packetAnimator.ts +++ b/web/src/packetAnimator.ts @@ -1,4 +1,5 @@ -import { stableHash as stableVisualHash, colorWithAlpha as withAlpha } from './trafficVisuals'; +import { stableHash as stableVisualHash } from './trafficVisuals'; +import { canvasColorWithAlpha as withAlpha, displayColor, displayPreferences, displayResidueAge, lightScene, lineDash, residueLifetime } from './displayPreferences'; import type * as maplibregl from 'maplibre-gl'; import type { EndpointV2, ObserverPacketEventV2, PacketView, RoutePacketView, RouteSegmentView } from './types'; import { TerrainProjector, surfaceArc, surfacePathPoint, surfaceTrail, traceSurfacePath, type SurfacePoint } from './terrainProjection'; @@ -398,6 +399,13 @@ export class PacketAnimator { } } + refreshAppearance(): void { + this.residueContentDirty = true; + this.residueProjectionDirty = true; + this.canvas.dataset.routePreset = displayPreferences().preset; + this.requestFrame(); + } + destroy(): void { this.setPaused(true); this.reducedMotionQuery.removeEventListener('change', this.handleReducedMotionChange); @@ -481,7 +489,7 @@ export class PacketAnimator { if (this.residue.length > 0) { const redraw = this.qualityMode() === 'low' ? RESIDUE_REDRAW_MS * 2 : RESIDUE_REDRAW_MS; delay = Math.max(0, this.residueCacheUpdatedAt + redraw - now); - for (const item of this.residue) delay = Math.min(delay, Math.max(0, item.addedAt + RESIDUE_MS - now)); + for (const item of this.residue) delay = Math.min(delay, Math.max(0, item.addedAt + residueLifetime() - now)); } if (this.nodeWakes.length > 0) { const redraw = this.qualityMode() === 'low' ? RESIDUE_REDRAW_MS * 2 : RESIDUE_REDRAW_MS; @@ -516,7 +524,7 @@ export class PacketAnimator { if (!this.reducedMotion) { for (const route of this.activeRoutes) this.completeRoute(route, now); } - const liveResidue = this.residue.filter((item) => now - item.addedAt < RESIDUE_MS); + const liveResidue = this.residue.filter((item) => now - item.addedAt < residueLifetime()); if (liveResidue.length !== this.residue.length) { this.residue = liveResidue; this.residueContentDirty = true; @@ -572,7 +580,7 @@ export class PacketAnimator { } private drawResidue(context: CanvasRenderingContext2D, item: Residue, now: number): void { - const style = residueStyle(now - item.addedAt); + const style = residueStyle(displayResidueAge(now - item.addedAt)); const rangeBoost = item.longHaul ? 1.28 : 1; const detail = Math.max(0.3, Math.min(1, (this.map.getZoom() - 3) / 7)); const bloomOpacity = this.reducedMotion ? style.life * 0.12 : style.bloomOpacity; @@ -581,24 +589,24 @@ export class PacketAnimator { const coreWidth = this.reducedMotion ? 1.8 : style.coreWidth; const coreColor = this.reducedMotion ? item.color : blendWithWhite(item.color, style.hot * 0.16); traceSurfacePath(context, this.projection.projectSegment(item.segment)); - context.strokeStyle = withAlpha(item.color, Math.min(0.7, bloomOpacity * rangeBoost * detail)); + context.strokeStyle = withAlpha(item.color, Math.min(0.7, bloomOpacity * rangeBoost * detail) * displayPreferences().glow); context.lineWidth = bloomWidth * rangeBoost * detail; context.stroke(); - context.setLineDash(item.signature === 'echo' ? [6, 5] : []); + context.setLineDash(lineDash()); context.strokeStyle = withAlpha(coreColor, Math.min(0.96, coreOpacity * rangeBoost)); - context.lineWidth = Math.max(0.65, coreWidth * detail) * (item.longHaul ? 1.18 : 1); + context.lineWidth = Math.max(1, coreWidth * detail) * (displayPreferences().width / 1.6) * (item.longHaul ? 1.18 : 1); context.stroke(); context.setLineDash([]); } private drawResidueSparkles(now: number): void { - if (this.map.getZoom() < 5) return; + if (this.map.getZoom() < 5 || displayPreferences().glow < 0.25) return; const quality = this.qualityMode(); const count = quality === 'full' ? 3 : quality === 'balanced' ? 2 : 1; const limit = quality === 'full' ? 160 : quality === 'balanced' ? 120 : 96; for (const item of this.residue.slice(-limit)) { const path = this.projection.projectSegment(item.segment); - const style = residueStyle(now - item.addedAt); + const style = residueStyle(displayResidueAge(now - item.addedAt)); if (style.life <= 0.025) continue; const age = Math.max(0, now - item.addedAt); const sparkleCount = Math.min(4, count + (item.longHaul ? 1 : 0)); @@ -680,7 +688,7 @@ export class PacketAnimator { if (segment && elapsed <= item.duration) { const path = this.projection.projectSegment(segment); const head = surfacePathPoint(path, motion.localProgress); - const points = surfaceTrail(path, motion.localProgress, (quality === 'full' ? 46 : quality === 'balanced' ? 38 : 28) * (head.scale ?? 1)); + const points = surfaceTrail(path, motion.localProgress, (quality === 'full' ? 46 : quality === 'balanced' ? 38 : 28) * (head.scale ?? 1) * displayPreferences().trailLength); const trail: PacketTrail = { points, tail: points[0]!, head, length: points.slice(1).reduce((sum, point, index) => sum + Math.hypot(point.x - points[index]!.x, point.y - points[index]!.y), 0) }; const before = surfacePathPoint(path, Math.max(0, motion.localProgress - 0.01)); const after = surfacePathPoint(path, Math.min(1, motion.localProgress + 0.01)); @@ -749,13 +757,15 @@ export class PacketAnimator { const width = quality === 'full' ? 7.2 : quality === 'balanced' ? 5.8 : 3.8; this.context.lineWidth = width * (longHaul ? 1.42 : 1) * (trail.head.scale ?? 1); traceSurfacePath(this.context, trail.points ?? [trail.tail, trail.head]); + this.context.globalAlpha = displayPreferences().glow; this.context.stroke(); + this.context.globalAlpha = 1; const core = this.context.createLinearGradient(trail.tail.x, trail.tail.y, trail.head.x, trail.head.y); core.addColorStop(0, withAlpha(color, 0)); core.addColorStop(0.58, withAlpha(color, longHaul ? 0.5 : 0.36)); core.addColorStop(1, withAlpha(color, 0.98)); this.context.strokeStyle = core; - this.context.lineWidth = (quality === 'low' ? 1.3 : 1.65) * (longHaul ? 1.2 : 1) * (trail.head.scale ?? 1); + this.context.lineWidth = (quality === 'low' ? 1.3 : 1.65) * (longHaul ? 1.2 : 1) * (trail.head.scale ?? 1) * displayPreferences().packetSize; this.context.stroke(); } @@ -809,14 +819,14 @@ export class PacketAnimator { if (last) this.endpointGlow(this.point(last.to), item.color, opacity); } - private drawStaticSegment(points: readonly ScreenPoint[], color: string, opacity: number, signature: PacketSignature): void { - this.context.strokeStyle = withAlpha(color, opacity * 0.2); + private drawStaticSegment(points: readonly ScreenPoint[], color: string, opacity: number, _signature: PacketSignature): void { + this.context.strokeStyle = withAlpha(color, opacity * 0.2 * displayPreferences().glow); this.context.lineWidth = 7; traceSurfacePath(this.context, points); this.context.stroke(); this.context.strokeStyle = withAlpha(color, opacity * 0.75); - this.context.lineWidth = 1.8; - this.context.setLineDash(signature === 'echo' ? [6, 5] : []); + this.context.lineWidth = displayPreferences().width; + this.context.setLineDash(lineDash()); this.context.stroke(); this.context.setLineDash([]); } @@ -842,7 +852,7 @@ export class PacketAnimator { } private drawPacketCore(point: ScreenPoint, color: string, quality: VisualQuality, longHaul = false): void { - const scale = point.scale ?? 1; + const scale = (point.scale ?? 1) * displayPreferences().packetSize; const radius = (quality === 'low' ? 4 : 6.5) * (longHaul ? 1.38 : 1) * scale; const glow = this.context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius); glow.addColorStop(0, withAlpha(color, 0.86)); @@ -851,8 +861,10 @@ export class PacketAnimator { this.context.fillStyle = glow; this.context.beginPath(); this.context.arc(point.x, point.y, radius, 0, Math.PI * 2); + this.context.globalAlpha = displayPreferences().glow; this.context.fill(); - this.context.fillStyle = color; + this.context.globalAlpha = 1; + this.context.fillStyle = displayColor(color); this.context.beginPath(); this.context.arc(point.x, point.y, (quality === 'low' ? 1.5 : 1.85) * (longHaul ? 1.18 : 1) * scale, 0, Math.PI * 2); this.context.fill(); @@ -958,8 +970,8 @@ export class PacketAnimator { return; } const gradient = this.context.createRadialGradient(point.x, point.y, 0, point.x, point.y, radius); - gradient.addColorStop(0, withAlpha(blendWithWhite(color, 0.3), timing.opacity * 0.62)); - gradient.addColorStop(0.2, withAlpha(color, timing.opacity * 0.5)); + gradient.addColorStop(0, withAlpha(blendWithWhite(color, 0.3), timing.opacity * 0.62 * displayPreferences().glow)); + gradient.addColorStop(0.2, withAlpha(color, timing.opacity * 0.5 * displayPreferences().glow)); gradient.addColorStop(1, withAlpha(color, 0)); this.context.fillStyle = gradient; this.context.beginPath(); @@ -1117,11 +1129,12 @@ function easeOutCubic(value: number): number { } function blendWithWhite(color: string, amount: number): string { + color = displayColor(color); const value = color.startsWith('#') ? color.slice(1) : 'ffffff'; const blend = clamp(amount); const channels = [0, 2, 4].map((start) => { const channel = Number.parseInt(value.slice(start, start + 2), 16); - return Math.round(channel + (255 - channel) * blend) + return Math.round(channel + ((lightScene() ? 0 : 255) - channel) * blend) .toString(16) .padStart(2, '0'); }); diff --git a/web/src/preferences.ts b/web/src/preferences.ts index d81f8f0..f41df69 100644 --- a/web/src/preferences.ts +++ b/web/src/preferences.ts @@ -14,6 +14,10 @@ export interface UiPreferences { clusters: boolean; hillshade: boolean; terrain3D: boolean; + buildings: boolean; + terrainExaggeration: number; + terrainPitch: number; + terrainBearing: number; routeWindow: SavedRouteWindow; legendExpanded: boolean; basemap: BasemapStyle; @@ -34,6 +38,10 @@ export const DEFAULT_UI_PREFERENCES: UiPreferences = { clusters: true, hillshade: false, terrain3D: false, + buildings: false, + terrainExaggeration: 1, + terrainPitch: 50, + terrainBearing: 0, routeWindow: 'auto', legendExpanded: false, basemap: 'dark', @@ -68,7 +76,7 @@ export function loadSavedView(storage: Storage, kind: ViewClass): SavedView | nu const lat = Number(value.center[1]); const zoom = Number(value.zoom); if (!Number.isFinite(lng) || !Number.isFinite(lat) - || lng < -180 || lng > 180 || lat < -85.051129 || lat > 85.051129 || zoom < 1 || zoom > 16) return null; + || lng < -180 || lng > 180 || lat < -85.051129 || lat > 85.051129 || zoom < 1 || zoom > 18) return null; return { center: [lng, lat], zoom }; } catch { return null; @@ -94,6 +102,10 @@ export function loadUiPreferences(storage: Storage): UiPreferences { clusters: typeof value.clusters === 'boolean' ? value.clusters : DEFAULT_UI_PREFERENCES.clusters, hillshade: typeof value.hillshade === 'boolean' ? value.hillshade : DEFAULT_UI_PREFERENCES.hillshade, terrain3D: typeof value.terrain3D === 'boolean' ? value.terrain3D : DEFAULT_UI_PREFERENCES.terrain3D, + buildings: typeof value.buildings === 'boolean' ? value.buildings : Boolean(value.terrain3D), + terrainExaggeration: savedRange(value.terrainExaggeration, 1, 0.5, 2), + terrainPitch: savedRange(value.terrainPitch, 50, 0, 65), + terrainBearing: savedRange(value.terrainBearing, 0, -180, 180), routeWindow, legendExpanded: typeof value.legendExpanded === 'boolean' ? value.legendExpanded @@ -127,3 +139,7 @@ function isSavedRouteWindow(value: unknown): value is SavedRouteWindow { function savedAmount(value: unknown, fallback: number, minimum: number): number { return typeof value === 'number' && Number.isFinite(value) ? Math.max(minimum, Math.min(1, value)) : fallback; } + +function savedRange(value: unknown, fallback: number, min: number, max: number): number { + return typeof value === 'number' && Number.isFinite(value) ? Math.max(min, Math.min(max, value)) : fallback; +} diff --git a/web/src/routeLayer.test.ts b/web/src/routeLayer.test.ts index 1231dc9..546e02d 100644 --- a/web/src/routeLayer.test.ts +++ b/web/src/routeLayer.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; import type { Feature, LineString } from 'geojson'; -import { historicalRouteVertices } from './routeLayer'; +import { historicalRouteVertices, STROKE_VERTEX_FLOATS } from './routeLayer'; describe('historical route WebGL geometry', () => { it('keeps one exact line segment for every route', () => { @@ -11,11 +11,17 @@ describe('historical route WebGL geometry', () => { const vertices = historicalRouteVertices(routes); - expect(vertices).toHaveLength(routes.length * 2 * 7); - expect(vertices[6]).toBe(0); - expect(vertices[20]).toBe(3); + expect(vertices).toHaveLength(routes.length * 6 * STROKE_VERTEX_FLOATS); + expect(vertices[12]).toBe(0); + expect(vertices[6 * STROKE_VERTEX_FLOATS + 12]).toBe(3); + expect([...vertices].every(Number.isFinite)).toBe(true); expect(routes[0]?.geometry.coordinates).toEqual([[-0.13, 51.51], [18.42, -33.93]]); }); + it('splits date-line routes into short strokes', () => { + const vertices = historicalRouteVertices([route('seam', [[179, 40], [-179, 41]], '#4de7c4', 1)]); + expect(vertices).toHaveLength(12 * STROKE_VERTEX_FLOATS); + for (let i = 0; i < vertices.length; i += STROKE_VERTEX_FLOATS) expect(Math.abs(vertices[i]! - vertices[i + 3]!)).toBeLessThan(0.01); + }); }); function route( diff --git a/web/src/routeLayer.ts b/web/src/routeLayer.ts index 21ea35b..3f314ea 100644 --- a/web/src/routeLayer.ts +++ b/web/src/routeLayer.ts @@ -1,274 +1,273 @@ -import type { - CustomLayerInterface, - CustomRenderMethodInput, - Map as MapLibreMap, -} from 'maplibre-gl'; +import { MercatorCoordinate, type CustomLayerInterface, type CustomRenderMethodInput, type Map as MapLibreMap } from 'maplibre-gl'; import type { Feature, LineString } from 'geojson'; -import { splitWorldRoute } from './worldGeometry'; +import { splitWorldRoute, routeCoordinate, type Coordinate } from './worldGeometry'; +import { adaptiveSurfacePath, type SurfacePoint } from './terrainProjection'; +import { displayColor, displayPreferences } from './displayPreferences'; export const ROUTE_WEBGL_LAYER_ID = 'route-exact-webgl'; -const FLOATS_PER_VERTEX = 7; - -interface GLResources { - gl: WebGLRenderingContext | WebGL2RenderingContext; - program: WebGLProgram; - buffer: WebGLBuffer; - position: number; - color: number; - alpha: number; - band: number; - matrix: WebGLUniformLocation; - opacity: WebGLUniformLocation; - brightness: WebGLUniformLocation; - maximumBand: WebGLUniformLocation; - maximumLineWidth: number; -} +// From xyz, to xyz, corner (along/side), rgb, alpha, age band. +export const STROKE_VERTEX_FLOATS = 13; +type GL = WebGLRenderingContext | WebGL2RenderingContext; +interface StrokeSegment { from: [number, number, number]; to: [number, number, number]; color: string; opacity: number; band: number } +interface HitPath { id: string; band: number; points: readonly SurfacePoint[] } +interface Resources { program: WebGLProgram; buffer: WebGLBuffer; uniforms: Record; attributes: number[] } export class HistoricalRouteLayer implements CustomLayerInterface { readonly id = ROUTE_WEBGL_LAYER_ID; readonly type = 'custom' as const; - readonly renderingMode = '2d' as const; + // Terrain/buildings provide context; confirmed paths deliberately remain visible. + readonly renderingMode = '3d' as const; private map?: MapLibreMap; - private resources?: GLResources; - private vertices: Float32Array = new Float32Array(); + private gl?: GL; + private resources?: Resources; + private routes: readonly Feature[] = []; + private hitPaths: HitPath[] = []; private vertexCount = 0; + private dirty = true; private visible = false; private maximumBand = 3; private opacity = 0.8; private lightBackground = false; + private origin: [number, number, number] = [0, 0, 0]; - onAdd(map: MapLibreMap, gl: WebGLRenderingContext | WebGL2RenderingContext): void { + onAdd(map: MapLibreMap, gl: GL): void { this.map = map; + this.gl = gl; const program = createProgram(gl); const buffer = gl.createBuffer(); - if (!buffer) throw new Error('Unable to create the historical-route buffer'); - const lineWidthRange = gl.getParameter(gl.ALIASED_LINE_WIDTH_RANGE) as Float32Array | number[] | null; - this.resources = { - gl, - program, - buffer, - position: requiredAttribute(gl, program, 'a_position'), - color: requiredAttribute(gl, program, 'a_color'), - alpha: requiredAttribute(gl, program, 'a_alpha'), - band: requiredAttribute(gl, program, 'a_band'), - matrix: requiredUniform(gl, program, 'u_matrix'), - opacity: requiredUniform(gl, program, 'u_opacity'), - brightness: requiredUniform(gl, program, 'u_brightness'), - maximumBand: requiredUniform(gl, program, 'u_maximum_band'), - maximumLineWidth: Number(lineWidthRange?.[1] ?? 1), - }; - this.upload(); - } - - onRemove(_map: MapLibreMap, gl: WebGLRenderingContext | WebGL2RenderingContext): void { - if (this.resources) { - gl.deleteBuffer(this.resources.buffer); - gl.deleteProgram(this.resources.program); + if (!buffer) throw new Error('Unable to create route stroke buffer'); + const uniforms: Record = {}; + for (const name of ['matrix', 'viewport', 'width', 'glow', 'opacity', 'maximum_band', 'pattern']) { + const location = gl.getUniformLocation(program, `u_${name}`); + if (location === null) throw new Error(`Missing route uniform: ${name}`); + uniforms[name] = location; } + const attributes = ['from', 'to', 'corner', 'color', 'alpha', 'band'].map((name) => { + const location = gl.getAttribLocation(program, `a_${name}`); + if (location < 0) throw new Error(`Missing route attribute: ${name}`); + return location; + }); + this.resources = { program, buffer, uniforms, attributes }; + map.on('move', this.cameraChanged); + map.on('terrain', this.invalidate); + map.on('sourcedata', this.sourceChanged); + this.dirty = true; + } + onRemove(map: MapLibreMap, gl: GL): void { + map.off('move', this.cameraChanged); + map.off('terrain', this.invalidate); + map.off('sourcedata', this.sourceChanged); + if (this.resources) { gl.deleteBuffer(this.resources.buffer); gl.deleteProgram(this.resources.program); } this.resources = undefined; this.map = undefined; + this.gl = undefined; } - - setRoutes(routes: readonly Feature[]): void { - this.vertices = historicalRouteVertices(routes); - this.vertexCount = this.vertices.length / FLOATS_PER_VERTEX; - this.upload(); - this.map?.triggerRepaint(); - } - - setOpacity(opacity: number): void { - this.opacity = clamp(opacity, 0.2, 1); - this.map?.triggerRepaint(); - } - - setLightBackground(light: boolean): void { - this.lightBackground = light; - this.map?.triggerRepaint(); - } - + private cameraChanged = (): void => { + if (!this.map) return; + const center = MercatorCoordinate.fromLngLat(this.map.getCenter()); + if (this.map.getTerrain() || Math.abs(center.x - this.origin[0]) > 1 / 256 || Math.abs(center.y - this.origin[1]) > 1 / 256) this.invalidate(); + }; + private sourceChanged = (event: { sourceId?: string }): void => { + if (event.sourceId && event.sourceId === this.map?.getTerrain()?.source) this.invalidate(); + }; + private invalidate = (): void => { this.dirty = true; if (this.visible) this.map?.triggerRepaint(); }; + setRoutes(routes: readonly Feature[]): void { this.routes = routes; this.invalidate(); } + setOpacity(opacity: number): void { this.opacity = clamp(opacity, 0.2, 1); this.map?.triggerRepaint(); } + setLightBackground(light: boolean): void { if (light !== this.lightBackground) { this.lightBackground = light; this.invalidate(); } } setVisible(visible: boolean): void { if (this.visible === visible) return; this.visible = visible; this.map?.triggerRepaint(); } - - setMaximumBand(maximumBand: number): void { - const next = Math.max(0, Math.min(3, Math.round(maximumBand))); - if (this.maximumBand === next) return; + setMaximumBand(band: number): void { + const next = clamp(Math.round(band), 0, 3); + if (next === this.maximumBand) return; this.maximumBand = next; if (this.visible) this.map?.triggerRepaint(); } + refreshAppearance(): void { this.map?.triggerRepaint(); } - render(gl: WebGLRenderingContext | WebGL2RenderingContext, options: CustomRenderMethodInput): void { - const resources = this.resources; - if (!resources || !this.visible || this.vertexCount === 0) return; - const zoom = this.map?.getZoom() ?? 3; - gl.enable(gl.BLEND); - gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); - gl.useProgram(resources.program); - gl.bindBuffer(gl.ARRAY_BUFFER, resources.buffer); - bindAttributes(gl, resources); - gl.uniformMatrix4fv(resources.matrix, false, options.defaultProjectionData.mainMatrix); - gl.uniform1f(resources.maximumBand, this.maximumBand); - const pixelRatio = gl.drawingBufferWidth / Math.max(1, this.map?.getCanvas().clientWidth ?? gl.drawingBufferWidth); - const coreWidth = zoom < 6 ? 0.75 : zoom < 10 ? 1 : 1.25; - gl.lineWidth(Math.max(1, Math.min(resources.maximumLineWidth, pixelRatio * coreWidth))); - gl.uniform1f(resources.opacity, this.opacity); - gl.uniform1f(resources.brightness, this.lightBackground ? 0.58 : 1); - gl.drawArrays(gl.LINES, 0, this.vertexCount); + pick(point: { x: number; y: number }, allowed: (id: string) => boolean): string | undefined { + if (!this.visible || !this.map?.getTerrain()) return undefined; + if (this.dirty) this.upload(); + let best = 7 + displayPreferences().width / 2; + let found: string | undefined; + for (const path of this.hitPaths) { + if (path.band > this.maximumBand || !allowed(path.id)) continue; + for (let i = 1; i < path.points.length; i += 1) { + const a = path.points[i - 1]!; const b = path.points[i]!; + const dx = b.x - a.x; const dy = b.y - a.y; + const t = clamp(((point.x - a.x) * dx + (point.y - a.y) * dy) / Math.max(0.001, dx * dx + dy * dy), 0, 1); + const distance = Math.hypot(point.x - a.x - t * dx, point.y - a.y - t * dy); + if (distance < best) { best = distance; found = path.id; } + } + } + return found; } private upload(): void { - if (!this.resources) return; - const { gl, buffer } = this.resources; - gl.bindBuffer(gl.ARRAY_BUFFER, buffer); - gl.bufferData(gl.ARRAY_BUFFER, this.vertices, gl.DYNAMIC_DRAW); - } - -} - -export function historicalRouteVertices(routes: readonly Feature[]): Float32Array { - const values = new Float32Array(routes.length * 4 * FLOATS_PER_VERTEX); - let offset = 0; - for (const route of routes) { - const from = route.geometry.coordinates[0]; - const to = route.geometry.coordinates[route.geometry.coordinates.length - 1]; - if (!from || !to) continue; - const properties = route.properties ?? {}; - const color = parseColor(String(properties.color ?? '#73d9cf')); - const alpha = clamp(Number(properties.opacity ?? 0.4), 0.04, 1); - const band = clamp(Number(properties.windowBand ?? 3), 0, 3); - for (const pair of splitWorldRoute([Number(from[0]), Number(from[1])], [Number(to[0]), Number(to[1])])) { - for (const point of pair) { - const position = mercator(point[0], point[1]); - values.set([ - position[0], position[1], color[0], color[1], color[2], alpha, band, - ], offset); - offset += FLOATS_PER_VERTEX; + if (!this.map || !this.gl || !this.resources) return; + const map = this.map; + const center = MercatorCoordinate.fromLngLat(map.getCenter()); + this.origin = [center.x, center.y, 0]; + const terrain = Boolean(map.getTerrain()); + const segments: StrokeSegment[] = []; + this.hitPaths = []; + for (const route of this.routes) { + const rawFrom = route.geometry.coordinates[0]; const rawTo = route.geometry.coordinates.at(-1); + if (!rawFrom || !rawTo) continue; + const band = Number(route.properties?.windowBand ?? 3); + const color = displayColor(String(route.properties?.color ?? '#73d9cf'), this.lightBackground); + const rawOpacity = clamp(Number(route.properties?.opacity ?? 0.4), 0, 1); + if (rawOpacity === 0) continue; + const opacity = Math.max(this.lightBackground ? 0.95 : 0.55, rawOpacity); + for (const pair of splitWorldRoute([rawFrom[0]!, rawFrom[1]!], [rawTo[0]!, rawTo[1]!])) { + let fractions = [0, 1]; + if (terrain) { + const project = (t: number): SurfacePoint => map.project(routeCoordinate(pair[0], pair[1], t)); + const seeds = [0, 0.25, 0.5, 0.75, 1].map(project); + const width = map.getCanvas().clientWidth; const height = map.getCanvas().clientHeight; + if (seeds.every((p) => p.x < -160) || seeds.every((p) => p.x > width + 160) + || seeds.every((p) => p.y < -160) || seeds.every((p) => p.y > height + 160)) continue; + const points = adaptiveSurfacePath(project); + fractions = points.map((p) => p.progress!); + this.hitPaths.push({ id: String(route.properties?.id ?? route.id ?? ''), band, points }); + } + const positions = fractions.map((t) => { + const point = routeCoordinate(pair[0], pair[1], t); + // MapLibre returns rendered (already exaggerated) terrain elevation. + const elevation = terrain ? map.queryTerrainElevation(point) ?? 0 : 0; + const mercator = MercatorCoordinate.fromLngLat(point, Number.isFinite(elevation) ? elevation : 0); + return [mercator.x, mercator.y, mercator.z] as [number, number, number]; + }); + for (let i = 1; i < positions.length; i += 1) segments.push({ from: positions[i - 1]!, to: positions[i]!, color, opacity, band }); } } + const data = strokeVertices(segments, this.origin); + this.vertexCount = data.length / STROKE_VERTEX_FLOATS; + this.gl.bindBuffer(this.gl.ARRAY_BUFFER, this.resources.buffer); + this.gl.bufferData(this.gl.ARRAY_BUFFER, data, this.gl.DYNAMIC_DRAW); + this.dirty = false; + } + render(gl: GL, options: CustomRenderMethodInput): void { + if (!this.resources || !this.map || !this.visible) return; + if (this.dirty) this.upload(); + if (!this.vertexCount) return; + const { program, buffer, uniforms, attributes } = this.resources; + const settings = displayPreferences(); + const depth = gl.isEnabled(gl.DEPTH_TEST); + const cull = gl.isEnabled(gl.CULL_FACE); + const depthMask = gl.getParameter(gl.DEPTH_WRITEMASK) as boolean; + gl.disable(gl.DEPTH_TEST); + gl.disable(gl.CULL_FACE); + gl.depthMask(false); + gl.enable(gl.BLEND); + gl.blendFuncSeparate(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA, gl.ONE, gl.ONE_MINUS_SRC_ALPHA); + gl.useProgram(program); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + let offset = 0; + [3, 3, 2, 3, 1, 1].forEach((size, index) => { + gl.enableVertexAttribArray(attributes[index]!); + gl.vertexAttribPointer(attributes[index]!, size, gl.FLOAT, false, STROKE_VERTEX_FLOATS * 4, offset * 4); + offset += size; + }); + // Rebase in double precision before uploading floats, keeping close-zoom endpoints aligned. + const matrix = Array.from(options.defaultProjectionData.mainMatrix); + for (let row = 0; row < 4; row += 1) matrix[12 + row] = matrix[12 + row]! + matrix[row]! * this.origin[0] + matrix[4 + row]! * this.origin[1]; + gl.uniformMatrix4fv(uniforms.matrix!, false, new Float32Array(matrix)); + gl.uniform2f(uniforms.viewport!, Math.max(1, this.map.getCanvas().clientWidth), Math.max(1, this.map.getCanvas().clientHeight)); + gl.uniform1f(uniforms.width!, settings.width); + gl.uniform1f(uniforms.glow!, settings.glow * clamp((this.map.getZoom() - 3) / 7, 0.15, 1)); + gl.uniform1f(uniforms.opacity!, this.opacity); + gl.uniform1f(uniforms.maximum_band!, this.maximumBand); + gl.uniform1f(uniforms.pattern!, settings.pattern === 'dashed' ? 1 : settings.pattern === 'dotted' ? 2 : 0); + gl.drawArrays(gl.TRIANGLES, 0, this.vertexCount); + gl.depthMask(depthMask); + if (depth) gl.enable(gl.DEPTH_TEST); + if (cull) gl.enable(gl.CULL_FACE); } - return offset === values.length ? values : values.slice(0, offset); } -function bindAttributes(gl: WebGLRenderingContext | WebGL2RenderingContext, resources: GLResources): void { - const stride = FLOATS_PER_VERTEX * Float32Array.BYTES_PER_ELEMENT; - bindAttribute(gl, resources.position, 2, stride, 0); - bindAttribute(gl, resources.color, 3, stride, 2); - bindAttribute(gl, resources.alpha, 1, stride, 5); - bindAttribute(gl, resources.band, 1, stride, 6); +function strokeVertices(segments: readonly StrokeSegment[], origin: [number, number, number] = [0, 0, 0]): Float32Array { + const data = new Float32Array(segments.length * 6 * STROKE_VERTEX_FLOATS); + let offset = 0; + for (const segment of segments) { + const color = parseColor(segment.color); + for (const [along, side] of [[0, -1], [1, -1], [1, 1], [0, -1], [1, 1], [0, 1]]) { + data.set([...segment.from.map((v, i) => v - origin[i]!), ...segment.to.map((v, i) => v - origin[i]!), along!, side!, ...color, segment.opacity, segment.band], offset); + offset += STROKE_VERTEX_FLOATS; + } + } + return data; +} +export function historicalRouteVertices(routes: readonly Feature[]): Float32Array { + const segments: StrokeSegment[] = []; + const position = (p: Coordinate): [number, number, number] => { + const m = MercatorCoordinate.fromLngLat([p[0], p[1]], 0); + return [m.x, m.y, m.z]; + }; + for (const route of routes) { + const a = route.geometry.coordinates[0]; const b = route.geometry.coordinates.at(-1); + if (!a || !b) continue; + for (const [from, to] of splitWorldRoute([a[0]!, a[1]!], [b[0]!, b[1]!])) segments.push({ + from: position(from), to: position(to), color: String(route.properties?.color ?? '#73d9cf'), + opacity: Number(route.properties?.opacity ?? 0.4), band: Number(route.properties?.windowBand ?? 3), + }); + } + return strokeVertices(segments); } -function bindAttribute( - gl: WebGLRenderingContext | WebGL2RenderingContext, - location: number, - size: number, - stride: number, - floatOffset: number, -): void { - gl.enableVertexAttribArray(location); - gl.vertexAttribPointer(location, size, gl.FLOAT, false, stride, floatOffset * Float32Array.BYTES_PER_ELEMENT); -} - -function createProgram(gl: WebGLRenderingContext | WebGL2RenderingContext): WebGLProgram { +function createProgram(gl: GL): WebGLProgram { const vertex = compileShader(gl, gl.VERTEX_SHADER, ` precision highp float; - uniform mat4 u_matrix; - attribute vec2 a_position; - attribute vec3 a_color; - attribute float a_alpha; - attribute float a_band; - varying vec3 v_color; - varying float v_alpha; - varying float v_band; + uniform mat4 u_matrix; uniform vec2 u_viewport; uniform float u_width; uniform float u_glow; + attribute vec3 a_from; attribute vec3 a_to; attribute vec2 a_corner; + attribute vec3 a_color; attribute float a_alpha; attribute float a_band; + varying vec3 v_color; varying float v_alpha; varying float v_band; varying vec2 v_local; varying float v_length; void main() { - gl_Position = u_matrix * vec4(a_position, 0.0, 1.0); - v_color = a_color; - v_alpha = a_alpha; - v_band = a_band; + vec4 a = u_matrix * vec4(a_from, 1.0); vec4 b = u_matrix * vec4(a_to, 1.0); + vec2 delta = (b.xy / b.w - a.xy / a.w) * u_viewport * 0.5; + float len = max(0.001, length(delta)); vec2 direction = delta / len; + float extent = u_width * 0.5 + 1.0 + u_glow * 5.0; + vec4 point = mix(a, b, a_corner.x); + vec2 shift = (direction * (a_corner.x * 2.0 - 1.0) + vec2(-direction.y, direction.x) * a_corner.y) * extent; + point.xy += shift * 2.0 / u_viewport * point.w; + gl_Position = point; + v_local = vec2(mix(-extent, len + extent, a_corner.x), a_corner.y * extent); + v_length = len; v_color = a_color; v_alpha = a_alpha * step(0.001, a.w) * step(0.001, b.w); v_band = a_band; } `); const fragment = compileShader(gl, gl.FRAGMENT_SHADER, ` - precision mediump float; - uniform float u_opacity; - uniform float u_brightness; - uniform float u_maximum_band; - varying vec3 v_color; - varying float v_alpha; - varying float v_band; + precision highp float; + uniform float u_width; uniform float u_glow; uniform float u_opacity; uniform float u_maximum_band; uniform float u_pattern; + varying vec3 v_color; varying float v_alpha; varying float v_band; varying vec2 v_local; varying float v_length; void main() { - if (v_band > u_maximum_band + 0.1) discard; - gl_FragColor = vec4(v_color * u_brightness, v_alpha * u_opacity); + if (v_band > u_maximum_band + 0.1 || v_alpha <= 0.0) discard; + float x = v_local.x; float outside = max(-x, x - v_length); + float distance = length(vec2(max(0.0, outside), v_local.y)); + if (u_pattern > 1.5) { float period = u_width * 2.7; distance = max(distance, length(vec2(mod(x, period) - period * 0.5, v_local.y))); } + else if (u_pattern > 0.5 && mod(max(0.0, x), u_width * 7.0) > u_width * 4.0) discard; + float core = 1.0 - smoothstep(u_width * 0.5 - 0.45, u_width * 0.5 + 0.55, distance); + float glow = (1.0 - smoothstep(u_width * 0.5, u_width * 0.5 + 1.0 + u_glow * 5.0, distance)) * u_glow * 0.18; + gl_FragColor = vec4(v_color, max(core, glow) * v_alpha * u_opacity); } `); const program = gl.createProgram(); - if (!program) throw new Error('Unable to create the historical-route shader program'); - gl.attachShader(program, vertex); - gl.attachShader(program, fragment); - gl.linkProgram(program); - gl.deleteShader(vertex); - gl.deleteShader(fragment); - if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { - const message = gl.getProgramInfoLog(program) || 'unknown link error'; - gl.deleteProgram(program); - throw new Error(`Historical-route shader link failed: ${message}`); - } + if (!program) throw new Error('Unable to create route stroke program'); + gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program); + gl.deleteShader(vertex); gl.deleteShader(fragment); + if (!gl.getProgramParameter(program, gl.LINK_STATUS)) { const error = gl.getProgramInfoLog(program); gl.deleteProgram(program); throw new Error(`Route stroke link failed: ${error}`); } return program; } - -function compileShader( - gl: WebGLRenderingContext | WebGL2RenderingContext, - type: number, - source: string, -): WebGLShader { +function compileShader(gl: GL, type: number, source: string): WebGLShader { const shader = gl.createShader(type); - if (!shader) throw new Error('Unable to create a historical-route shader'); - gl.shaderSource(shader, source); - gl.compileShader(shader); - if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { - const message = gl.getShaderInfoLog(shader) || 'unknown compile error'; - gl.deleteShader(shader); - throw new Error(`Historical-route shader compile failed: ${message}`); - } + if (!shader) throw new Error('Unable to create route shader'); + gl.shaderSource(shader, source); gl.compileShader(shader); + if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) { const error = gl.getShaderInfoLog(shader); gl.deleteShader(shader); throw new Error(`Route shader failed: ${error}`); } return shader; } - -function requiredAttribute( - gl: WebGLRenderingContext | WebGL2RenderingContext, - program: WebGLProgram, - name: string, -): number { - const location = gl.getAttribLocation(program, name); - if (location < 0) throw new Error(`Historical-route shader is missing ${name}`); - return location; -} - -function requiredUniform( - gl: WebGLRenderingContext | WebGL2RenderingContext, - program: WebGLProgram, - name: string, -): WebGLUniformLocation { - const location = gl.getUniformLocation(program, name); - if (!location) throw new Error(`Historical-route shader is missing ${name}`); - return location; -} - -function mercator(longitude: number, latitude: number): [number, number] { - const lng = clamp(longitude, -180, 180); - const lat = clamp(latitude, -85.0511287798, 85.0511287798); - const radians = lat * Math.PI / 180; - return [ - (lng + 180) / 360, - (1 - Math.log(Math.tan(radians) + 1 / Math.cos(radians)) / Math.PI) / 2, - ]; -} - function parseColor(color: string): [number, number, number] { - const match = /^#([0-9a-f]{6})$/iu.exec(color); - const value = Number.parseInt(match?.[1] ?? '73d9cf', 16); + const value = Number.parseInt(/^#[0-9a-f]{6}$/i.test(color) ? color.slice(1) : '73d9cf', 16); return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255]; } - -function clamp(value: number, minimum: number, maximum: number): number { - return Math.max(minimum, Math.min(maximum, Number.isFinite(value) ? value : minimum)); -} +function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, Number.isFinite(value) ? value : min)); } diff --git a/web/src/styles.css b/web/src/styles.css index 508a05b..8598cfa 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -687,7 +687,7 @@ button { font: inherit; } :root[data-theme="light"] :is(.find-panel input, .node-inspector, .maplibregl-popup-content, .node-inspector-sheet, .about-dialog) { color: var(--ink); background: var(--panel-solid); } :root[data-theme="light"] :is(.tooltip strong, .tooltip span, .node-inspector header strong, .node-inspector header span, .node-inspector h3, .node-inspector dt, .node-inspector dd, .about-dialog h1, .about-dialog p, .about-dialog dd, .about-dialog dt) { color: var(--ink); } :root[data-basemap="light"] .map-grade, :root[data-basemap="streets"] .map-grade { opacity: 0; } -:root:not([data-basemap="dark"]) #packet-canvas { filter: brightness(0.72) saturate(1.2); } +:root:not([data-basemap="dark"]) #packet-canvas { filter: none; } :root[data-view-class="mobile"] .controls { backdrop-filter: none; -webkit-backdrop-filter: none; } :root[data-view-class="mobile"] #layers-panel { position: fixed; top: max(108px, calc(env(safe-area-inset-top) + 100px)); right: 8px; } :root[data-view-class="mobile"] .find-panel { position: fixed; top: max(108px, calc(env(safe-area-inset-top) + 100px)); right: 8px; width: min(320px, calc(100vw - 16px)); } diff --git a/web/src/terrainProjection.test.ts b/web/src/terrainProjection.test.ts index ca25f98..8165a41 100644 --- a/web/src/terrainProjection.test.ts +++ b/web/src/terrainProjection.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest'; -import { geographicSegmentPoint, surfaceArc, surfacePathPoint, surfaceTrail, TerrainProjector } from './terrainProjection'; +import { adaptiveSurfacePath, geographicSegmentPoint, surfaceArc, surfacePathPoint, surfaceTrail, TerrainProjector } from './terrainProjection'; import type { EndpointV2 } from './types'; const endpoint = (lng: number, lat: number): EndpointV2 => ({ id: `${lng}:${lat}`, label: 'Synthetic', lng, lat }); @@ -18,11 +18,22 @@ describe('terrain-aware packet geometry', () => { terrain = true; projection.reset(); const path = projection.projectSegment(segment); - expect(path).toHaveLength(17); + expect(path.length).toBeGreaterThanOrEqual(5); + expect(path.length).toBeLessThanOrEqual(65); expect(surfacePathPoint(path, 0.5)).toMatchObject({ x: 50, y: 21 }); expect(path[0]).toMatchObject({ x: 0, y: 51 }); expect(path.at(-1)!.y).toBeCloseTo(51); }); + it('keeps packet timing independent of adaptive spacing and caps pathological relief', () => { + const uneven = [{ x: 0, y: 0, progress: 0 }, { x: 10, y: 0, progress: 0.1 }, { x: 100, y: 0, progress: 1 }]; + expect(surfacePathPoint(uneven, 0.5).x).toBeCloseTo(50); + const trail = surfaceTrail(uneven, 0.5, 20); + expect(trail[0]!.x).toBeCloseTo(30); + expect(trail.at(-1)!.x).toBeCloseTo(50); + expect(trail.every((point) => point.progress === undefined)).toBe(true); + expect(adaptiveSurfacePath((t) => ({ x: t * 100_000, y: Math.sin(t * 500) * 900 })).length).toBeLessThanOrEqual(65); + expect(adaptiveSurfacePath(() => ({ x: NaN, y: 0 }))).toEqual([]); + }); it('reuses samples until the camera or DEM changes, and keys moved endpoints separately', () => { let offset = 0; diff --git a/web/src/terrainProjection.ts b/web/src/terrainProjection.ts index 7ac0f98..91c2f3a 100644 --- a/web/src/terrainProjection.ts +++ b/web/src/terrainProjection.ts @@ -1,13 +1,13 @@ import type { EndpointV2, RouteSegmentView } from './types'; -import { longitudeDelta, routeCoordinate } from './worldGeometry'; +import { routeCoordinate } from './worldGeometry'; export interface SurfacePoint { x: number; y: number; scale?: number; - ground?: readonly [number, number, number, number]; + /** Geographic progress, independent of adaptive sample spacing. */ progress?: number; - breakBefore?: boolean; + ground?: readonly [number, number, number, number]; } interface TerrainMap { @@ -63,31 +63,11 @@ export class TerrainProjector { const key = `${from.lng}:${from.lat}:${to.lng}:${to.lat}`; const cached = this.paths.get(key); if (cached) return cached; - const delta = longitudeDelta(from.lng, to.lng); - const endLongitude = from.lng + delta; - if (Math.abs(endLongitude) > 180) { - const boundary = Math.sign(endLongitude) * 180; - const crossing = (boundary - from.lng) / delta; - const points: SurfacePoint[] = []; - const times = [...new Set([...Array.from({ length: 17 }, (_, index) => index / 16), crossing])].sort((a, b) => a - b); - for (const t of times) { - const [lng, lat] = geographicSegmentPoint(from, to, t); - const endpoint = { ...from, lng: t > crossing ? lng - Math.sign(boundary) * 360 : lng, lat }; - points.push({ ...this.projectEndpoint(endpoint), progress: t }); - if (t === crossing) points.push({ ...this.projectEndpoint({ ...endpoint, lng: -boundary }), progress: t, breakBefore: true }); - } - if (this.paths.size >= 1024) this.paths.delete(this.paths.keys().next().value!); - this.paths.set(key, points); - return points; - } const first = this.projectEndpoint(from); - const last = this.projectEndpoint({ ...to, lng: endLongitude }); - const points = this.enabled() ? Array.from({ length: 17 }, (_, index): SurfacePoint => { - const t = index / 16; - if (index === 0) return first; - if (index === 16) return last; - const point = this.project(geographicSegmentPoint(from, to, t)); - return { ...point, scale: (first.scale ?? 1) + ((last.scale ?? 1) - (first.scale ?? 1)) * t }; + const last = this.projectEndpoint(to); + const points = this.enabled() ? adaptiveSurfacePath((t) => { + const point = t === 0 ? first : t === 1 ? last : this.project(geographicSegmentPoint(from, to, t)); + return { ...point, progress: t, scale: (first.scale ?? 1) + ((last.scale ?? 1) - (first.scale ?? 1)) * t }; }) : [first, last]; if (this.paths.size >= 1024) this.paths.delete(this.paths.keys().next().value!); this.paths.set(key, points); @@ -95,57 +75,76 @@ export class TerrainProjector { } } -export function surfacePathPoint(points: readonly SurfacePoint[], progress: number): SurfacePoint { - if (points.length === 1) return points[0]!; - if (points[0]!.progress !== undefined) { - const t = Math.max(0, Math.min(1, progress)); - let index = 0; - while (index < points.length - 1 && points[index + 1]!.progress! <= t) index += 1; - if (index === points.length - 1) return points[index]!; - const from = points[index]!; - const to = points[index + 1]!; - return surfacePathPoint([{ ...from, progress: undefined }, { ...to, progress: undefined }], (t - from.progress!) / (to.progress! - from.progress!)); +/** Four seed intervals catch relief away from the midpoint; subdivision is capped at 65 vertices. */ +export function adaptiveSurfacePath(project: (progress: number) => SurfacePoint): SurfacePoint[] { + const sample = (t: number): SurfacePoint => ({ ...project(t), progress: t }); + const points: SurfacePoint[] = []; + const split = (a: SurfacePoint, b: SurfacePoint, depth: number): void => { + const m = sample((a.progress! + b.progress!) / 2); + const error = Math.hypot(m.x - (a.x + b.x) / 2, m.y - (a.y + b.y) / 2); + if (depth < 4 && Number.isFinite(error) && (error > 1.5 || Math.hypot(b.x - a.x, b.y - a.y) > 160)) { + split(a, m, depth + 1); + split(m, b, depth + 1); + } else points.push(b); + }; + let a = sample(0); + points.push(a); + for (let index = 1; index <= 4; index += 1) { + const b = sample(index / 4); + split(a, b, 0); + a = b; } - const position = Math.max(0, Math.min(1, progress)) * (points.length - 1); - const index = Math.min(points.length - 2, Math.floor(position)); - const from = points[index]!; - const to = points[index + 1]!; - const t = position - index; + return points.every((point) => Number.isFinite(point.x) && Number.isFinite(point.y)) ? points : []; +} + +function fraction(points: readonly SurfacePoint[], index: number): number { + return points[index]!.progress ?? index / Math.max(1, points.length - 1); +} + +function interpolatePoint(from: SurfacePoint, to: SurfacePoint, t: number): SurfacePoint { return { x: from.x + (to.x - from.x) * t, y: from.y + (to.y - from.y) * t, scale: (from.scale ?? 1) + ((to.scale ?? 1) - (from.scale ?? 1)) * t }; } +export function surfacePathPoint(points: readonly SurfacePoint[], progress: number): SurfacePoint { + if (points.length === 0) return { x: -1_000_000, y: -1_000_000, scale: 1 }; + if (points.length === 1) return points[0]!; + progress = Math.max(0, Math.min(1, progress)); + let index = 0; + while (index < points.length - 2 && fraction(points, index + 1) <= progress) index += 1; + const from = points[index]!; + const to = points[index + 1]!; + const t = (progress - fraction(points, index)) / Math.max(0.000001, fraction(points, index + 1) - fraction(points, index)); + return interpolatePoint(from, to, t); +} + export function surfaceTrail(points: readonly SurfacePoint[], progress: number, maxLength: number): SurfacePoint[] { progress = Math.max(0, Math.min(1, progress)); const head = surfacePathPoint(points, progress); const trail = [head]; let remaining = maxLength; - let start = Math.floor(progress * (points.length - 1)); - if (points[0]!.progress !== undefined) { - start = 0; - while (start < points.length - 1 && points[start + 1]!.progress! <= progress) start += 1; - } - for (let index = Math.min(points.length - 1, start); index >= 0 && remaining > 0; index -= 1) { + let start = points.length - 1; + while (start > 0 && fraction(points, start) > progress) start -= 1; + for (let index = start; index >= 0 && remaining > 0; index -= 1) { const next = points[index]!; const previous = trail[trail.length - 1]!; - if (previous.breakBefore) { - if (Number.isFinite(maxLength)) break; - trail.push(next); - continue; - } const distance = Math.hypot(next.x - previous.x, next.y - previous.y); - if (distance < 0.001) { previous.breakBefore ||= next.breakBefore; continue; } - trail.push(distance > remaining ? surfacePathPoint([previous, next], remaining / distance) : next); + if (distance < 0.001) continue; + trail.push(distance > remaining ? interpolatePoint(previous, next, remaining / distance) : next); remaining -= distance; } - trail.reverse(); - return points[0]!.progress === undefined ? trail : trail.map((point) => ({ ...point, progress: undefined })); + return trail.reverse().map((point) => { + if (point.progress === undefined) return point; + const local = { ...point }; + delete local.progress; + return local; + }); } export function traceSurfacePath(context: CanvasRenderingContext2D, points: readonly SurfacePoint[]): void { context.beginPath(); points.forEach((point, index) => { - if (index === 0 || point.breakBefore) context.moveTo(point.x, point.y); + if (index === 0) context.moveTo(point.x, point.y); else context.lineTo(point.x, point.y); }); } diff --git a/web/src/trafficVisuals.ts b/web/src/trafficVisuals.ts index cc958e0..76ab68f 100644 --- a/web/src/trafficVisuals.ts +++ b/web/src/trafficVisuals.ts @@ -9,12 +9,7 @@ export const ROUTE_BRIGHT_AGE_MS = 60 * 60_000; export const ROUTE_MAX_AGE_MS = 24 * 60 * 60_000; export const PACKET_KIND_COLORS: Readonly> = { - Advert: '#4de7c4', - Trace: '#ffd15a', - Text: '#ff75b5', - ACK: '#78cfff', - Control: '#a78bfa', - Other: '#9caebd' + Advert: '#4de7c4', Trace: '#ffd15a', Text: '#ff75b5', ACK: '#78cfff', Control: '#a78bfa', Other: '#9caebd', }; export const PACKET_KIND_SIGNATURES: Readonly> = { diff --git a/web/src/types.ts b/web/src/types.ts index de4e617..5dce6c5 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -87,3 +87,8 @@ export type HelloV2 = { seq: number; bootId: string }; export type NodeEventV2 = { seq: number; node: NodeV2 }; export type StatusEventV2 = { seq: number; status: StatusV2 }; export type ResetV2 = { seq: number; bootId: string }; + +export interface PublicMapConfig { + schemaVersion: 1; + basemap: { provider: 'openfreemap' | 'carto'; cartoBrowserKey?: string }; +}