Upgrade visual styling and ship prebuilt worldwide Docker images (#12)

* Add shared visual controls and native prebuilt worldwide Docker releases

* Validate shared styles, synthetic buildings, and date-line-safe packet geometry

* Document the release and bound decorative work in dense route views

* Remove the retired terrain-history collection cache

* Address review feedback on terrain updates, legends, and responsive buildings

* Keep the region and packet legends separate on compact screens

* Separate building, camera, and responsive browser checks

* Wait for the complete synthetic snapshot before upgrade checks
This commit is contained in:
n30nex authored and GitHub committed 2026-09-12 18:36:06 -04:00
1 parent 198fbef34d
commit f7941b7a95
57 files changed
+2094 -530

No files matched your search

+5 -4
View File
@@ -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
View File
@@ -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
+121
View File
@@ -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
+3 -1
View File
@@ -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.
+8
View File
@@ -1,5 +1,13 @@
# Changelog
## 0.5.0 - 2026-09-12
- Add shared Map/Netgraph themes, six route presets, advanced styling controls and readable light/dark palettes.
- Add desktop buildings and camera controls, sharp configurable route strokes and adaptive date-line-safe terrain animation.
- Default to key-free OpenFreeMap; provide optional CARTO browser configuration at runtime without embedding keys in images.
- Publish tested and attested amd64/arm64 Docker images, a pull-only Compose installation, source archives and checksum manifests.
- Validate native installations, checkpoint-preserving upgrades, anonymous image pulls, privacy, performance and browser behavior in Actions.
## 0.4.1 - 2026-09-12
- Keep desktop node details above live packet trails and map shading so traffic cannot obscure the card. Preserve the toolbar and phone sheet layer order.
+2 -4
View File
@@ -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
+19 -12
View File
@@ -49,7 +49,8 @@ CartoLite Server renders public MeshCore activity as a living map: packet trails
| | |
|---|---|
| **Live topology** | Vector geography, packet trails, route glow, packet-type heat, clusters, hillshade, and 3D terrain. |
| **Live topology** | Vector geography, six route styles, packet-type heat, clusters, terrain and desktop 3D buildings. |
| **Make it yours** | Shared Map/Netgraph themes, preset and advanced line controls, readable light/dark palettes, and saved display choices. |
| **Find and inspect** | Search downloaded node labels, open node details, and browse neighbours sorted by last heard. |
| **Musical traffic** | A grouped choice of [30 sound voices](docs/sound-voices.md), using opt-in native browser audio and visible live hops only. |
| **Works worldwide** | Accept valid coordinates anywhere, or apply an exact region allowlist for a shared broker. |
@@ -64,23 +65,29 @@ See the exact guarantees in the [privacy documentation](docs/privacy.md) and the
## Quick start
You need Docker Engine, Docker Compose v2, a standard MeshCore MQTT feed, and your own CARTO Basemaps browser key.
You need Docker Engine, Docker Compose v2, and a standard MeshCore MQTT feed. The published image supports Intel/AMD servers and ARM64 hosts. OpenFreeMap works without an account or map key.
> [!IMPORTANT]
> No API keys, passwords, or tokens are bundled with this repository. `.env` and `.secrets/` are excluded from Git and the Docker build context.
1. Copy `.env.example` to `.env`.
2. Create `.secrets/carto-basemap-api-key` and place only your CARTO browser key in it.
3. Set `MQTT_BROKER_URL`, `MQTT_TOPIC`, and any optional MQTT credentials in `.env`.
4. Build and start your instance:
Download the two installation files into a new folder:
```sh
docker compose build --pull
docker compose up -d
curl -fLO https://github.com/n30nex/CartoLite-Server/releases/latest/download/compose.yml
curl -fL https://github.com/n30nex/CartoLite-Server/releases/latest/download/default.env.example -o .env
```
Set `MQTT_BROKER_URL`, `MQTT_TOPIC`, and any required MQTT username/password in `.env`, then start:
```sh
docker compose pull
docker compose up -d --no-build
curl --fail http://127.0.0.1:8080/readyz
```
The default bind is loopback-only. Keep it that way behind a TLS reverse proxy, or deliberately set `CARTOLITE_BIND_ADDR=0.0.0.0` for trusted LAN access. The [deployment guide](docs/deployment.md) covers production configuration and updates.
Open [the map](http://localhost:8080/) or [Netgraph](http://localhost:8080/netgraph/). In Windows PowerShell, use `curl.exe` for the download commands. Existing installations should follow the [upgrade guide](docs/deployment.md#upgrades) and retain their current `.env` and data volume.
The default bind is loopback-only. Keep it behind a TLS reverse proxy for public operation, or deliberately set `CARTOLITE_BIND_ADDR=0.0.0.0` for trusted LAN access.
## MQTT input
@@ -97,11 +104,11 @@ meshcore/<region>/<publisher-key>/status
The server accepts the standard packet/status JSON or raw packet hex published by a MeshCore MQTT bridge. Unknown topic shapes and malformed messages are counted and ignored.
## Basemap key
## Map providers
Docker supplies the CARTO key to the web build through a BuildKit secret. It stays out of source, Compose, image labels, and build logs. Like any browser map credential, it is visible to visitors in tile requests after deployment, so use a CARTO project key restricted for your site.
OpenFreeMap supplies the default vector map and buildings. Terrain comes from Mapterhorn. Both are attributed in the map.
CartoLite Server does not publish a universal prebuilt image because each operator builds the frontend with their own browser-visible basemap key.
CARTO remains optional through `compose.carto.yml` and a mounted browser-key file. Configuration is read at startup, so the same published image works for every operator. Browser keys are visible to visitors by design; MQTT credentials remain server-only. See the [provider configuration](docs/deployment.md#optional-carto-provider).
## Project scope
+1 -1
View File
@@ -1 +1 @@
0.4.1
0.5.0
+1 -1
View File
@@ -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()
+43
View File
@@ -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")
}
}
+38 -12
View File
@@ -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())
}
}
+27 -3
View File
@@ -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)
})
}
+4 -4
View File
@@ -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)
}
+11
View File
@@ -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}
+11
View File
@@ -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
View File
@@ -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}
+4 -2
View File
@@ -21,8 +21,10 @@ The checkpoint contains current topology and private resolver material needed af
The browser treats `/api/state` as authoritative and `/api/events` as a bounded low-latency delta stream. Boot changes, sequence gaps, expired replay cursors, visibility resume, and network restoration trigger one coalesced state refresh and stream replacement.
MapLibre owns stable geography, nodes, heat, and route geometry. The historical route texture covers the complete Web Mercator world at overview zooms; detail zooms use the same exact straight route segments in a compact WebGL line buffer. Canvas owns only transient packet cores, wakes, sparks, handoffs, and 45-second residue. The browser keeps map and sound settings locally.
MapLibre owns stable geography, nodes, heat, buildings and route geometry. Batched WebGL stroke meshes support consistent widths, casings and patterns without enlarged route textures. Terrain paths use bounded adaptive samples; the same projection governs packet movement and route inspection. Date-line breaks never become extra hops or cross-world strokes. Canvas owns transient packet motion and configurable 0–45-second residue. One browser-local display profile is shared by Map and Netgraph; sound preferences remain independent.
`GET /api/config` is a separate schema-1 browser configuration response, containing only the selected provider and an optional public CARTO browser key. The server reads that key from a mounted file at startup. Published images contain no operator or CI key; OpenFreeMap is the default. Native amd64/arm64 images are tested and attested before digest promotion.
## Worldwide visual refresh
The map and Netgraph share recovery, keyboard Finder, packet timing, colours and hop audio. Terrain paths use bounded geographic samples with cache invalidation on camera and DEM changes; date-line strokes are split without creating new hops. Netgraph derives Maidenhead groups locally from public coordinates and uses adaptive Canvas2D effects. No country data, database, new service, geocoder, or public schema fields are required. See [the port notes](upstream-refresh-0.2.0.md).
The map and Netgraph share recovery, keyboard Finder, packet timing, palettes, route styles and hop audio. Netgraph derives Maidenhead groups locally from public coordinates and uses adaptive Canvas2D effects. The traffic schema stays at v2. No country data, database, new service or geocoder is introduced. The earlier [0.2.0 port notes](upstream-refresh-0.2.0.md) describe that historical source release.
+4 -2
View File
@@ -4,9 +4,11 @@
All topology and activity originate from the operator-configured MeshCore MQTT feed. The server sends no traffic to a shared CartoLite service. MQTT credentials stay inside the server container environment and are never returned by an endpoint.
## CARTO vector basemap
## Vector maps and buildings
The browser uses a minimal CARTO vector style for land, water, boundaries, roads, and place labels. Each operator supplies a CARTO Basemaps API key during the image build. The client makes normal TileJSON, vector PBF, and glyph requests directly to CARTO. There is no raster basemap fallback.
The default map uses OpenFreeMap vector tiles and glyphs without registration or a key. CartoLite supplies its own Night, Daylight and Streets paint styles. The same OpenMapTiles-compatible source supplies building footprints and approximate heights; no precise building or antenna height is invented when data is missing.
Operators can choose CARTO using the runtime secret override described in [Deployment](deployment.md). Its browser-visible key is provided through `/api/config`, never built into the published image. Buildings still use OpenFreeMap in this mode. Requests go directly to the attributed provider and contain no MeshCore traffic. There is no raster basemap fallback.
## Optional terrain
+47 -23
View File
@@ -1,44 +1,68 @@
# Deployment
## Build and start
## Install the published image
Copy `.env.example` to `.env`, create `.secrets/carto-basemap-api-key`, and configure your MQTT broker. Then run:
Docker Engine and Compose v2 are required. Published images support `linux/amd64` and `linux/arm64`, including 64-bit Raspberry Pi hosts. No Node, Go, source build or map-provider account is required.
In a new directory, download `compose.yml` and `default.env.example` from the latest release. Save the environment example as `.env`, then set your MQTT broker, topic and any required username/password. Choose a unique `MQTT_CLIENT_ID` for each instance.
```sh
docker compose build --pull
docker compose up -d
docker compose pull
docker compose up -d --no-build
docker compose ps
curl --fail http://127.0.0.1:8080/healthz
curl --fail http://127.0.0.1:8080/readyz
```
The BuildKit secret injects the browser-visible CARTO project key into the compiled client without placing it in source, Compose, image labels, or build logs. Verify that TileJSON, vector PBF, and glyph requests authorize from the final public origin.
The map is at `http://localhost:8080/`; Netgraph is at `/netgraph/`. Default maps and buildings use OpenFreeMap without a key. `/api/config` contains only public map-provider settings; the traffic API remains schema v2.
## Configuration
| Variable | Default | Purpose |
|---|---:|---|
| `CARTOLITE_BIND_ADDR` | `127.0.0.1` | Published host address |
| `CARTOLITE_PORT` | `8080` | Published host port |
| `MQTT_BROKER_URL` | required | `tcp`, `ssl`, `ws`, or `wss` broker URL |
| `MQTT_TOPIC` | `meshcore/#` | Broker subscription |
| `MQTT_CLIENT_ID` | `cartolite-server` | Unique MQTT client ID |
| `MQTT_USERNAME` / `MQTT_PASSWORD` | empty | Optional credentials; set both or neither |
| `REGION_ALLOWLIST` | empty | Accept all regions, or exact comma-separated labels |
| `MQTT_INGEST_QUEUE_SIZE` | `4096` | Bounded queue, from 64 through 65536 |
| `STATE_PATH` | `/data/state-v1.json` | Atomic checkpoint path |
| `LOG_LEVEL` | `info` | `debug`, `info`, `warn`, or `error` |
|---|---|---|
| `CARTOLITE_IMAGE` | versioned `ghcr.io/n30nex/cartolite-server` image | Published tag or exact release-manifest digest |
| `CARTOLITE_BIND_ADDR` | `127.0.0.1` | Host bind address |
| `CARTOLITE_PORT` | `8080` | Host HTTP port |
| `MQTT_BROKER_URL` | required | Your MeshCore broker URL (`tcp`, `ssl`, `ws`, or `wss`) |
| `MQTT_TOPIC` | `meshcore/#` | Subscription filter |
| `MQTT_CLIENT_ID` | `cartolite-server` | Must be unique per instance |
| `MQTT_USERNAME` / `MQTT_PASSWORD` | empty | Configure both when authentication is required |
| `REGION_ALLOWLIST` | empty | Accept all valid regions, or exact comma-separated labels |
| `MQTT_INGEST_QUEUE_SIZE` | `4096` | Bounded ingest queue |
| `STATE_PATH` | `/data/state-v1.json` | Atomic checkpoint |
| `LOG_LEVEL` | `info` | Application logging |
`CARTO_BASEMAP_API_KEY_FILE` points Compose at the BuildKit secret file. Do not place the key in `.env`.
Use the loopback bind behind a TLS reverse proxy for public operation. Set `CARTOLITE_BIND_ADDR=0.0.0.0` explicitly for appropriate LAN access. Keep the non-root user, read-only filesystem, dropped capabilities, data volume, health checks and resource/log limits supplied by Compose.
## Public operation
## Optional CARTO provider
Use a TLS reverse proxy and forward to the loopback port. Do not expose `.env`, `.secrets`, or the data volume. Keep the supplied read-only filesystem, non-root user, dropped capabilities, memory limit, process limit, healthcheck, and bounded logs.
Download `compose.carto.yml` from the same release. Store your site-restricted CARTO browser key in `.secrets/carto-basemap-api-key`. The file must be readable by the container’s UID 65532; on Linux, give that UID ownership and mode 0400. A custom host path can be set with `CARTO_BASEMAP_API_KEY_FILE` in `.env`.
`/healthz` reports liveness. `/readyz` requires a healthy checkpoint, connected and subscribed MQTT client, healthy queue, and zero drops. Normal RF silence is ready.
```sh
docker compose -f compose.yml -f compose.carto.yml up -d --no-build
```
Before an upgrade, copy the checkpoint and record the current image ID. Build the new source revision, recreate only the `cartolite` service, and verify health, readiness, schema v2 privacy, SSE traffic, vector resources, desktop/mobile layout, and container hardening. Roll back to the recorded image and checkpoint if any gate fails.
The override mounts the file at `/run/secrets/carto_basemap_api_key` and selects `BASEMAP_PROVIDER=carto`. The key is read at startup; restart the service after changing it. It is intentionally visible in browser configuration/tile requests, just as with the previous compiled browser key. It is never included in the published image. MQTT credentials are never returned by `/api/config`. OpenFreeMap remains the building source when CARTO is selected.
## Upgrade to 0.2.0
## Upgrades
Use the v0.2.0 source tag and set CARTOLITE_VERSION=0.2.0 in your existing .env. Keep your broker settings and secret files. Rebuild with your own existing BuildKit basemap secret, retain the MQTT and optional region configuration, and preserve the checkpoint and previous image before recreating the service. Both the map and /netgraph/ use the same public API v2 feed. Verify 3D enables Topo, world locations remain available, and Netgraph groups nodes by global grid squares.
Record the current image/digest and keep a private, verified checkpoint backup before upgrading. Retain the current `.env`, project name and `cartolite-data` volume. Download the new Compose file without replacing your environment file, set `CARTOLITE_IMAGE` to the new version or release-manifest digest, then run `docker compose pull` and `docker compose up -d --no-build`.
When upgrading from the 0.4.x source distribution, replace `cartolite-server:local` in `.env` with the published image. The default becomes key-free OpenFreeMap; add the CARTO override above to keep your existing provider. Old build-only version variables are no longer required. The checkpoint format is unchanged.
Verify health/readiness, retained nodes and routes, live traffic, Map and Netgraph after the upgrade. Roll back by selecting the recorded image and recreating only the CartoLite service; use the verified checkpoint recovery copy if state recovery is necessary. Never run `docker compose down -v` against an installation you intend to keep.
## Source builds
A source checkout can use `compose.build.yml` explicitly:
```sh
docker compose -f compose.yml -f compose.build.yml build --pull
docker compose -f compose.yml -f compose.build.yml up -d --no-build
```
Runtime provider configuration is identical for source-built and published images. Maintainer builds, tests and releases run only in GitHub Actions; no workstation build is used for a release.
## Release verification
Actions tests native amd64 and arm64 images, including synthetic MQTT/privacy/load checks and a checkpoint-preserving upgrade from 0.4.1. Browser checks run against the same frontend on amd64. Main publishes and attests the tested platform digests, assembles their index without rebuilding, and verifies anonymous pulls and the Compose recipe on both architectures. Tag releases promote that exact index and publish Compose files, source archives, a release manifest and SHA256SUMS.
+25 -9
View File
@@ -1,17 +1,33 @@
# Map appearance and Live Follow
# Map, Netgraph, and display settings
The toolbar keeps Live Follow, Find, Map, Sound and Home within reach. Map opens a compact, scrollable menu on desktop, tablet and phone. Click outside it, press Escape or use its close button to return to the map. Escape restores keyboard focus to the menu button.
Map keeps its everyday controls in the toolbar. Open **Map** for layers, appearance and camera settings. Netgraph has a matching **Display** menu. Menus scroll within the viewport; Escape closes them and returns keyboard focus to their button.
Choose Night, Daylight or Streets. These are CartoLite vector styles using the existing CARTO streets tiles and authorization. Colours update in place: the camera, live feed, route sources, terrain, selection and layer choices stay alive. Interface colours can match the map or remain light or dark. Satellite imagery is not bundled because it requires a separately authorized provider; this release introduces no extra map account, external style URL or tile credential.
## Shared appearance
The menu includes route, heatmap, cluster, Topo, 3D, place-label, node-label, road and live-packet toggles. Route strength and Topo relief have independent sliders. Turning off live packets also pauses their sounds. Display choices stay in this browser; Restore display defaults resets them without losing the current camera view. Turning on 3D still enables Topo automatically.
Night, Daylight and Streets remain available on Map. Netgraph uses the matching Night, Daylight and warm Paper backgrounds. One browser-local profile carries the scene, interface theme and route styling between both pages, including already-open tabs. Each view keeps its own camera/layout, and changing appearance preserves the current selection and live feed. Interface panels may follow the scene or stay explicitly light/dark. Packet and node ink follows the actual scene background.
Wide-zoom history now uses exact GPU line segments at every flat-map zoom. The previous fixed-resolution texture enlarged and softened those lines as the camera moved. Removing that texture also removes its canvas allocation and rebuild work. Live residue gets narrower and quieter at wide zooms; terrain routes retain native MapLibre draping. These lines visualize confirmed hops, not propagation or mountain occlusion.
Choose **Crisp**, **Neon**, **Dashed**, **Dotted**, **Ribbon** or **Comet**. Crisp is the default, with clear strokes and restrained glow. **Advanced route styling** controls pattern, width, opacity, glow, packet size, trail length and after-trail duration (0–45 seconds). Editing a slider selects Custom. The preview is silent. Reset route styling restores Crisp; the map’s full display reset also restores its layer choices without erasing sound preferences or the camera location.
Live Follow holds each packet for ten seconds. A small card shows its public node labels, sanitized packet kind, confirmed hop count and countdown. Observer-only packets say “heard here”; they do not acquire an inferred route. New traffic is queued during the hold, with nearby traffic preferred. Camera transitions are gradual, preserve 3D orientation and avoid further movement when the packet already fits. The starting zoom is retained as the detail limit so repeated long routes cannot permanently ratchet the camera outward.
Dense overview maps automatically reduce decorative glow and casings while preserving every route, line width and pattern. Historical paths remain still. Only received live packets drive motion and sound. Line strokes stay sharp as the map zooms, and colors distinguish the same sanitized packet categories in every scene. Labels remain readable as nodes age; node appearance and the inspector still communicate freshness.
Dragging, zooming, rotating, inspecting a node or hiding the tab pauses follow. Resume starts with fresh activity. Closing the card stops follow. When the ten-second hold ends with no replacement, the card and its map highlight clear and wait for a new packet instead of retaining stale activity. The card does not display packet IDs, keys, paths, messages or resolver details.
## Buildings and terrain
Validation uses synthetic fixtures in GitHub Actions: saved settings, keyboard controls, menu bounds, theme changes, route geometry, a ten-second countdown under incoming traffic, pause/resume, terrain and worldwide date-line regressions. Existing performance and privacy budgets remain in place.
**Buildings** shows mapped footprints at neighborhood zoom. Desktop 3D introduces height where the source provides it, respecting buildings that should not be extruded. Building heights are approximate map data, not radio or antenna heights.
References: [CARTO vector basemaps and authorization](https://carto.com/basemaps/apikey/), [MapLibre layer properties](https://maplibre.org/maplibre-style-spec/layers/), [MapLibre camera options](https://maplibre.org/maplibre-gl-js/docs/API/type-aliases/CameraForBoundsOptions/).
Entering **3D** enables **Topo** and **Buildings**; either can then be switched off independently. Returning to 2D retains building footprints if Buildings remains enabled. Desktop camera settings offer a compass/reset north, pitch and terrain-height adjustment. New 3D views start near 50° pitch and 1× terrain height. Desktop orientation is remembered. Phones keep their existing optional terrain mode and fast 2D defaults; detailed camera controls and extrusions target the desktop layout.
Topo shading and terrain height have separate controls. Shading becomes gentler at close urban zooms. Packet paths and ground rings follow the terrain/camera projection; bounded adaptive sampling keeps travel timing independent of sample spacing. Paths remain readable through hills and buildings. Terrain and building data do not establish radio coverage, obstruction or line of sight.
## Live Follow
Live Follow holds each activity for ten seconds. Its card shows public node labels, sanitized packet kind, confirmed hop count and countdown. Observer-only activity says “heard here” and gains no inferred route. New traffic queues during the hold, with nearby activity preferred. Camera moves remain gradual and preserve 3D orientation.
Dragging, zooming, rotating, inspecting a node or hiding the tab pauses follow. Resume starts with fresh activity. When no replacement arrives, the card clears and waits. Closing it stops follow. No packet IDs, keys, messages or resolver details appear in the card.
## Availability and validation
A map notice explains unavailable tile details and offers Retry; traffic data and Netgraph remain usable independently. Display settings are optional browser-local storage. No accounts, analytics or server-side preference database are added.
Synthetic Actions checks cover themes, presets, storage migration and cross-tab updates, keyboard/menu behavior, actual building pixels, terrain/date-line projection, route/packet visibility, bounded performance and the public privacy contract.
OpenFreeMap supplies the default geography and building layer. Optional CARTO configuration is described in [Deployment](deployment.md). Netgraph areas remain coordinate-derived global grid squares.
+4 -2
View File
@@ -1,11 +1,13 @@
# Privacy boundary
CartoLite Server publishes only schema v2 nodes, routes, status, and sanitized packet kinds. It never publishes full node or observer keys, raw paths, packet hashes, packet payloads, message text, credentials, resolver reasons, or MQTT region labels.
The traffic API publishes schema v2 nodes, routes, status, and sanitized packet kinds. It never publishes full node or observer keys, raw paths, packet hashes, packet payloads, message text, server credentials, resolver reasons, or MQTT region labels.
The separate `/api/config` response contains only public map-provider settings. Optional CARTO browser keys are intentionally client-visible; MQTT credentials and other server configuration are never included. The default OpenFreeMap provider needs no key, and published images contain no operator or CI map key.
The Node Finder searches already-downloaded public labels in the browser. Queries are neither sent nor persisted. Inspector content is built with DOM text nodes rather than HTML insertion.
The browser stores only separate desktop/mobile views, map layer and route-window choices, and `{enabled, volume, scene}` sound preferences. Remembered sound still requires a fresh user gesture. There are no cookies, accounts, analytics, advertising identifiers, or visitor logs.
Enabling Topography or 3D causes normal attributed elevation-tile requests to Mapterhorn. The CARTO vector basemap makes normal browser requests using the operator's browser-visible project key. Neither request includes MeshCore traffic data.
Topography and 3D make attributed elevation-tile requests to Mapterhorn. Default geography/buildings use OpenFreeMap; optional CARTO geography uses the operator's public browser key. These tile requests contain no MeshCore traffic data or added visitor identifier.
The atomic server checkpoint contains current topology and private resolver keys needed for restart. Treat it as private operational data: back it up encrypted, never publish it, and never attach a live copy to an issue.
+8
View File
@@ -1,5 +1,13 @@
# Public API v2
Traffic endpoints remain schema v2. A separate `GET /api/config` endpoint returns display configuration with `Cache-Control: no-store`:
```json
{"schemaVersion":1,"basemap":{"provider":"openfreemap"}}
```
When the operator selects CARTO, `basemap.provider` is `carto` and `basemap.cartoBrowserKey` contains the intentionally browser-visible map key read at startup. No other configuration fields or MQTT credentials are returned.
All endpoints are public and intentionally sanitized. State responses use `Cache-Control: no-store`.
## Endpoints
+8 -2
View File
@@ -1,6 +1,6 @@
# Audit roadmap
Updated: 9 September 2026. This roadmap turns the end-to-end audit into tracked delivery work. Audit IDs A01–A12 are retained; F IDs track features and R IDs track validation risks.
Updated: 12 September 2026. This roadmap turns the end-to-end audit into tracked delivery work. Audit IDs A01–A12 are retained; F IDs track features and R IDs track validation risks.
This is the worldwide source-release roadmap. Shared work is paired with [CartoLite Canada](https://github.com/n30nex/CartoLite); A04/O1 is tracked upstream and is not an operator rollout for this repository.
@@ -86,4 +86,10 @@ The migration measured 474,482 compressed JS/CSS bytes for Canada and 446,084 fo
For each work package, record its PR, exact checked commit, CI result, counterpart PR or scope exception, and any release/runtime proof. A green candidate PR remains Review until merged; a merged change remains undeployed until an exact release is rolled out and verified.
Preserve public schema v2, conservative route resolution, the single-service/atomic-checkpoint architecture and hardened runtime. Canada keeps its published-image promotion path, geographic scope, Labs and Android. Worldwide remains an independent source distribution with coordinate-derived areas, optional exact region filtering, and no Canada-only data, Labs or Android.
Preserve public schema v2, conservative route resolution, the single-service/atomic-checkpoint architecture and hardened runtime. Canada keeps its published-image promotion path, geographic scope, Labs and Android. Worldwide adds a prebuilt amd64/arm64 image distribution with coordinate-derived areas, optional exact region filtering, and no Canada-only data, Labs or Android.
## Current visual and Docker release package
Active work: Canada 0.14.0 ([PR77](https://github.com/n30nex/CartoLite/pull/77)) and Worldwide 0.5.0 ([PR12](https://github.com/n30nex/CartoLite-Server/pull/12)). This package delivers shared scene/interface themes, six route presets with advanced controls, readable light/dark palettes, adaptive terrain strokes, desktop buildings/camera controls, and Canada region fills/legend. Worldwide additionally gains key-free default maps, runtime provider configuration and tested native Docker image publication.
This covers the theme/preset/contrast parts of F03, line/glow/residue controls in F04, and the camera/shading parts of F05. Their remaining text-size/motion/heat-strength work and the other follow/discovery features remain on the roadmap. R08 gains missing-tile recovery coverage; broader degraded-service scenarios remain tracked. A passing candidate remains unreleased until exact-commit main validation, release verification and applicable live deployment checks complete.
+1 -1
View File
@@ -4,6 +4,6 @@ CartoLite offers [30 packet sound voices](sound-voices.md) using native Web Audi
Sound is opt-in. A browser gesture is always required to create or resume output, including after mobile sleep. The panel shows On, Off, or Tap to Resume and stores only `{enabled, volume, scene}` locally.
Each live hop follows its confirmed geographic segment. In 3D, trails and residue follow sampled terrain while ground rings respond to the camera; historical routes drape over the same terrain. Topo turns on with 3D, with 1.35x elevation exaggeration. Date-line strokes split at the world-map seam without creating extra hops or notes. Its cue uses a sharp packet core, short glow, restrained sparks, relay handoff, destination shimmer, node wake, and bounded 45-second residue. Hiding Routes does not hide recent live traffic.
Each live hop follows its confirmed geographic segment. In 3D, trails and residue follow sampled terrain while ground rings respond to the camera; historical routes drape over the same terrain. Topo turns on with 3D, with adjustable terrain height (1x by default). Date-line strokes split at the world-map seam without creating extra hops or notes. Its cue uses a sharp packet core, short glow, restrained sparks, relay handoff, destination shimmer, node wake, and configurable 0–45-second residue (15 seconds in Crisp). Hiding Routes does not hide recent live traffic.
Adaptive quality reduces secondary detail and backing resolution during bursts but does not discard visible route hops. Reduced-motion mode uses static cues. The renderer prohibits full-map flashes, additive white saturation, camera-relative route geometry, and effects that survive route expiry.
+75
View File
@@ -0,0 +1,75 @@
#!/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
# State snapshots refresh once per second. Wait for all eight fixture nodes and
# three confirmed routes before recording the baseline, not a partial snapshot.
for _ in $(seq 1 60); do
curl --fail --silent http://127.0.0.1:39479/api/state > "$scratch/before.json"
jq -e '(.nodes | length) == 8 and (.routes | length) == 3' "$scratch/before.json" >/dev/null && break
sleep 1
done
jq -e '(.nodes | length) == 8 and (.routes | length) == 3' "$scratch/before.json" >/dev/null || { echo 'The complete synthetic fixture was not observed before upgrade' >&2; exit 1; }
# 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 || { echo 'Upgraded public map configuration is not key-free OpenFreeMap' >&2; exit 1; }
jq -n --slurpfile before "$scratch/before.json" --slurpfile after "$scratch/after.json" '{before:{nodes:($before[0].nodes|length),routes:($before[0].routes|length)},after:{revision:$after[0].status.gitSha,nodes:($after[0].nodes|length),routes:($after[0].routes|length)}}' > "$output/upgrade-comparison.json"
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 || { cat "$output/upgrade-comparison.json"; echo 'Checkpoint identity changed across the upgrade' >&2; exit 1; }
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 || { echo 'Upgraded container identity or hardening failed verification' >&2; exit 1; }
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.'
+1
View File
@@ -20,6 +20,7 @@ test('map options are compact, persistent, and preserve live layers through styl
await page.locator('#node-labels-button').click();
await page.locator('#map-labels-button').click();
await page.locator('#roads-button').click();
await page.locator('.display-advanced > summary').click();
await page.locator('#route-opacity').press('Home');
for (let step = 0; step < 6; step++) await page.locator('#route-opacity').press('ArrowRight');
await page.locator('#terrain-relief').press('Home');
+226
View File
@@ -0,0 +1,226 @@
import { expect, test, type Page } from '@playwright/test';
import { openMapOptions } from './mapControls';
import { visualFixture, visualPacket } from './visualFixtures';
test('shares presets and custom styling across Map and Netgraph without losing context', async ({ page, context }, info) => {
await visualFixture(page);
await page.goto('/');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await page.locator('#find-button').click();
await page.locator('#node-search').fill('Visual Alpha');
await page.getByRole('option', { name: /Visual Alpha/ }).click();
await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id', 'visual-a');
const source = await page.locator('#map').getAttribute('data-route-source-revision');
await openMapOptions(page);
await page.locator('#interface-theme').selectOption('light');
expect(await page.locator('.route-legend-item[aria-label="Trace"] i').evaluate((element) => getComputedStyle(element, '::after').backgroundColor)).toBe('rgb(255, 209, 90)');
await page.locator('#interface-theme').selectOption('map');
for (const preset of ['crisp', 'neon', 'dashed', 'dotted', 'ribbon', 'comet']) {
await page.getByLabel('Route style', { exact: true }).selectOption(preset);
await expect(page.locator('#map')).toHaveAttribute('data-route-preset', preset);
await expect(page.locator('#map')).toHaveAttribute('data-route-source-revision', source!);
}
await page.locator('.display-advanced > summary').click();
await page.getByLabel('Line width', { exact: true }).press('Home');
await page.getByLabel('Line width', { exact: true }).press('ArrowRight');
await expect(page.getByLabel('Route style', { exact: true })).toHaveValue('custom');
await page.locator('#basemap-style').selectOption('light');
await page.keyboard.press('Escape');
await expect(page.locator('#layers-summary')).toBeFocused();
await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id', 'visual-a');
const graph = await context.newPage();
await visualFixture(graph);
await graph.goto('/netgraph/');
await expect(graph.locator('#connected-count')).toHaveText('2');
await expect(graph.locator('html')).toHaveAttribute('data-basemap', 'light');
await graph.locator('#display-button').click();
await expect(graph.getByLabel('Route style', { exact: true })).toHaveValue('custom');
await graph.getByLabel('Route style', { exact: true }).selectOption('ribbon');
await graph.getByLabel('Scene style', { exact: true }).selectOption('streets');
await expect(page.locator('#map')).toHaveAttribute('data-basemap-style', 'streets');
await expect(page.locator('#map')).toHaveAttribute('data-route-preset', 'ribbon');
await expect(page.locator('#map')).toHaveAttribute('data-selected-node-id', 'visual-a');
await expect(graph.locator('#netgraph-stage')).toHaveAttribute('data-route-preset', 'ribbon');
await graph.keyboard.press('Escape');
await expect(graph.locator('#display-button')).toBeFocused();
await graph.reload();
await expect(graph.locator('html')).toHaveAttribute('data-basemap', 'streets');
await graph.screenshot({ path: info.outputPath('netgraph-paper.png') });
await graph.close();
await expect(page.locator('#map-notice')).toBeHidden();
});
test('uses bright packet ink at night and dark ink on both light scenes', async ({ page }, info) => {
await visualFixture(page);
let sequence = 0;
for (const path of ['/', '/netgraph/']) {
sequence = 0;
await page.goto(path);
await expect(page.locator('html')).toHaveAttribute('data-fixture-stream', 'ready');
for (const scene of ['dark', 'light', 'streets']) {
if (path === '/') { await openMapOptions(page); await page.locator('#basemap-style').selectOption(scene); }
else { await page.locator('#display-button').click(); await page.getByLabel('Scene style', { exact: true }).selectOption(scene); }
await page.keyboard.press('Escape');
await visualPacket(page, ++sequence);
await expect.poll(() => packetInk(page, scene !== 'dark'), { timeout: 3000 }).toBeGreaterThan(2);
await page.screenshot({ path: info.outputPath(`${path === '/' ? 'map' : 'netgraph'}-${scene}.png`) });
}
}
});
test('reports missing tiles while keeping node data and Netgraph available', async ({ page }) => {
await visualFixture(page);
await page.route('https://tiles.openfreemap.org/fixture/**', (route) => route.abort());
await page.goto('/');
await expect(page.locator('#status')).toHaveAttribute('title', '3 nodes · 1 routes');
await expect(page.locator('#map-notice')).toBeVisible();
await expect(page.locator('#map-notice')).toContainText('Some map details could not load');
await page.goto('/netgraph/');
await expect(page.locator('#connected-count')).toHaveText('2');
await expect(page.locator('#fatal')).toBeHidden();
});
test('shows matching region colors in a viewport legend only when Regions is enabled', async ({ page }, info) => {
await visualFixture(page);
await page.goto('/');
test.skip(await page.locator('#regions-button').count() === 0, 'Canada region layer only');
await expect(page.locator('#map')).toHaveAttribute('data-render-state', 'idle');
await openMapOptions(page);
await page.locator('#regions-button').click();
await page.keyboard.press('Escape');
await expect(page.locator('#region-legend > summary')).toHaveText(/Regions in view \([1-9]\d*\)/);
if (await page.locator('#region-legend').getAttribute('open') === null) await page.locator('#region-legend > summary').click();
await expect(page.locator('.region-legend-list > span').first()).toBeVisible();
const colors = await page.locator('.region-legend-list i').evaluateAll((items) => items.map((item) => getComputedStyle(item).backgroundColor));
expect(colors.every((color) => color !== 'rgba(0, 0, 0, 0)')).toBe(true);
const regionBounds = await page.locator('.region-legend-list').boundingBox();
const packetBounds = await page.locator('#route-legend').boundingBox();
if (regionBounds && packetBounds) {
const overlap = Math.max(0, Math.min(regionBounds.x + regionBounds.width, packetBounds.x + packetBounds.width) - Math.max(regionBounds.x, packetBounds.x))
* Math.max(0, Math.min(regionBounds.y + regionBounds.height, packetBounds.y + packetBounds.height) - Math.max(regionBounds.y, packetBounds.y));
expect(overlap, 'packet and region legends must not cover each other').toBe(0);
}
await page.screenshot({ path: info.outputPath('region-colors-legend.png') });
await openMapOptions(page);
await page.locator('#regions-button').click();
await expect(page.locator('#region-legend')).toBeHidden();
});
test('renders buildings in desktop 3D', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'desktop 3D feature; mobile controls are covered separately');
await visualFixture(page, 15);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await openMapOptions(page);
await page.locator('#routes-button').click();
await page.locator('#terrain-button').click();
await expect(page.locator('#hillshade-button')).toHaveAttribute('aria-pressed', 'true');
await expect(page.locator('#buildings-button')).toHaveAttribute('aria-pressed', 'true');
await expect(map).toHaveAttribute('data-buildings-loaded', 'true');
await expect(map).toHaveAttribute('data-camera-moving', 'false');
await page.locator('#buildings-button').click();
await page.keyboard.press('Escape');
const size = page.viewportSize()!;
const clip = { x: size.width / 2 - 150, y: size.height / 2 - 150, width: 300, height: 300 };
const without = await page.screenshot({ clip });
await openMapOptions(page);
await page.locator('#buildings-button').click();
await expect(map).toHaveAttribute('data-buildings-loaded', 'true');
await page.keyboard.press('Escape');
const withBuildings = await page.screenshot({ clip, path: info.outputPath('synthetic-buildings.png') });
expect(await changedPixels(page, without, withBuildings), 'synthetic building geometry must actually draw').toBeGreaterThan(300);
});
test('preserves controllable desktop camera orientation', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'desktop camera controls');
await visualFixture(page, 15);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await openMapOptions(page);
await page.locator('#routes-button').click();
await page.locator('#terrain-button').click();
await expect(map).toHaveAttribute('data-camera-moving', 'false');
await page.locator('.camera-settings > summary').click();
await page.locator('#camera-pitch').press('End');
await page.locator('#camera-pitch').press('ArrowLeft');
await expect(map).toHaveAttribute('data-camera-pitch', '64');
await page.locator('#terrain-height').press('End');
await expect(page.locator('#terrain-height-output')).toHaveText('2×');
await page.keyboard.press('Escape');
await page.reload();
await expect(map).toHaveAttribute('data-camera-pitch', '64');
await expect(map).toHaveAttribute('data-buildings-visible', 'true');
await expect(page.locator('#map-notice')).toBeHidden();
});
test('switches buildings between desktop extrusions and mobile footprints on resize', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'crosses the desktop breakpoint in both directions');
await visualFixture(page, 15);
await page.goto('/');
const map = page.locator('#map');
await expect(map).toHaveAttribute('data-render-state', 'idle');
await openMapOptions(page);
await page.locator('#routes-button').click();
await page.locator('#terrain-button').click();
await expect(map).toHaveAttribute('data-building-extrusions', 'true');
await expect(map).toHaveAttribute('data-buildings-loaded', 'true');
await page.keyboard.press('Escape');
const size = page.viewportSize()!;
await page.setViewportSize({ width: 800, height: 1000 });
await expect(map).toHaveAttribute('data-building-extrusions', 'false');
await expect(map).toHaveAttribute('data-buildings-visible', 'true');
await page.setViewportSize(size);
await expect(map).toHaveAttribute('data-building-extrusions', 'true');
});
test('can disable lingering trails without disabling live packets', async ({ page }, info) => {
test.skip(info.project.name !== 'desktop', 'lifetime is checked once');
await visualFixture(page);
await page.goto('/');
await expect(page.locator('html')).toHaveAttribute('data-fixture-stream', 'ready');
await openMapOptions(page);
await page.locator('.display-advanced > summary').click();
await page.getByLabel('After-trails', { exact: true }).press('Home');
await expect(page.getByLabel('After-trails', { exact: true })).toHaveValue('0');
await page.keyboard.press('Escape');
await visualPacket(page, 1);
await expect.poll(() => packetInk(page, false)).toBeGreaterThan(2);
// Includes the existing six-second node wake, independently of route residue.
await page.waitForTimeout(9500);
const painted = await page.locator('#packet-canvas').evaluate((element) => {
const canvas = element as HTMLCanvasElement;
return canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data.some((value, index) => index % 4 === 3 && value > 0);
});
expect(painted).toBe(false);
await expect(page.locator('#packet-canvas')).toHaveAttribute('data-enabled', 'true');
});
async function packetInk(page: Page, light: boolean): Promise<number> {
return page.locator('#packet-canvas').evaluate((element, light) => {
const canvas = element as HTMLCanvasElement;
const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data;
let found = 0;
for (let i = 0; i < pixels.length; i += 4) {
if (pixels[i + 3]! < 180) continue;
if (light ? pixels[i]! < 40 && pixels[i + 1]! > 45 && pixels[i + 1]! < 145 && pixels[i + 2]! < 140
: pixels[i]! > 40 && pixels[i]! < 110 && pixels[i + 1]! > 180 && pixels[i + 2]! > 150) found++;
}
return found;
}, light);
}
async function changedPixels(page: Page, before: Buffer, after: Buffer): Promise<number> {
return page.evaluate(async (images) => {
const decode = async (data: string): Promise<Uint8ClampedArray> => {
const image = new Image(); image.src = `data:image/png;base64,${data}`; await image.decode();
const canvas = document.createElement('canvas'); canvas.width = image.width; canvas.height = image.height;
const context = canvas.getContext('2d', { willReadFrequently: true })!; context.drawImage(image, 0, 0);
return context.getImageData(0, 0, canvas.width, canvas.height).data;
};
const [a, b] = await Promise.all(images.map(decode)); let changed = 0;
for (let i = 0; i < a!.length; i += 4) if (Math.abs(a![i]! - b![i]!) + Math.abs(a![i + 1]! - b![i + 1]!) + Math.abs(a![i + 2]! - b![i + 2]!) > 35) changed++;
return changed;
}, [before.toString('base64'), after.toString('base64')]);
}
+4 -2
View File
@@ -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);
+9 -8
View File
@@ -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 };
+90
View File
@@ -0,0 +1,90 @@
import { deflateSync } from 'node:zlib';
import { fromGeojsonVt } from '@maplibre/vt-pbf';
import type { Page } from '@playwright/test';
import type { StateV2 } from '../src/types';
export const visualCenter: [number, number] = [-80.35, 43.45];
const cors = { 'access-control-allow-origin': '*' };
const layers = ['landcover', 'landuse', 'water', 'waterway', 'boundary', 'transportation', 'water_name', 'place', 'building'];
export async function visualFixture(page: Page, zoom = 10.2): Promise<void> {
const now = Date.now();
const nodes = [
{ id: 'visual-a', label: 'Visual Alpha', lng: visualCenter[0], lat: visualCenter[1] },
{ id: 'visual-b', label: 'Visual Bravo', lng: -80.23, lat: 43.51 },
{ id: 'visual-c', label: 'Visual Charlie', lng: -80.40, lat: 43.58 },
].map((node) => ({ ...node, role: 'repeater' as const, observer: false, lastSeen: now }));
const state: StateV2 = {
schemaVersion: 2, bootId: 'visual-fixture', seq: 0, serverTime: now,
status: { feed: 'connected', activity: 'active', dropped: 0, version: 'test', gitSha: 'synthetic' },
map: { center: visualCenter, zoom }, nodes,
routes: [{ id: 'visual-hop', fromId: 'visual-a', toId: 'visual-b', packetCount: 4, lastHeard: now, intensity: 2, lastKind: 'Text', traffic: 4 }],
};
await page.route('**/api/state', (route) => route.fulfill({ json: state }));
await page.route('**/api/config', (route) => route.fulfill({ json: { schemaVersion: 1, basemap: { provider: 'openfreemap' } } }));
await page.addInitScript(({ center, zoom }) => {
const ui = { routes: true, heatmap: false, clusters: false, hillshade: false, terrain3D: false, buildings: false, mapLabels: false, nodeLabels: false, livePackets: true, routeWindow: '24h' };
for (const key of ['cartolite:ui:v1', 'cartolite-server:ui:v1']) if (!localStorage.getItem(key)) localStorage.setItem(key, JSON.stringify(ui));
for (const prefix of ['cartolite:view:v3', 'cartolite-server:view:v1']) for (const kind of ['desktop', 'mobile']) {
if (!localStorage.getItem(`${prefix}:${kind}`)) localStorage.setItem(`${prefix}:${kind}`, JSON.stringify({ center, zoom }));
}
class FixtureStream extends EventTarget {
static CLOSED = 2;
readyState = 1;
onopen: ((event: Event) => void) | null = null;
private receive = (event: Event) => this.dispatchEvent(new MessageEvent('packet', { data: JSON.stringify((event as CustomEvent).detail) }));
constructor() {
super(); window.addEventListener('visual-packet', this.receive);
setTimeout(() => { this.onopen?.(new Event('open')); document.documentElement.dataset.fixtureStream = 'ready'; }, 0);
}
close(): void { this.readyState = 2; window.removeEventListener('visual-packet', this.receive); }
}
Object.defineProperty(window, 'EventSource', { value: FixtureStream });
}, { center: visualCenter, zoom });
const tilejson = { tilejson: '3.0.0', minzoom: 0, maxzoom: 14, tiles: ['https://tiles.openfreemap.org/fixture/{z}/{x}/{y}.pbf'], vector_layers: layers.map((id) => ({ id, fields: {} })) };
await page.route('https://tiles.openfreemap.org/planet', (route) => route.fulfill({ headers: cors, json: tilejson }));
await page.route(/https:\/\/[^/]*basemaps\.cartocdn\.com\/vector\/carto\.streets\/v1\/tiles\.json/, (route) => route.fulfill({ headers: cors, json: tilejson }));
await page.route(/https:\/\/tiles\.openfreemap\.org\/fixture\/\d+\/\d+\/\d+\.pbf/, (route) => {
const parts = /fixture\/(\d+)\/(\d+)\/(\d+)/.exec(route.request().url())!;
return route.fulfill({ headers: cors, contentType: 'application/x-protobuf', body: buildingTile(Number(parts[1]), Number(parts[2]), Number(parts[3])) });
});
await page.route('https://tiles.mapterhorn.com/tilejson.json', (route) => route.fulfill({ headers: cors, json: { tilejson: '3.0.0', tiles: ['https://tiles.mapterhorn.com/fixture/{z}/{x}/{y}.webp'], minzoom: 0, maxzoom: 14, encoding: 'terrarium' } }));
const dem = flatTerrain();
await page.route('https://tiles.mapterhorn.com/fixture/**', (route) => route.fulfill({ headers: cors, contentType: 'image/png', body: dem }));
}
export async function visualPacket(page: Page, seq: number, kind = 'Advert'): Promise<void> {
await page.evaluate(({ seq, kind }) => window.dispatchEvent(new CustomEvent('visual-packet', { detail: {
seq, id: `visual-${seq}`, at: Date.now(), payloadType: kind, mode: 'route',
segments: [{ routeId: 'visual-hop', fromId: 'visual-a', toId: 'visual-b' }],
} })), { seq, kind });
}
function buildingTile(z: number, x: number, y: number): Buffer {
const scale = 2 ** z;
const px = (visualCenter[0] + 180) / 360 * scale;
const py = (1 - Math.log(Math.tan(Math.PI / 4 + visualCenter[1] * Math.PI / 360)) / Math.PI) / 2 * scale;
const tx = Math.round((px - x) * 4096); const ty = Math.round((py - y) * 4096);
const tile = Object.fromEntries(layers.map((name) => [name, { features: [] as Array<{ id: number; type: number; tags: Record<string, string | number | boolean>; geometry: number[][][] }> }]));
tile.water!.features.push({ id: 2, type: 3, tags: { class: 'ocean' }, geometry: [[[0, 0], [2048, 0], [2048, 4096], [0, 4096], [0, 0]]] });
if (Math.floor(px) === x && Math.floor(py) === y) {
tile.building!.features.push({ id: 1, type: 3, tags: { render_height: 100, render_min_height: 0, hide_3d: false }, geometry: [[[tx - 80, ty - 80], [tx + 80, ty - 80], [tx + 80, ty + 80], [tx - 80, ty + 80], [tx - 80, ty - 80]]] });
}
return Buffer.from(fromGeojsonVt(tile, { version: 2 }));
}
function flatTerrain(): Buffer {
const size = 128;
const pixels = Buffer.alloc((size * 4 + 1) * size);
for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) pixels.set([128, 0, 0, 255], y * (size * 4 + 1) + 1 + x * 4);
const header = Buffer.alloc(13); header.writeUInt32BE(size, 0); header.writeUInt32BE(size, 4); header[8] = 8; header[9] = 6;
return Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), pngChunk('IHDR', header), pngChunk('IDAT', deflateSync(pixels)), pngChunk('IEND', Buffer.alloc(0))]);
}
function pngChunk(type: string, data: Buffer): Buffer {
const content = Buffer.concat([Buffer.from(type), data]); let crc = 0xffffffff;
for (const byte of content) { crc ^= byte; for (let bit = 0; bit < 8; bit++) crc = (crc >>> 1) ^ ((crc & 1) ? 0xedb88320 : 0); }
const length = Buffer.alloc(4); length.writeUInt32BE(data.length);
const checksum = Buffer.alloc(4); checksum.writeUInt32BE((crc ^ 0xffffffff) >>> 0);
return Buffer.concat([length, content, checksum]);
}
+9 -2
View File
@@ -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" aria-label="Reset north"><span class="compass-needle" aria-hidden="true">↑</span> Reset north <output id="camera-bearing" aria-label="Camera bearing">0°</output></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>
@@ -174,7 +181,7 @@
<h1 id="about-title">CartoLite</h1>
<p>A self-hosted, privacy-safe view of high-confidence MeshCore RF traffic worldwide.</p>
<dl>
<div><dt>Map</dt><dd>Clusters and packet-coloured heat summarize activity. 3D also enables Topo, with terrain-following packet trails and tilted arrival rings. Terrain is exaggerated 1.35× for readability; traffic overlays illustrate confirmed hops, not antenna heights or radio coverage.</dd></div>
<div><dt>Map</dt><dd>Shared display presets keep routes and packets readable on light and dark scenes. 3D enables Topo and Buildings. Desktop camera and terrain height are adjustable (1× terrain height by default). Terrain and buildings provide visual context, not antenna heights or radio coverage.</dd></div>
<div><dt>Colours</dt><dd>Route and packet colours show the latest sanitized packet kind, never message content.</dd></div>
<div><dt>Sound</dt><dd>Opt in to hear every visible live hop with one of 30 voices, from soft keys and bells to synths, pings, and percussion. Choose a Voice in Sound settings. Volume and your choice stay only in this browser; remembered sound always waits for a tap before resuming.</dd></div>
<div><dt>Find</dt><dd>Search public node labels locally, then inspect active neighbours without sending or saving your query.</dd></div>
+2 -2
View File
@@ -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
View File
@@ -1,6 +1,6 @@
{
"name": "cartolite-server-web",
"version": "0.4.1",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
+32 -11
View File
@@ -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: '&copy; OpenStreetMap contributors &copy; CARTO'
url: carto ? withKey(CARTO_VECTOR_TILEJSON, key) : OPENFREEMAP_TILEJSON,
attribution: carto ? '&copy; OpenStreetMap contributors &copy; CARTO' : '<a href="https://openfreemap.org">OpenFreeMap</a> <a href="https://openmaptiles.org">&copy; 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 {
+23
View File
@@ -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=');
});
+33
View File
@@ -0,0 +1,33 @@
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): string | undefined {
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');
return source;
}
+72
View File
@@ -0,0 +1,72 @@
.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); opacity: 1; font-size: 10px; }
#map .maplibregl-ctrl-attrib a { color: var(--ink); }
:root[data-theme="light"] .route-legend, :root[data-theme="light"] .route-legend-label { color: var(--ink); }
:root[data-view-class="mobile"] #app:has(.region-legend:not([hidden])) .route-legend { top: max(112px, calc(env(safe-area-inset-top) + 104px)); bottom: auto; }
@media (orientation: landscape) and (max-height: 520px) { :root[data-view-class="mobile"] #app:has(.region-legend:not([hidden])) .route-legend { top: max(62px, calc(env(safe-area-inset-top) + 54px)); } }
: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; }
.route-legend-swatch, .netgraph-app .packet-legend i[data-kind]:not([data-kind="inter-area"]) { position: relative; width: 18px; height: 10px; background: var(--scene-background) !important; border: 1px solid var(--panel-border); border-radius: 3px; box-shadow: none; }
.route-legend-swatch::after, .netgraph-app .packet-legend i[data-kind]:not([data-kind="inter-area"])::after { content: ''; position: absolute; left: 2px; right: 2px; top: 3px; height: 2px; background: var(--route-color); }
.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; }
.compass-needle { display: inline-block; transform: rotate(var(--bearing, 0deg)); }
#camera-bearing { margin-left: auto; font-variant-numeric: tabular-nums; }
: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; }
+80
View File
@@ -0,0 +1,80 @@
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-label="Display settings" title="Display settings" 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) return;
event.preventDefault(); event.stopImmediatePropagation(); close(true);
});
mountDisplayControls(panel, true);
}
+34
View File
@@ -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);
}
+136
View File
@@ -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.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()[el.dataset.kind as keyof typeof DARK_KINDS];
if (color) el.style.setProperty('--route-color', color);
}
}
+68 -13
View File
@@ -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;
@@ -194,9 +203,11 @@ document.addEventListener('pointerdown', (event) => {
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
const activeMenu = layersDisclosure.hasAttribute('open') ? layersSummary : !soundPanel.hidden ? soundButton : !findPanel.hidden ? findButton : undefined;
closeSoundPanel();
closeFindPanel();
if (layersDisclosure.hasAttribute('open')) { setLayersOpen(false); layersSummary.focus(); }
setLayersOpen(false);
if (activeMenu) { event.preventDefault(); event.stopImmediatePropagation(); activeMenu.focus(); }
});
required<HTMLButtonElement>('layers-close').addEventListener('click', () => { setLayersOpen(false); layersSummary.focus(); });
@@ -212,6 +223,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 +241,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 +281,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 +311,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 +355,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,7 +538,12 @@ 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();
}
});
liveMap.map.on('rotate', () => updateCompass(liveMap.map.getBearing()));
let resizeTimer: number | undefined;
window.addEventListener('resize', () => {
if (resizeTimer !== undefined) window.clearTimeout(resizeTimer);
@@ -693,13 +738,16 @@ 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}×`;
updateCompass(uiPreferences.terrainBearing);
required<HTMLOutputElement>('route-opacity-output').value = `${routeOpacity.value}%`;
required<HTMLOutputElement>('terrain-relief-output').value = `${terrainRelief.value}%`;
}
@@ -709,6 +757,13 @@ function persistUiPreference(update: Partial<UiPreferences>): void {
saveUiPreferences(localStorage, uiPreferences);
}
function updateCompass(bearing: number): void {
const button = required<HTMLButtonElement>('north-button');
button.style.setProperty('--bearing', `${-bearing}deg`);
button.title = `Reset north · bearing ${Math.round(bearing)}°`;
required<HTMLOutputElement>('camera-bearing').value = `${Math.round(bearing)}°`;
}
function wireLayerToggle(
button: HTMLButtonElement,
initiallyVisible: boolean,
@@ -742,7 +797,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';
+100 -50
View File
@@ -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 {
@@ -64,7 +67,6 @@ const TERRAIN_SOURCE_ID = 'mapterhorn-dem';
const HILLSHADE_SOURCE_ID = 'mapterhorn-hillshade-dem';
const TERRAIN_TILEJSON_URL = 'https://tiles.mapterhorn.com/tilejson.json';
const ROUTE_DETAIL_SOURCE_ID = 'route-details';
const ROUTE_TERRAIN_LAYER_ID = 'route-terrain';
const ROUTE_FOCUS_SOURCE_ID = 'route-focus';
const FOLLOW_SOURCE_ID = 'live-follow-activity';
export const HEATMAP_LAYER_IDS = PACKET_KINDS.map((kind) => `activity-heat-${kind.toLowerCase()}`);
@@ -100,7 +102,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';
@@ -169,7 +171,6 @@ export class LiveMap {
private lastRouteHydrationAt = 0;
private routeSourceRevision = 0;
private routeClock = 0;
private routeCollections?: RouteSourceCollections;
private routeDetailFeatures = new Map<string, Feature<LineString>>();
private dirtyRouteIDs = new Set<string>();
private rebuildAllRoutes = true;
@@ -181,7 +182,6 @@ export class LiveMap {
private hillshadeVisible = false;
private terrain3D = false;
private terrainLayersReady = false;
private terrainRoutesVisible = false;
private selectedNodeID: string | null = null;
private selectedNodeLabel = '';
private neighborNodeIDs: string[] = [];
@@ -201,6 +201,8 @@ export class LiveMap {
private followZoom = DEFAULT_ZOOM;
private followedPacket?: PacketView;
private appearance: UiPreferences = { ...DEFAULT_UI_PREFERENCES };
private readonly originalOverlayColors = new Map<string, unknown>();
private buildingSourceID?: string;
private directorTimer?: number;
private readonly reducedMotion = prefersReducedMotion();
private freshnessTimer: number;
@@ -225,6 +227,7 @@ export class LiveMap {
this.container.dataset.terrain3d = 'false';
this.container.dataset.terrainReady = 'false';
this.container.dataset.cameraPitch = '0';
this.container.dataset.cameraMoving = 'false';
this.container.dataset.selectedNodeId = '';
this.container.dataset.neighborRouteCount = '0';
this.container.dataset.focusedRouteCount = '0';
@@ -239,12 +242,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 +260,20 @@ export class LiveMap {
this.updateRouteRepresentation();
this.map.addControl(new maplibregl.AttributionControl({ compact: true }), 'bottom-right');
this.map.on('load', () => this.installLayers());
this.map.on('movestart', () => { this.container.dataset.cameraMoving = 'true'; });
this.map.on('sourcedata', (event) => {
if (this.buildingSourceID && event.sourceId === this.buildingSourceID) {
this.container.dataset.buildingsLoaded = String(this.map.isSourceLoaded(this.buildingSourceID));
}
});
this.map.on('moveend', () => {
this.container.dataset.cameraMoving = 'false';
if (this.terrain3D && viewClass() === 'desktop') {
this.appearance.terrainPitch = this.map.getPitch();
this.appearance.terrainBearing = this.map.getBearing();
this.container.dataset.cameraPitch = String(Math.round(this.map.getPitch() * 10) / 10);
}
});
this.map.on('zoom', this.updateRouteRepresentation);
this.map.on('zoomend', this.handleZoomEnd);
this.map.on('resize', this.handleInspectorResize);
@@ -410,13 +427,12 @@ export class LiveMap {
active
).then((collections) => {
if (!collections || !active()) return;
this.routeCollections = collections;
this.container.dataset.exactRoutesLoaded = 'true';
this.container.dataset.trunkRepresentationsLoaded = '';
this.container.dataset.routeBuildMaxSliceMs = collections.maxSliceMS.toFixed(1);
const sourceStarted = performance.now();
this.historicalRouteLayer.setRoutes(collections.individual.features);
this.updateTerrainRoutes(true);
this.updateTerrainRoutes();
this.routeDetailFeatures.clear();
for (const feature of collections.individual.features) {
if (feature.id !== undefined) this.routeDetailFeatures.set(String(feature.id), feature);
@@ -692,8 +708,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 +719,14 @@ export class LiveMap {
}
this.map.setLayoutProperty(NODE_LABEL_LAYER_ID, 'visibility', preferences.nodeLabels ? 'visible' : 'none');
this.historicalRouteLayer.setOpacity(preferences.routeOpacity);
this.map.setPaintProperty(ROUTE_TERRAIN_LAYER_ID, 'line-opacity', ['*', ['get', 'opacity'], 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();
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,14 +761,14 @@ 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;
}
const detailSource = this.map.getSource(ROUTE_DETAIL_SOURCE_ID) as GeoJSONSource | undefined;
const needsHydration = visible && this.routeDataDirty && Boolean(detailSource);
this.updateTerrainRoutes(true);
this.updateTerrainRoutes();
const maxAge = this.effectiveRouteAgeMS();
const visualApplied = detailSource
? applyRouteVisibilityForZoom(this.map, visible, maxAge, this.map.getZoom())
@@ -800,8 +821,11 @@ 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.updateTerrainRoutes(true);
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();
this.setTerrainGestures(enabled);
const camera = this.cameraOrientation();
this.container.dataset.cameraPitch = String(camera.pitch);
@@ -810,6 +834,43 @@ 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 });
this.container.dataset.cameraPitch = String(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);
this.container.dataset.buildingExtrusions = String(this.appearance.buildings && this.terrain3D && viewClass() === 'desktop');
if (!this.layersReady) return;
this.buildingSourceID = 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 +932,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,23 +956,12 @@ 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 {
if (!this.map.getLayer(ROUTE_TERRAIN_LAYER_ID)) return;
const visible = this.terrain3D && this.routesVisible;
private updateTerrainRoutes(): void {
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.map.setFilter(ROUTE_TERRAIN_LAYER_ID, ['<=', ['get', 'windowBand'], routeWindowBand(this.effectiveRouteAgeMS())]);
if (refreshData) {
(this.map.getSource(ROUTE_DETAIL_SOURCE_ID) as GeoJSONSource).setData(
visible ? this.routeCollections?.individual ?? EMPTY_LINES : EMPTY_LINES
);
}
this.historicalRouteLayer.setVisible(this.routesVisible);
}
private updateRouteRepresentation = (): void => {
@@ -1020,16 +1070,9 @@ 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({
id: ROUTE_TERRAIN_LAYER_ID,
type: 'line',
source: ROUTE_DETAIL_SOURCE_ID,
layout: { visibility: 'none', 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': routeColorExpression(), 'line-width': ['get', 'width'], 'line-opacity': ['*', ['get', 'opacity'], 0.55] }
});
this.map.addLayer({
id: ROUTE_FOCUS_LAYER_IDS[0],
type: 'line',
@@ -1151,7 +1194,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 +1299,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 +1317,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 +1357,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 +1539,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);
@@ -1729,6 +1777,7 @@ export class LiveMap {
}
private handleInspectorResize = (): void => {
this.updateBuildingLayer();
if (this.selectedNodeID) this.renderNodeInspector(true);
};
@@ -1744,7 +1793,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 +2658,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;
+22
View File
@@ -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;
}
+4
View File
@@ -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');
+50 -31
View File
@@ -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);
@@ -551,7 +563,7 @@ export class NetgraphRenderer implements ViewportProjector {
const offsetX = modulo(this.width / 2 - this.centerX * this.scale, spacing);
const offsetY = modulo(this.height / 2 - this.centerY * this.scale, spacing);
context.save();
context.strokeStyle = 'rgba(93, 176, 183, 0.055)';
context.strokeStyle = lightScene() ? 'rgba(35, 82, 88, 0.1)' : 'rgba(93, 176, 183, 0.075)';
context.lineWidth = 1;
context.beginPath();
for (let x = offsetX; x <= this.width; x += spacing) {
@@ -582,14 +594,14 @@ export class NetgraphRenderer implements ViewportProjector {
if (radius < 5) continue;
const edge = Math.max(8, radius);
const glow = context.createRadialGradient(point.x, point.y, 0, point.x, point.y, edge);
glow.addColorStop(0, 'rgba(69, 220, 202, 0.055)');
glow.addColorStop(0.72, 'rgba(39, 139, 150, 0.022)');
glow.addColorStop(0, lightScene() ? 'rgba(0, 105, 87, 0.055)' : 'rgba(69, 220, 202, 0.055)');
glow.addColorStop(0.72, lightScene() ? 'rgba(0, 105, 87, 0.022)' : 'rgba(39, 139, 150, 0.022)');
glow.addColorStop(1, 'rgba(39, 139, 150, 0)');
context.beginPath();
context.arc(point.x, point.y, edge, 0, Math.PI * 2);
context.fillStyle = glow;
context.fill();
context.strokeStyle = 'rgba(110, 220, 217, 0.12)';
context.strokeStyle = lightScene() ? 'rgba(35, 85, 90, 0.36)' : 'rgba(110, 220, 217, 0.25)';
context.lineWidth = 0.8;
context.stroke();
}
@@ -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,26 @@ 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 = lightScene() ? 'rgba(249, 252, 245, 0.85)' : 'rgba(3, 14, 19, 0.8)';
context.lineWidth = displayPreferences().width + 1.5;
context.globalAlpha = selected ? 0.1 : displayPreferences().opacity;
context.stroke();
context.globalAlpha = 1;
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 +716,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 +828,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 +881,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 +1001,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 +1022,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 +1073,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 +1082,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 +1106,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 +1125,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 +1254,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 +1328,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 +1341,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';
+17
View File
@@ -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);
}
}
}
+34 -21
View File
@@ -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,12 +688,12 @@ 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));
let tangent = { x: after.x - before.x, y: after.y - before.y };
if (path[0]!.progress !== undefined) {
if (path[0]?.progress !== undefined) {
for (let index = 1; index < path.length; index += 1) {
const from = path[index - 1]!;
const to = path[index]!;
@@ -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
View File
@@ -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;
}
+10 -4
View File
@@ -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(
+253 -215
View File
@@ -1,274 +1,312 @@
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; positions: readonly [number, number, number][] }
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];
private lastUploadAt = -Infinity;
private refreshTimer?: ReturnType<typeof setTimeout>;
private hitMatrix?: Float32Array;
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', 'casing', 'outline']) {
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('moveend', 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('moveend', 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;
if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
setRoutes(routes: readonly Feature<LineString>[]): void {
this.vertices = historicalRouteVertices(routes);
this.vertexCount = this.vertices.length / FLOATS_PER_VERTEX;
this.upload();
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.scheduleResample();
};
private sourceChanged = (event: { sourceId?: string }): void => {
if (event.sourceId && event.sourceId === this.map?.getTerrain()?.source) this.scheduleResample();
};
private scheduleResample(): void {
if (!this.visible) { this.dirty = true; return; }
const delay = Math.max(0, 200 - (performance.now() - this.lastUploadAt));
if (delay === 0) { this.invalidate(); return; }
if (this.refreshTimer !== undefined) return;
this.refreshTimer = setTimeout(() => { this.refreshTimer = undefined; this.invalidate(); }, delay);
}
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 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.hitMatrix) return undefined;
const matrix = this.hitMatrix;
const width = this.map.getCanvas().clientWidth; const height = this.map.getCanvas().clientHeight;
const project = (position: readonly number[]): SurfacePoint | undefined => {
const x = position[0]! - this.origin[0]; const y = position[1]! - this.origin[1]; const z = position[2]!;
const w = matrix[3]! * x + matrix[7]! * y + matrix[11]! * z + matrix[15]!;
if (w <= 0) return undefined;
return { x: ((matrix[0]! * x + matrix[4]! * y + matrix[8]! * z + matrix[12]!) / w + 1) * width / 2,
y: (1 - (matrix[1]! * x + matrix[5]! * y + matrix[9]! * z + matrix[13]!) / w) * height / 2 };
};
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;
const points = path.positions.map(project);
for (let i = 1; i < points.length; i += 1) {
const a = points[i - 1]; const b = points[i];
if (!a || !b) continue;
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);
const pieces = splitWorldRoute([rawFrom[0]!, rawFrom[1]!], [rawTo[0]!, rawTo[1]!]);
for (const pair of pieces) {
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, pieces.length > 1 ? 3 : 4);
fractions = points.map((p) => p.progress!);
}
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];
});
if (terrain) this.hitPaths.push({ id: String(route.properties?.id ?? route.id ?? ''), band, positions });
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;
this.lastUploadAt = performance.now();
if (this.refreshTimer !== undefined) clearTimeout(this.refreshTimer);
this.refreshTimer = undefined;
}
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 denseOverview = this.routes.length > 2000 && this.map.getZoom() < 11;
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];
this.hitMatrix = new Float32Array(matrix);
gl.uniformMatrix4fv(uniforms.matrix!, false, this.hitMatrix);
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!, denseOverview ? 0 : settings.glow * clamp((this.map.getZoom() - 3) / 7, 0.15, 1));
gl.uniform1f(uniforms.outline!, denseOverview ? 0 : 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);
if (this.lightBackground) gl.uniform3f(uniforms.casing!, 0.97, 0.99, 0.95);
else gl.uniform3f(uniforms.casing!, 0.025, 0.055, 0.075);
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; uniform float u_outline;
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 + max(0.55, u_outline) + 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;
uniform vec3 u_casing; uniform float u_outline;
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 = abs(v_local.y);
if (outside > 0.0) distance = length(vec2(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 = 0.0; float rim = 0.0; vec3 ink = v_color;
if (u_glow > 0.001) glow = (1.0 - smoothstep(u_width * 0.5, u_width * 0.5 + 1.0 + u_glow * 5.0, distance)) * u_glow * 0.18;
if (u_outline > 0.0) rim = (1.0 - smoothstep(u_width * 0.5 + 0.25, u_width * 0.5 + u_outline, distance)) * (1.0 - core);
if (rim > 0.001) ink = (v_color * core + u_casing * rim) / max(0.001, core + rim);
gl_FragColor = vec4(ink, max(core + rim * 0.8, 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
View File
@@ -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)); }
+13 -2
View File
@@ -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;
+67 -39
View File
@@ -5,9 +5,10 @@ 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 {
@@ -68,26 +69,28 @@ export class TerrainProjector {
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 });
}
const section = (start: number, end: number, wrapped: boolean): SurfacePoint[] => {
const sample = (local: number): SurfacePoint => {
const t = start + (end - start) * local;
const [lng, lat] = geographicSegmentPoint(from, to, t);
return this.projectEndpoint({ ...from, lng: wrapped ? lng - Math.sign(boundary) * 360 : lng, lat });
};
const points = this.enabled() ? adaptiveSurfacePath(sample, 3) : [{ ...sample(0), progress: 0 }, { ...sample(1), progress: 1 }];
return points.map((point) => ({ ...point, progress: start + (end - start) * point.progress! }));
};
const left = section(0, crossing, false);
const right = section(crossing, 1, true);
if (right[0]) right[0].breakBefore = true;
const points = [...left, ...right];
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 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,37 +98,58 @@ 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, maxDepth = 4): SurfacePoint[] {
const depthLimit = Number.isFinite(maxDepth) ? Math.max(0, Math.min(4, Math.floor(maxDepth))) : 4;
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 < depthLimit && 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) {
@@ -135,11 +159,15 @@ export function surfaceTrail(points: readonly SurfacePoint[], progress: number,
}
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);
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 {
+1 -6
View File
@@ -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>> = {
+5
View File
@@ -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 };
}
+5 -5
View File
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
import { longitudeDelta, routeCoordinate, splitWorldRoute } from './worldGeometry';
import { surfacePathPoint, surfaceTrail, TerrainProjector, traceSurfacePath } from './terrainProjection';
import { routeSoundPlan } from './audio';
import { historicalRouteVertices } from './routeLayer';
import { historicalRouteVertices, STROKE_VERTEX_FLOATS } from './routeLayer';
import type { RoutePacketView } from './types';
describe('worldwide routes at the date line', () => {
@@ -40,10 +40,10 @@ describe('worldwide routes at the date line', () => {
it('keeps historical route vertices at the two map edges', () => {
const vertices = historicalRouteVertices([{ type: 'Feature', properties: {}, geometry: { type: 'LineString', coordinates: [[179, 0], [181, 0]] } }]);
expect(vertices.length).toBe(28);
expect(vertices.length).toBe(12 * STROKE_VERTEX_FLOATS);
expect(vertices[0]).toBeGreaterThan(0.99);
expect(vertices[7]).toBe(1);
expect(vertices[14]).toBe(0);
expect(vertices[21]).toBeLessThan(0.01);
expect(vertices[3]).toBe(1);
expect(vertices[6 * STROKE_VERTEX_FLOATS]).toBe(0);
expect(vertices[6 * STROKE_VERTEX_FLOATS + 3]).toBeLessThan(0.01);
});
});