mirror of
https://github.com/n30nex/CartoLite-Server.git
synced 2026-10-22 13:11:36 +00:00
Add shared visual controls and native prebuilt worldwide Docker releases
This commit is contained in:
1 parent
198fbef34d
commit
a2f1a55ed0
43 files changed
+1541
-459
No files matched your search
+5
-4
@@ -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
|
||||
|
||||
+176
-20
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
+2
-4
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
+2
-13
@@ -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}
|
||||
@@ -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" <<EOF
|
||||
CARTOLITE_IMAGE=$1
|
||||
CARTOLITE_BIND_ADDR=127.0.0.1
|
||||
CARTOLITE_PORT=39479
|
||||
MQTT_BROKER_URL=tcp://$broker:1883
|
||||
MQTT_TOPIC=meshcore/#
|
||||
MQTT_CLIENT_ID=cartolite-install-ci
|
||||
EOF
|
||||
}
|
||||
cleanup() {
|
||||
docker rm -f "$broker" >/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.'
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
|
||||
+8
-1
@@ -98,6 +98,7 @@
|
||||
<span>3D</span>
|
||||
</button>
|
||||
<button id="map-labels-button" class="control-button" type="button" aria-label="Place labels" aria-pressed="true"><span>Place labels</span></button>
|
||||
<button id="buildings-button" class="control-button" type="button" aria-label="Buildings" aria-pressed="false"><span>Buildings</span></button>
|
||||
<button id="node-labels-button" class="control-button" type="button" aria-label="Node labels" aria-pressed="true"><span>Node labels</span></button>
|
||||
<button id="roads-button" class="control-button" type="button" aria-label="Roads" aria-pressed="true"><span>Roads</span></button>
|
||||
<button id="live-packets-button" class="control-button" type="button" aria-label="Live packets" aria-pressed="true"><span>Live packets</span></button>
|
||||
@@ -113,7 +114,13 @@
|
||||
</select>
|
||||
</label>
|
||||
<label class="map-slider" for="route-opacity"><span>Route strength</span><output id="route-opacity-output" for="route-opacity">80%</output><input id="route-opacity" type="range" min="20" max="100" step="5" value="80" /></label>
|
||||
<label class="map-slider" for="terrain-relief"><span>Topo relief</span><output id="terrain-relief-output" for="terrain-relief">75%</output><input id="terrain-relief" type="range" min="0" max="100" step="5" value="75" /></label>
|
||||
<label class="map-slider" for="terrain-relief"><span>Topo shading</span><output id="terrain-relief-output" for="terrain-relief">75%</output><input id="terrain-relief" type="range" min="0" max="100" step="5" value="75" /></label>
|
||||
<details class="camera-settings"><summary>Desktop 3D camera</summary>
|
||||
<button id="north-button" class="control-button reset-appearance" type="button">↑ Reset north</button>
|
||||
<label class="map-slider" for="camera-pitch"><span>Camera pitch</span><output id="camera-pitch-output" for="camera-pitch">50°</output><input id="camera-pitch" type="range" min="0" max="65" step="1" value="50" /></label>
|
||||
<label class="map-slider" for="terrain-height"><span>Terrain height</span><output id="terrain-height-output" for="terrain-height">1×</output><input id="terrain-height" type="range" min="0.5" max="2" step="0.1" value="1" /></label>
|
||||
<p class="display-note">Height changes the terrain’s visual scale. Buildings use approximate mapped heights, not antenna heights.</p>
|
||||
</details>
|
||||
<button id="reset-appearance" class="control-button reset-appearance" type="button">Restore display defaults</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Generated
+2
-2
@@ -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"
|
||||
},
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "cartolite-server-web",
|
||||
"version": "0.4.1",
|
||||
"version": "0.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+32
-11
@@ -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<void> {
|
||||
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<PublicMapConfig> | 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' : '<a href="https://openfreemap.org">OpenFreeMap</a> <a href="https://openmaptiles.org">© OpenMapTiles</a> Data from <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
||||
}
|
||||
},
|
||||
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 {
|
||||
|
||||
@@ -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=');
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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 ? `<label>Scene<select data-display="basemap" aria-label="Scene style"><option value="dark">Night</option><option value="light">Daylight</option><option value="streets">Paper</option></select></label><label>Interface<select data-display="theme" aria-label="Interface theme"><option value="map">Match scene</option><option value="dark">Dark</option><option value="light">Light</option></select></label>` : ''}
|
||||
<label>Route style<select data-display="preset" aria-label="Route style">${Object.keys(ROUTE_PRESETS).map((id) => `<option value="${id}">${id[0]!.toUpperCase()}${id.slice(1)}</option>`).join('')}<option value="custom">Custom</option></select></label>
|
||||
<div class="route-preview" aria-label="Silent route style preview"><i></i><b></b><span>Preview · silent</span></div>
|
||||
<details class="display-advanced"><summary>Advanced route styling</summary>
|
||||
<label>Line pattern<select data-display="pattern" aria-label="Line pattern"><option value="solid">Solid</option><option value="dashed">Dashed</option><option value="dotted">Dotted</option></select></label>
|
||||
${([
|
||||
['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]) => `<label class="display-slider">${label}<output data-output="${key}" data-unit="${unit}"></output><input data-display="${key}" aria-label="${label}" type="range" min="${min}" max="${max}" step="${step}"></label>`).join('')}
|
||||
<button class="display-reset" type="button">Reset route styling</button>
|
||||
</details><p class="display-note">Shared with Map and Netgraph in this browser.</p>`;
|
||||
const oldOpacity = parent.querySelector<HTMLInputElement>('#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<HTMLInputElement | HTMLSelectElement>('[data-display]')) input.value = String(settings[input.dataset.display as keyof DisplayPreferences]);
|
||||
for (const output of section.querySelectorAll<HTMLOutputElement>('[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<HTMLElement>('.controls')!;
|
||||
const host = document.createElement('div');
|
||||
host.className = 'display-disclosure popover-control';
|
||||
host.innerHTML = '<button id="display-button" class="control-button" type="button" aria-expanded="false" aria-controls="display-panel">◐ <span>Display</span></button><section id="display-panel" class="display-panel glass" aria-label="Display settings" hidden><header><strong>Display</strong><button type="button" aria-label="Close display settings">×</button></header></section>';
|
||||
controls.prepend(host);
|
||||
const button = host.querySelector<HTMLButtonElement>('#display-button')!;
|
||||
const panel = host.querySelector<HTMLElement>('#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);
|
||||
}
|
||||
@@ -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<keyof typeof dark>) {
|
||||
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);
|
||||
}
|
||||
@@ -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<string, string> = {
|
||||
...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<DisplayPreferences> { 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<DisplayPreferences> : {};
|
||||
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<Storage, 'getItem'>): 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<DisplayPreferences>): 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<HTMLElement>('[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);
|
||||
}
|
||||
}
|
||||
+56
-12
@@ -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<HTMLButtonElement>('heatmap-button');
|
||||
const clustersButton = required<HTMLButtonElement>('clusters-button');
|
||||
const hillshadeButton = required<HTMLButtonElement>('hillshade-button');
|
||||
const terrainButton = required<HTMLButtonElement>('terrain-button');
|
||||
const buildingsButton = required<HTMLButtonElement>('buildings-button');
|
||||
const cameraPitch = required<HTMLInputElement>('camera-pitch');
|
||||
const terrainHeight = required<HTMLInputElement>('terrain-height');
|
||||
const soundButton = required<HTMLButtonElement>('sound-button');
|
||||
const soundControl = required<HTMLElement>('sound-button').parentElement as HTMLElement;
|
||||
const soundPanel = required<HTMLElement>('sound-panel');
|
||||
@@ -83,7 +90,9 @@ const followCountdown = required<HTMLOutputElement>('follow-countdown');
|
||||
const followProgress = required<HTMLProgressElement>('follow-progress');
|
||||
const followPause = required<HTMLButtonElement>('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<void> {
|
||||
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<void> {
|
||||
},
|
||||
);
|
||||
mapView = liveMap;
|
||||
const showMapNotice = attachMapNotice(liveMap.map, mapElement);
|
||||
if (configUnavailable) showMapNotice('Map settings could not load. Showing the default map.');
|
||||
const packetCanvas = required<HTMLCanvasElement>('packet-canvas');
|
||||
const liveAnimator = new PacketAnimator(liveMap.map, packetCanvas);
|
||||
animator = liveAnimator;
|
||||
@@ -266,14 +279,20 @@ async function start(): Promise<void> {
|
||||
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<void> {
|
||||
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<HTMLButtonElement>('north-button').addEventListener('click', () => {
|
||||
pauseLiveFollow();
|
||||
liveMap.resetNorth();
|
||||
persistUiPreference({ terrainBearing: 0 });
|
||||
});
|
||||
required<HTMLButtonElement>('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<void> {
|
||||
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<void> {
|
||||
|
||||
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<T extends HTMLElement>(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<HTMLOutputElement>('camera-pitch-output').value = `${cameraPitch.value}°`;
|
||||
required<HTMLOutputElement>('terrain-height-output').value = `${terrainHeight.value}×`;
|
||||
required<HTMLOutputElement>('route-opacity-output').value = `${routeOpacity.value}%`;
|
||||
required<HTMLOutputElement>('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';
|
||||
|
||||
+82
-24
@@ -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<string, unknown>();
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<HTMLElement>('netgraph-app');
|
||||
const stage = required<HTMLElement>('netgraph-stage');
|
||||
const graphCanvas = required<HTMLCanvasElement>('graph-canvas');
|
||||
|
||||
@@ -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<StateV2>, 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';
|
||||
|
||||
@@ -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<string, unknown>, 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<MapLibreMap['setPaintProperty']>[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<MapLibreMap['setPaintProperty']>[2];
|
||||
map.setPaintProperty(layer.id, paintProperty, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-20
@@ -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');
|
||||
});
|
||||
|
||||
+17
-1
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
+215
-216
@@ -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<string, WebGLUniformLocation>; 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<ArrayBufferLike> = new Float32Array();
|
||||
private gl?: GL;
|
||||
private resources?: Resources;
|
||||
private routes: readonly Feature<LineString>[] = [];
|
||||
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<string, WebGLUniformLocation> = {};
|
||||
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<LineString>[]): 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<LineString>[]): 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<LineString>[]): 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<LineString>[]): 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)); }
|
||||
+1
-1
@@ -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)); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<Record<PacketKind, string>> = {
|
||||
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<Record<PacketKind, PacketSignature>> = {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Reference in new issue
Block a user