29 Commits

Author SHA1 Message Date
root 72b4d1393e Re-add viewer screenshot (receiver IP and location masked)
CI / Python checks (push) Has been cancelled
CI / Docker build (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Re-added after history scrub. This capture shows neither the receiver
IP nor the location name (status bar reads "CONNECTED" / "ADS-Bit").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 01:10:59 -07:00
root d649593239 Scrub location/IP from public assets; add gitleaks pre-commit hook
- README: replace real lat/lon in the config example with 0.0/0.0 and
  drop the city name from the screenshot caption
- viewer screenshot: re-captured with the receiver IP masked
- add .pre-commit-config.yaml running gitleaks to block secrets from
  reaching gitea or the public GitHub mirror

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 14:22:12 -07:00
root acce31ed3a Replace gitea references with GitHub
Point the Quick Start clone command at the GitHub repo and drop the
obsolete .gitea-token ignore rule in favour of a generic secrets pattern.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 13:35:45 -07:00
AllenNPIT 47320b24d1 Create FUNDING.yml 2026-06-15 13:31:04 -07:00
root 07df375d00 Add viewer + pixel editor screenshots and Screenshots section to README
New README "Screenshots" section showing the live viewer (East view with
aircraft over the Las Vegas skyline) and the in-app pixel sprite editor,
using freshly captured screenshots.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 12:08:22 -07:00
allen 6d1b178e85 Merge pull request 'Release v1.1.2: publish container image + security workflows' (#12) from release/v1.1.2 into main
Release / image (push) Has been cancelled
2026-06-10 15:36:03 -07:00
root babed47fe6 Release v1.1.2: publish container image + add security workflows
- Bump VERSION to 1.1.2 (no app behavior change from 1.1.1).
- release.yml: add workflow_dispatch so images can be published manually from
  the Actions tab in addition to on version tags.
- Add CodeQL code scanning (Python + JavaScript), free for public repos.
- Add Dependabot config for GitHub Actions and pip updates.

This is the first release to publish a multi-arch image to GHCR
(ghcr.io/allennpit/ads-bit).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 15:36:02 -07:00
allen f635c23027 Merge pull request 'Set GitHub namespace (AllenNPIT)' (#11) from chore/set-namespace into main 2026-06-10 10:26:48 -07:00
root 9c0594975a Set GitHub namespace (AllenNPIT) in README badges and CHANGELOG links
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:26:47 -07:00
allen f081af36d8 Merge pull request 'Package for public GitHub release' (#10) from chore/github-packaging into main 2026-06-10 10:13:54 -07:00
root bf71ba1b50 Package for public GitHub release
Add open-source scaffolding and CI; remove Claude-specific dev notes from the
published tree.

- Remove CLAUDE.md from tracking (kept locally, now git-ignored).
- CHANGELOG.md (Keep a Changelog; v1.0 → v1.1.1).
- SECURITY.md (private vuln reporting + operator guidance).
- CONTRIBUTING.md (dev setup, conventions, PR flow).
- .github/: bug + feature issue templates, PR template.
- .github/workflows/ci.yml: byte-compile, validate example config, docker build.
- .github/workflows/release.yml: on a version tag, publish a multi-arch
  (amd64 + arm64) image to GHCR.
- README: license/release/CI badges, prebuilt-image (GHCR) run instructions,
  and a corrected Python-version note (3.9–3.11; netifaces caveat).

Badges and image names use an `OWNER` placeholder to replace with the GitHub
namespace after the repo is created.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 10:13:42 -07:00
allen f047f82e86 Merge pull request 'v1.1.1: harden deployment for easier fielding' (#9) from release/v1.1.1 into main 2026-06-10 09:32:54 -07:00
root 9f122f47aa v1.1.1: harden deployment for easier fielding
Make a fresh deploy (especially Docker) work with no manual steps and behave
well on real-world hosts:

- Auto-scan skips subnets larger than /20 (e.g. a docker bridge 172.17.0.0/16
  = 65k hosts) so AUTO discovery no longer stalls startup or hammers the
  network on any host with Docker. Manual scan shares the same MIN_SCAN_PREFIX.
- Docker: config.json is auto-seeded from config.json.example by a new
  docker-entrypoint.sh and persisted on a ./data directory mount — no more
  single-file bind-mount footgun, no manual cp before first run.
- Config path is overridable via ADSBIT_CONFIG (defaults to ./config.json for
  bare metal; /app/data/config.json in the container).
- New /health endpoint (status/version/receivers/flights) + Docker HEALTHCHECK.
- Clean SIGTERM/SIGINT shutdown (docker stop / systemctl stop) — no traceback.
- .gitignore/.dockerignore exclude the data/ config volume.
- README/CLAUDE.md updated; VERSION bumped to 1.1.1.

Verified: image builds, fresh `docker run` seeds config and reports healthy,
oversized subnets skipped in-container, /health responds, graceful shutdown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 09:32:36 -07:00
allen 14de77926f Merge pull request 'Prep v1.1.0 release: version string, gitignore secrets, docs' (#8) from release/v1.1.0 into main 2026-06-09 23:41:25 -07:00
root 3cbf36e0ad Prep v1.1.0 release: version string, gitignore secrets, docs
- Add VERSION = "1.1.0" to server.py; print at startup, expose via public
  GET /api/config, and show it in the admin header (so deployed == released
  is verifiable at a glance).
- Ignore config.json in .gitignore — it holds the admin password hash and
  session secret; fresh installs use config.json.example.
- README/CLAUDE.md: document the in-app pixel editor (now with shape tools,
  select/move, reference overlay, custom/recent palettes), the receiver health
  dashboard, 9 sprite types, and the asset cache-busting convention.
- Bump admin asset cache-bust to ?v=20260609d.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 23:41:00 -07:00
allen 8204d8d41e Merge pull request 'Pixel editor: shape tools, select/move, reference overlay, color tools' (#7) from feature/pixel-editor-enhancements into main 2026-06-09 15:53:05 -07:00
root a95db23240 Add shape tools, select/move, reference overlay, and color tools to pixel editor
Extends the in-app sprite editor with four feature sets:

- Shape tools: line (L), rectangle (R), ellipse (O) with click-drag live
  preview and a FILL toggle for outline vs filled rect/ellipse.
- Select & move: rectangular marquee (M) to select, drag-to-move via a
  floating layer, arrow-key nudge, and clipboard ops (Ctrl+C/X/V, Delete,
  Enter/Esc to commit). Undo/redo and save auto-bake any pending move.
- Reference overlay: load any image as an adjustable-opacity backdrop to
  trace over (rendered under the artwork), with a show/hide toggle.
- Color tools: brush opacity slider, persistent custom palette
  (localStorage; right-click to remove), and an auto-updating recent colors row.

Cache-bust admin assets to ?v=20260609c so the update loads without a manual
hard-refresh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 15:52:31 -07:00
allen 005817d499 Merge pull request 'Admin: in-app sprite pixel editor + receiver health UX & restart-crash fix' (#6) from feature/pixel-editor into main 2026-06-09 12:50:34 -07:00
root 7a80057cb5 Add receiver health UX, fix restart crash, add interface scan selector
Receiver management improvements for the admin Receivers tab:

- Per-receiver health: server now tracks each connection's state
  (connecting/connected/down), message count, and last-message time, exposed
  via new GET /api/admin/receiver-status and POST /api/admin/test-receivers.
- Receivers tab redesign: live "Active Receivers" panel with colored
  indicators (green=receiving, yellow=connected/no data, red=unreachable),
  a clear select -> SAVE & APPLY -> auto-test flow, and selectable scan
  results with a "USE SELECTED RECEIVERS" action.
- Network scan interface selector: scan a chosen interface/subnet instead of
  every local subnet. Oversized subnets (>/20, e.g. a docker /16) are disabled
  in the UI and rejected server-side, eliminating slow/hanging scans.

Critical fix — server crash on receiver restart:

- Receiver connection tasks were awaited inside main()'s top-level
  asyncio.gather. Restarting receivers cancels those tasks, and the resulting
  CancelledError propagated into the gather and killed the whole server
  process (every action after a restart then failed). Receiver tasks now run
  as independent background tasks; restart cancels and awaits them with
  return_exceptions=True so cancellation can never tear down the server.

Also cache-bust admin.css/admin.js/pixel-editor.js (?v=) so updated assets
load without a manual hard-refresh, and surface a clear message instead of a
silent "Loading…" when the status endpoint is unavailable.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-09 12:49:45 -07:00
root 214ee8dd90 Add in-app pixel editor for sprites in admin UI
Build a dependency-free Canvas2D pixel editor integrated into the admin
Sprites tab. Each sprite card gains an EDIT button that opens a full-screen
editor for the sprite at its native 500x333 resolution.

Tools: pencil (1-8px brush, Bresenham interpolation), eraser, color picker
(palette + native input + pick-from-canvas), fill bucket (flood fill), pan.
Features: cursor-centered zoom, space-drag pan, undo/redo (snapshot per
stroke), pixel grid overlay, transparency checkerboard, load existing sprite,
and keyboard shortcuts (B/E/I/F, Space, +/-/0, Ctrl+Z/Y, G, Esc).

Export reuses the existing POST /api/admin/sprites/{type} upload endpoint via
srcCanvas.toBlob, so no server changes are required.

Closes #5

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:39:31 -07:00
root 38043d7d1f Add Docker Compose and Podman support for containerized deployment
Adds Dockerfile, docker-compose.yml, and .dockerignore to enable
running ADS-Bit in a container with host networking for LAN receiver
auto-scanning. Bind mounts persist config, sprites, and backgrounds
across container recreation. Updates README with usage instructions.

Closes #4

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 13:52:57 -07:00
root 7af9789db7 Add download buttons for sprites and theme backgrounds
Allow users to download existing sprite and background assets from the
admin panel, useful for editing externally and re-uploading or backing
up customizations.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:47:20 -07:00
root 9f341a5b14 Add host network info display and custom subnet scan to Receivers tab
Shows the server's network interfaces (IP, subnet) in the admin Receivers
tab so users know which networks are being scanned. Adds an optional subnet
input field to scan arbitrary networks (e.g. VLANs the server can route to
but isn't directly attached to), with validation and a /20 size cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 11:06:31 -07:00
root 3391523d45 Add admin theme management: rename, upload, and create themes
Adds a THEMES tab to the admin panel with per-theme cards showing
4 directional previews, editable display names, per-direction PNG
upload, and new theme creation. Display names are stored in
config.json theme_names map rather than renaming filesystem folders.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 10:50:40 -07:00
root 23d469d008 Add balloon, glider, and UAV sprites with admin sprite management
New dedicated pixel art sprites for ADS-B emitter categories B2
(lighter-than-air → balloon), B1 (glider/sailplane → glider), and
B6 (UAV → uav), replacing the previous fallback to smallProp. Adds
an admin Sprites tab for previewing and uploading replacement PNGs
for all 9 aircraft sprite types.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:44:46 -07:00
root a3fa38b740 Add ADS-B emitter category support via dump1090 JSON API
Poll dump1090's aircraft.json endpoint to get real transponder-reported
aircraft categories (A1=Light, A3=Large, A5=Heavy, A7=Rotorcraft, etc.)
instead of relying solely on heuristics. The SBS protocol on port 30003
doesn't include this field, but the JSON API on port 80 does. Categories
are polled every 5 seconds and merged into flight data. Client uses
emitter category when available, falling back to heuristic classification.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:12:08 -07:00
root c996ade142 Fix aircraft categorization misclassifying Cessnas as helicopters
N-prefix callsigns (US general aviation) were incorrectly matching the
helicopter detection rules due to 'N' in the helicopter callsign list
and a broken .some() condition. Speed/altitude heuristics were also too
broad, and zero-value speed data (not yet received) triggered false
matches. Types are now re-evaluated as better data arrives instead of
being locked to the first guess.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 09:00:15 -07:00
root 2a2c29de29 Rename project references from Pixel View to ADS-Bit
Rename pixel-view.js to ads-bit.js, rename PixelADSB class to ADSBit,
and update all documentation and code comments to use ADS-Bit branding
consistently throughout the project.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:54:59 -07:00
root 22a3078af9 Add admin panel, authentication, and first-run setup wizard for v1.0
Implements credentialed admin panel and multi-step setup wizard so new
users can configure location, receivers, and preferences without editing
JSON files. Adds bcrypt password auth with session cookies, setup lockout
after first run, and expanded config schema with backward compatibility.

New files: admin/{html,css,js}, setup/{html,css,js}
Modified: server.py (auth, setup, admin APIs), index.html (dynamic title),
pixel-view.js (site/display config), requirements.txt (bcrypt),
config.json.example (full schema)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-08 08:35:07 -07:00
27 changed files with 1080 additions and 245 deletions
+1
View File
@@ -3,6 +3,7 @@ __pycache__
*.pyc *.pyc
*.pyo *.pyo
config.json config.json
data/
screenshots/ screenshots/
.claude/ .claude/
CLAUDE.md CLAUDE.md
+1
View File
@@ -0,0 +1 @@
buy_me_a_coffee: adsbit
+29
View File
@@ -0,0 +1,29 @@
---
name: Bug report
about: Report something that isn't working
title: "[Bug] "
labels: bug
---
**Describe the bug**
A clear description of what's wrong.
**To reproduce**
Steps to reproduce the behavior:
1.
2.
**Expected behavior**
What you expected to happen.
**Screenshots / logs**
If applicable, add screenshots or server log output.
**Environment**
- ADS-Bit version (admin header or `GET /api/config`):
- Install method: [ ] bare metal [ ] Docker [ ] Podman
- OS / Python version:
- Receiver / feed (e.g. dump1090, readsb) and how it's reached:
**Additional context**
Anything else that might help.
+18
View File
@@ -0,0 +1,18 @@
---
name: Feature request
about: Suggest an idea or improvement
title: "[Feature] "
labels: enhancement
---
**What problem does this solve?**
A clear description of the need or pain point.
**Proposed solution**
What you'd like to happen.
**Alternatives considered**
Any other approaches you thought about.
**Additional context**
Mockups, examples, or references.
+19
View File
@@ -0,0 +1,19 @@
## Summary
<!-- What does this PR change, and why? -->
## Related issues
<!-- e.g. Closes #123 -->
## How was this tested?
<!-- Commands run, manual steps, screenshots. For Docker changes, note that
`docker build` succeeds. -->
## Checklist
- [ ] Server starts and the change works locally (`python3 server.py`)
- [ ] If admin assets changed, bumped the `?v=` cache-bust token in `admin/admin.html`
- [ ] If behavior/version changed, updated `VERSION` and `CHANGELOG.md`
- [ ] No secrets or local config committed (`config.json` stays ignored)
+17
View File
@@ -0,0 +1,17 @@
version: 2
updates:
# Keep GitHub Actions pinned versions current.
- package-ecosystem: github-actions
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: "ci"
# Keep Python dependencies current.
- package-ecosystem: pip
directory: "/"
schedule:
interval: weekly
commit-message:
prefix: "deps"
+30
View File
@@ -0,0 +1,30 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
python:
name: Python checks
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install -r requirements.txt
- name: Byte-compile server
run: python -m compileall -q server.py
- name: Validate example config is valid JSON
run: python -c "import json; json.load(open('config.json.example'))"
docker:
name: Docker build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build image
run: docker build -t ads-bit:ci .
+35
View File
@@ -0,0 +1,35 @@
name: CodeQL
# Static security analysis (free for public repositories). Scans the Python
# backend and the vanilla-JS frontend on pushes, PRs, and weekly.
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "23 5 * * 1"
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
actions: read
strategy:
fail-fast: false
matrix:
language: [python, javascript]
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: "/language:${{ matrix.language }}"
+59
View File
@@ -0,0 +1,59 @@
name: Release
# Build and publish a multi-arch image to GitHub Container Registry (GHCR)
# whenever a version tag is pushed (e.g. v1.1.2). Lets users run a prebuilt
# image — including on Raspberry Pi (arm64) — without building locally.
on:
push:
tags: ["v*"]
workflow_dispatch:
inputs:
version:
description: "Image tag to publish (e.g. 1.1.2); leave blank for just :latest"
required: false
default: ""
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Compute lowercase image name
id: img
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker metadata (tags & labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.img.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ inputs.version }},enable=${{ github.event_name == 'workflow_dispatch' && inputs.version != '' }}
type=raw,value=latest
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+10 -4
View File
@@ -15,13 +15,19 @@ __pycache__/
# Claude Code # Claude Code
.claude/ .claude/
CLAUDE.md
# Gitea token # Local secrets / tokens
.gitea-token *.token
.git-credentials
# OS # OS
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# Local config (uncomment if you want to ignore local changes) # Local config — contains the admin password hash and session secret.
# config.json # Fresh installs start from config.json.example (see CONFIG.md).
config.json
# Docker config volume (holds the live config.json with secrets)
data/
+14
View File
@@ -0,0 +1,14 @@
# Pre-commit hooks for ADS-Bit.
#
# Setup (once):
# pip install pre-commit
# pre-commit install
#
# Thereafter gitleaks scans every commit for secrets (tokens, keys,
# password hashes) before they can reach gitea or the public GitHub mirror.
# Run manually across the whole tree with: pre-commit run --all-files
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.4
hooks:
- id: gitleaks
+61
View File
@@ -0,0 +1,61 @@
# Changelog
All notable changes to this project are documented here. The format is based
on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to
[Semantic Versioning](https://semver.org/).
## [1.1.2] - 2026-06-10
First release with a published container image and security tooling. No
application behavior changes from 1.1.1.
### Added
- Prebuilt multi-arch (amd64 + arm64) container image published to GitHub
Container Registry: `ghcr.io/allennpit/ads-bit`.
- CodeQL code-scanning workflow (Python + JavaScript) and Dependabot updates
for GitHub Actions and pip.
- `workflow_dispatch` trigger on the release workflow for manual image builds.
## [1.1.1] - 2026-06-10
### Added
- `/health` endpoint (status, version, receiver/flight counts) plus a Docker
`HEALTHCHECK`.
- `ADSBIT_CONFIG` environment variable to override the config file path.
### Changed
- Docker first-run now works with no manual steps: `config.json` is auto-seeded
from `config.json.example` by `docker-entrypoint.sh` and persisted on a
`./data` directory mount (removing the single-file bind-mount footgun).
- Network auto-scan skips subnets larger than `/20` (e.g. a Docker bridge
`172.17.0.0/16`) so discovery no longer stalls startup on Docker hosts.
- `SIGTERM`/`SIGINT` (`docker stop`, `systemctl stop`) shut down cleanly
without a stack trace.
## [1.1.0] - 2026-06-09
### Added
- In-app pixel editor for sprites (admin → Sprites → EDIT): pencil, eraser,
color picker, fill bucket, line/rectangle/ellipse shapes, rectangular
select with move/cut/copy/paste, reference-image overlay, brush size &
opacity, custom + recent palettes, zoom/pan, grid, undo/redo.
- Receiver health dashboard: live per-receiver status (receiving / no data /
unreachable), connection testing, a select → save → apply flow, selectable
scan results, and a per-interface scan selector.
- Version surfaced at startup, via `GET /api/config`, and in the admin header.
### Fixed
- Server crash when restarting receiver connections: cancelled receiver tasks
no longer tear down the main event loop.
## [1.0] - 2026-01
### Added
- Initial release: retro SNES-style side-view ADS-B flight tracker with custom
pixel-art sprites, directional backgrounds, weather, sun/moon, admin panel,
first-run setup wizard, and Docker/Podman support.
[1.1.2]: https://github.com/AllenNPIT/ADS-Bit/releases/tag/v1.1.2
[1.1.1]: https://github.com/AllenNPIT/ADS-Bit/releases/tag/v1.1.1
[1.1.0]: https://github.com/AllenNPIT/ADS-Bit/releases/tag/v1.1.0
[1.0]: https://github.com/AllenNPIT/ADS-Bit/releases/tag/v1.0
-147
View File
@@ -1,147 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
ADS-Bit is a retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites.
## Running the Server
```bash
# Start the server (port configured in config.json, default 2001)
python3 server.py
# Or use the startup script
./start.sh
```
Access at http://localhost:{web_port} (configured in config.json)
## Architecture
### Components
- **server.py** - Python WebSocket server using aiohttp
- Reads configuration from config.json
- Auto-scans for ADS-B receivers or connects to configured IPs
- Parses SBS/BaseStation format messages from receivers (port 30003)
- Polls dump1090 JSON API (`/dump1090/data/aircraft.json`) every 5s for ADS-B emitter categories
- Broadcasts flight data (including emitter category) to WebSocket clients every 1 second
- Cleans up flights not seen in 60 seconds
- Serves static files, admin panel, setup wizard, and APIs
- Authentication via bcrypt password hashing and session cookies
- **config.json** - Configuration file for receiver and location settings
- See CONFIG.md for full documentation
- **ads-bit.js** - JavaScript rendering engine (Canvas-based)
- Handles WebSocket connection and flight data updates
- Renders 10 FPS retro-style canvas animation
- Layer order (bottom to top): sky gradient → clouds → sun → moon → directional background → grid → aircraft → labels
- Directional backgrounds include horizon, so low sun/moon is realistically occluded
- Aircraft sprites flip horizontally when heading west (track 90°-270°)
- View direction rotates between N/E/S/W (arrow keys or A/D), changing the background
- **index.html** - Main HTML interface with embedded styles
- **admin/** - Admin panel (login + tabbed settings UI)
- Theme management: rename display names, upload per-direction background PNGs, create new themes
- Display names stored in `config.json` `theme_names` map (folder name -> display name)
- **pixel-editor.js** - In-app Canvas2D pixel editor (Sprites tab "EDIT" button)
- Edits sprites at native 500x333; tools: pencil, eraser, color picker, fill bucket, pan
- Zoom/pan, undo/redo (snapshot-per-stroke), grid overlay, retro palette + native color input
- Exports a 500x333 PNG via `srcCanvas.toBlob` and POSTs to the existing `/api/admin/sprites/{type}` upload endpoint (no new server route)
- Keyboard: B/E/I/F tools, Space=pan, +/-/0 zoom, Ctrl+Z/Y undo/redo, G grid, Esc close
- **setup/** - First-run setup wizard (multi-step configuration)
### Sprite Assets
All PNG sprites face right (eastward) and are flipped in-canvas for westbound aircraft:
- 9 aircraft types: smallProp, regionalJet, narrowBody, wideBody, heavy, helicopter, balloon, glider, uav
- Directional backgrounds: north.png, south.png, east.png, west.png (1536x1024, shown based on view direction)
- Celestial: sun.png, moon_6_phases.png (2x3 sprite sheet)
- Weather: happycloud.png (clear), raincloud.png (rain/snow)
### Aircraft Type Detection Logic
Two-tier system: real ADS-B emitter categories when available, heuristic fallback otherwise.
#### Tier 1: ADS-B Emitter Category (from dump1090 JSON API)
The server polls `http://{receiver_ip}/dump1090/data/aircraft.json` every 5 seconds. The SBS/BaseStation protocol (port 30003) does **not** include emitter category, but the dump1090/readsb JSON API exposes the raw ADS-B transponder category field. When available, this is authoritative and always preferred over heuristics.
Mapping from DO-260B emitter categories to sprite types:
| ADS-B Category | Description | Sprite |
|---|---|---|
| A1 | Light (< 15,500 lbs) | smallProp |
| A2 | Small (15,500 - 75,000 lbs) | regionalJet |
| A3 | Large (75,000 - 300,000 lbs) | narrowBody |
| A4 | High vortex large (B757) | narrowBody |
| A5 | Heavy (> 300,000 lbs) | wideBody or heavy (by altitude/speed) |
| A6 | High performance (> 5g, > 400 kts) | narrowBody |
| A7 | Rotorcraft | helicopter |
| B1 | Glider / sailplane | glider |
| B2 | Lighter-than-air | balloon |
| B4 | Skydiver drop plane | smallProp |
| B6 | UAV | uav |
Once an emitter category is received for an aircraft, it is locked in and heuristic re-evaluation is skipped.
#### Tier 2: Heuristic Fallback (when no emitter category available)
Priority order when ADS-B category is not available (some transponders don't broadcast it):
1. **Helicopter callsigns**: known operator prefixes (LFE, MED, CHP, PHI, ERA, BHS) or keywords (LIFE, MERCY, COPTER, etc.)
2. **Heavy/Wide body callsigns**: major airline ICAO prefixes (CPA, UAE, ETH, QTR, SIA, AAL, DAL, UAL, FDX, UPS, etc.) + altitude/speed thresholds
3. **Altitude/speed extremes**: altitude > 42000 ft or speed > 550 kts = heavy; > 40000 ft or > 500 kts = wideBody
4. **N-number (US GA registration)**: pattern `N` + digit + 2-4 alphanumerics = smallProp (or narrowBody if above 25000 ft)
5. **Regional airline callsigns**: SKW, RPA, ASH, PDT, CHQ, ENY, JIA, CPZ
6. **Speed/altitude heuristics**: speed < 60 + alt < 5000 = helicopter; alt < 18000 + speed < 300 = smallProp or regionalJet
7. **Altitude-only**: < 10000 ft = smallProp, < 25000 ft = regionalJet, higher = narrowBody
8. **Default**: narrowBody (re-evaluated when better data arrives)
Important: `speed=0` or `altitude=0` means data not yet received, not actual zero. The system re-categorizes when better data arrives (speed > 0 and callsign present).
### View Direction Controls
The viewer can rotate between cardinal directions (N/E/S/W), showing aircraft in a 90° field of view:
- **Keyboard**: Left/Right arrow keys or A/D keys
- **UI**: Arrow buttons on the interface
- Each direction displays a unique background image (north.png, east.png, south.png, west.png)
- Sun and moon positions are calculated based on actual azimuth and only appear when in the current field of view
### External APIs
- **Open-Meteo**: Weather and sunrise/sunset data (updates every 10 minutes)
- **ADS-B Receivers**: SBS/BaseStation protocol on port 30003 (flight data)
- **dump1090 JSON API**: `http://{receiver}/dump1090/data/aircraft.json` on port 80 (emitter categories, polled every 5s)
## Dependencies
Python packages required:
- aiohttp (web server and WebSocket)
- netifaces (network interface scanning)
- bcrypt (password hashing)
No package.json - frontend is vanilla JavaScript with no build step.
## Key Code Patterns
- Canvas version parameter on assets (`?v=36`) for cache busting
- Aircraft direction: `const isFacingLeft = flight.track > 90 && flight.track < 270`
- Moon phases use sprite sheet cropping with 2x3 grid (3 columns, 2 rows)
- Flight data stored in global `flights` dict keyed by ICAO hex code
## Debugging
```bash
# Check for running server instances
ps aux | grep server.py
# Kill all instances
pkill -9 -f server.py
# Test receiver connectivity (replace with your receiver IP)
nc -zv <receiver_ip> 30003
```
+59
View File
@@ -0,0 +1,59 @@
# Contributing to ADS-Bit
Thanks for your interest in improving ADS-Bit! Contributions of all kinds —
bug reports, features, sprites, themes, docs — are welcome.
## Development setup
ADS-Bit is a Python (aiohttp) backend with a vanilla-JavaScript frontend.
There is **no build step** for the frontend.
```bash
git clone <your-fork-url>
cd ADS-Bit
pip install -r requirements.txt # Python 3.93.11 recommended (see note below)
cp config.json.example config.json # optional; the setup wizard runs otherwise
python3 server.py # serves on http://localhost:2001
```
Or with Docker (no local Python needed):
```bash
docker compose up -d
```
> **Python version note:** the `netifaces` dependency can be hard to build on
> Python 3.12+. Use Python 3.93.11, or just use the Docker image (Python 3.11).
## Project layout
- `server.py` — aiohttp server: receiver connections, flight state, APIs, auth.
- `ads-bit.js` / `index.html` — Canvas-based retro viewer.
- `admin/` — admin panel, including the in-browser `pixel-editor.js`.
- `setup/` — first-run setup wizard.
- `images/`, `backgrounds/` — sprite and theme assets.
## Conventions
- **Frontend assets are cache-busted** via a `?v=YYYYMMDD[x]` query in
`admin/admin.html`. If you change `admin.css`, `admin.js`, or
`pixel-editor.js`, bump that token so browsers reload them.
- The `VERSION` constant in `server.py` is the single source of truth; bump it
for releases and add a `CHANGELOG.md` entry.
- Keep the retro aesthetic (Press Start 2P, blue/gold palette) for UI work.
- Match the style of surrounding code; no frontend framework or build tooling.
## Submitting changes
1. Fork and create a topic branch (`feature/…`, `fix/…`, `chore/…`).
2. Make focused commits with clear messages.
3. Verify locally: the server starts, the page loads, and your change works.
For Docker changes, confirm `docker build` succeeds.
4. Open a pull request describing what changed and how you tested it.
CI will byte-compile the server, validate `config.json.example`, and build the
Docker image on every PR.
## Reporting bugs / requesting features
Use the issue templates. For security issues, see [SECURITY.md](SECURITY.md).
+9 -2
View File
@@ -13,9 +13,16 @@ RUN pip install --no-cache-dir -r requirements.txt && \
rm -rf /var/lib/apt/lists/* rm -rf /var/lib/apt/lists/*
COPY . . COPY . .
RUN chmod +x /app/docker-entrypoint.sh
EXPOSE 2001 EXPOSE 2001
ENV PYTHONUNBUFFERED=1 # Persist config on a mounted volume by default (see docker-compose.yml).
ENV PYTHONUNBUFFERED=1 \
ADSBIT_CONFIG=/app/data/config.json
CMD ["python3", "server.py"] # Liveness probe against the unauthenticated /health endpoint.
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
CMD python3 -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:2001/health',timeout=4).status==200 else 1)" || exit 1
ENTRYPOINT ["/app/docker-entrypoint.sh"]
+48 -14
View File
@@ -1,27 +1,45 @@
# ADS-Bit # ADS-Bit
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Release](https://img.shields.io/github/v/release/AllenNPIT/ADS-Bit)](https://github.com/AllenNPIT/ADS-Bit/releases)
[![CI](https://github.com/AllenNPIT/ADS-Bit/actions/workflows/ci.yml/badge.svg)](https://github.com/AllenNPIT/ADS-Bit/actions/workflows/ci.yml)
A retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites. A retro SNES-style side-view flight tracker that displays ADS-B aircraft data with custom pixel art sprites.
![ADS-Bit Screenshot](screenshots/screenshot.png) ## Screenshots
### Live viewer
Real-time ADS-B traffic rendered as pixel-art sprites over a directional background — here the East view, with a desert skyline on the horizon. Aircraft are labelled with callsign, altitude, and distance, and listed in the sidebar sorted by range.
![Live viewer with aircraft in view](screenshots/viewer.png)
### In-app pixel editor
Draw and edit aircraft sprites directly in the browser: pencil, eraser, line/rectangle/ellipse shapes, fill bucket, select & move, zoom/pan, undo/redo, custom palettes, and an adjustable reference overlay. Saving uploads the sprite back to the running tracker — no external tools needed.
![In-browser pixel sprite editor](screenshots/pixel-editor.png)
## Features ## Features
- Real-time aircraft tracking via ADS-B receivers - Real-time aircraft tracking via ADS-B receivers
- Custom pixel art sprites for 6 aircraft types (small prop, regional jet, narrow body, wide body, heavy, helicopter) - Custom pixel art sprites for 9 aircraft types (small prop, regional jet, narrow body, wide body, heavy, helicopter, balloon, glider, UAV)
- Animated sun and moon with accurate astronomical positions - Animated sun and moon with accurate astronomical positions
- Dynamic sky colors based on time of day - Dynamic sky colors based on time of day
- Weather visualization with cloud sprites - Weather visualization with cloud sprites
- Directional view (N/E/S/W) with themed backgrounds - Directional view (N/E/S/W) with themed backgrounds
- Auto-discovery of ADS-B receivers on your network - Auto-discovery of ADS-B receivers on your network, with per-interface scanning
- Canvas-based 10 FPS retro rendering - Canvas-based 10 FPS retro rendering
- Admin panel with password authentication - Admin panel with password authentication
- **In-app pixel editor** — draw and edit sprites in the browser (pencil, shapes, fill, select & move, reference overlay, custom palettes)
- **Receiver health dashboard** — live per-receiver status (receiving / no data / unreachable), connection testing, and select → save → apply flow
- First-run setup wizard - First-run setup wizard
## Quick Start ## Quick Start
```bash ```bash
# Clone the repository # Clone the repository
git clone https://gitea.chops.one/allen/ADS-Bit.git git clone https://github.com/AllenNPIT/ADS-Bit.git
cd ADS-Bit cd ADS-Bit
# Install dependencies # Install dependencies
@@ -39,17 +57,33 @@ On first run, visit http://localhost:2001 and the setup wizard will guide you th
- [Docker](https://docs.docker.com/get-docker/) with Compose V2, **or** [Podman](https://podman.io/) with `podman-compose` - [Docker](https://docs.docker.com/get-docker/) with Compose V2, **or** [Podman](https://podman.io/) with `podman-compose`
### First Run ### Run the prebuilt image (no clone needed)
Once a release is published, a multi-arch image (amd64 + arm64, e.g. Raspberry Pi)
is available from GitHub Container Registry:
```bash ```bash
# Create a config file (the setup wizard runs if you skip this) docker run -d --name ads-bit --network host --restart unless-stopped \
cp config.json.example config.json -e ADSBIT_CONFIG=/app/data/config.json \
-v "$PWD/data:/app/data" \
ghcr.io/allennpit/ads-bit:latest
```
# Build and start Then open http://localhost:2001 and complete the setup wizard.
### Build from source
```bash
# Build and start — no config step needed
docker compose up -d docker compose up -d
``` ```
The web UI is available at http://localhost:2001. A config file is created automatically in `data/` on first run, then the
browser **setup wizard** walks you through receiver, location, and password
setup. The web UI is available at http://localhost:2001.
A `/health` endpoint (used by the container healthcheck) reports status,
version, and receiver/flight counts: `curl http://localhost:2001/health`.
### Useful Commands ### Useful Commands
@@ -80,15 +114,15 @@ The following paths are bind-mounted from the repo directory and persist across
| Path | Contents | | Path | Contents |
|------|----------| |------|----------|
| `config.json` | Server configuration and credentials | | `data/` | Server configuration and credentials (`config.json`, auto-seeded) |
| `images/` | Aircraft and UI sprites (including uploads) | | `images/` | Aircraft and UI sprites (including uploads) |
| `backgrounds/` | Theme background images (including custom themes) | | `backgrounds/` | Theme background images (including custom themes) |
Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers. Host networking (`network_mode: host`) is used so the server can auto-scan your LAN for ADS-B receivers. Auto-scan skips oversized subnets (larger than /20, e.g. a Docker bridge `172.17.0.0/16`); if your receiver lives on such a network, set it explicitly via the admin Receivers tab.
## Requirements ## Requirements
- Python 3.8+ - Python 3.93.11 (3.12+ may require build tools for `netifaces`; the Docker image uses 3.11)
- ADS-B receiver providing SBS/BaseStation format on port 30003 (dump1090, readsb, etc.) - ADS-B receiver providing SBS/BaseStation format on port 30003 (dump1090, readsb, etc.)
- Modern web browser with Canvas support - Modern web browser with Canvas support
@@ -102,8 +136,8 @@ ADS-Bit uses a first-run setup wizard to configure your installation. You can al
"receiver_port": 30003, "receiver_port": 30003,
"location": { "location": {
"name": "My Location", "name": "My Location",
"lat": 36.2788, "lat": 0.0,
"lon": -115.2283 "lon": 0.0
}, },
"web_port": 2001, "web_port": 2001,
"theme": "desert" "theme": "desert"
+35
View File
@@ -0,0 +1,35 @@
# Security Policy
## Supported Versions
| Version | Supported |
|---------|-----------|
| 1.1.x | ✅ |
| < 1.1 | ❌ |
## Reporting a Vulnerability
Please **do not** open a public issue for security vulnerabilities.
Instead, use GitHub's private vulnerability reporting:
**Security → Report a vulnerability** on the repository.
Include as much detail as you can:
- Affected version (see the version in the admin header or `GET /api/config`)
- Steps to reproduce / proof of concept
- Impact and any suggested remediation
You can expect an acknowledgement within a few days. Please allow reasonable
time for a fix before any public disclosure.
## Security Notes for Operators
ADS-Bit is intended for trusted LANs, not direct exposure to the public
internet. If you must expose it:
- Set a strong admin password (changed from the setup default).
- Put it behind HTTPS via a reverse proxy or tunnel (e.g. Caddy, nginx,
Tailscale). The built-in server speaks plain HTTP.
- Never commit your `config.json` — it contains the admin password hash and
session secret. It is git-ignored by default; fresh installs start from
`config.json.example`.
+56
View File
@@ -169,6 +169,15 @@ textarea {
margin: 0; margin: 0;
} }
.admin-version {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #54fc54;
text-shadow: none;
margin-left: 6px;
vertical-align: middle;
}
.header-actions { .header-actions {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -901,6 +910,53 @@ body.pe-open { overflow: hidden; }
.pe-readout span { color: #54fc54; } .pe-readout span { color: #54fc54; }
/* Sidebar: opacity, palettes, reference */
.pe-opacity-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.pe-opacity-group input[type="range"] { width: 100%; accent-color: #fcd444; }
.pe-subhead {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
color: #b4d4ec;
margin-top: 4px;
letter-spacing: 1px;
}
.pe-mini-btn {
font-family: 'Press Start 2P', monospace;
font-size: 7px;
padding: 6px 4px;
border: 2px solid #5c94fc;
border-radius: 5px;
background: rgba(92, 148, 252, 0.15);
color: #fcfcfc;
cursor: pointer;
transition: all 0.12s;
text-align: center;
}
.pe-mini-btn:hover { background: rgba(92, 148, 252, 0.4); }
#pe-custom-palette:empty::after,
#pe-recent-palette:empty::after {
content: '—';
color: #555;
font-size: 11px;
}
.pe-ref-controls {
display: flex;
flex-direction: column;
gap: 6px;
}
.pe-ref-row { display: flex; gap: 6px; }
.pe-ref-row .pe-mini-btn { flex: 1; }
.pe-ref-load { display: inline-block; }
.pe-ref-controls input[type="range"] { width: 100%; accent-color: #fcd444; }
/* Sprite card EDIT button uses primary accent */ /* Sprite card EDIT button uses primary accent */
.sprite-edit-btn { border-color: #fcd444; color: #fcd444; } .sprite-edit-btn { border-color: #fcd444; color: #fcd444; }
.sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); } .sprite-edit-btn:hover { background: rgba(252, 212, 68, 0.3); }
+4 -4
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/admin/admin.css?v=20260609b"> <link rel="stylesheet" href="/admin/admin.css?v=20260609d">
</head> </head>
<body> <body>
<!-- Login Screen --> <!-- Login Screen -->
@@ -27,7 +27,7 @@
<!-- Admin Panel --> <!-- Admin Panel -->
<div id="admin-panel" class="hidden"> <div id="admin-panel" class="hidden">
<header class="admin-header"> <header class="admin-header">
<h1 class="retro-title">ADS-Bit Admin</h1> <h1 class="retro-title">ADS-Bit Admin <span id="admin-version" class="admin-version"></span></h1>
<div class="header-actions"> <div class="header-actions">
<a href="/" class="btn btn-small">VIEWER</a> <a href="/" class="btn btn-small">VIEWER</a>
<button id="logout-btn" class="btn btn-small btn-danger">LOGOUT</button> <button id="logout-btn" class="btn btn-small btn-danger">LOGOUT</button>
@@ -297,7 +297,7 @@
<div id="toast" class="toast hidden"></div> <div id="toast" class="toast hidden"></div>
</div> </div>
<script src="/admin/pixel-editor.js?v=20260609b"></script> <script src="/admin/pixel-editor.js?v=20260609d"></script>
<script src="/admin/admin.js?v=20260609b"></script> <script src="/admin/admin.js?v=20260609d"></script>
</body> </body>
</html> </html>
+12
View File
@@ -28,10 +28,22 @@
loadConfig(); loadConfig();
loadThemes(); loadThemes();
loadNetworkInfo(); loadNetworkInfo();
loadVersion();
refreshStatus(); refreshStatus();
statusInterval = setInterval(refreshStatus, 5000); statusInterval = setInterval(refreshStatus, 5000);
} }
async function loadVersion() {
try {
const res = await fetch('/api/config');
if (!res.ok) return;
const data = await res.json();
if (data.version) {
document.getElementById('admin-version').textContent = 'v' + data.version;
}
} catch (e) { /* ignore */ }
}
document.getElementById('login-form').addEventListener('submit', async (e) => { document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const pw = document.getElementById('login-password').value; const pw = document.getElementById('login-password').value;
+500 -67
View File
@@ -4,10 +4,13 @@
// is POSTed to the existing /api/admin/sprites/{type} endpoint. // is POSTed to the existing /api/admin/sprites/{type} endpoint.
// //
// Public API: // Public API:
// PixelEditor.open(spriteType, { existingUrl, onSave }) // PixelEditor.open(spriteType, { onSave })
// //
// Tools: pencil, eraser, color picker, fill bucket, pan. // Tools: pencil, eraser, color picker, fill bucket, pan,
// Features: zoom/pan, undo/redo, grid overlay, load existing, export PNG. // line, rectangle, ellipse (outline/filled), rectangular select.
// Features: zoom/pan, undo/redo, grid overlay, brush size & opacity,
// custom + recent color palettes, reference-image overlay,
// selection move/cut/copy/paste/delete, load existing, export PNG.
(function () { (function () {
'use strict'; 'use strict';
@@ -20,6 +23,8 @@
const MAX_ZOOM = 40; const MAX_ZOOM = 40;
const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each) const HISTORY_LIMIT = 40; // max undo snapshots (~666 KB each)
const GRID_MIN_ZOOM = 6; // only show pixel grid when zoomed in enough const GRID_MIN_ZOOM = 6; // only show pixel grid when zoomed in enough
const RECENT_LIMIT = 10;
const CUSTOM_KEY = 'pe-custom-palette';
// ----- Retro palette (SNES-style white/blue/red leaning) ----- // ----- Retro palette (SNES-style white/blue/red leaning) -----
const PALETTE = [ const PALETTE = [
@@ -29,6 +34,8 @@
'#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc', '#54fc54', '#2c942c', '#a4e4a4', '#fc54fc', '#9438a8', '#54fcdc',
]; ];
const SHAPE_TOOLS = ['line', 'rect', 'ellipse'];
// ----- Module state ----- // ----- Module state -----
let root = null; // overlay element let root = null; // overlay element
let els = {}; // cached DOM references let els = {}; // cached DOM references
@@ -40,7 +47,9 @@
let tool = 'pencil'; let tool = 'pencil';
let brushSize = 1; let brushSize = 1;
let brushOpacity = 1; // 0..1, applies to color tools (not eraser)
let color = '#5c94fc'; let color = '#5c94fc';
let shapeFill = false;
let zoom = 1; let zoom = 1;
let panX = 0, panY = 0; let panX = 0, panY = 0;
let showGrid = true; let showGrid = true;
@@ -49,11 +58,29 @@
let saveCallback = null; let saveCallback = null;
// Pointer interaction state // Pointer interaction state
let drawing = false; // a draw stroke is in progress let drawing = false; // pencil/eraser stroke in progress
let panning = false; // a pan drag is in progress let drawingShape = false; // line/rect/ellipse drag in progress
let panning = false; // pan drag in progress
let spaceDown = false; // hold space to temporarily pan let spaceDown = false; // hold space to temporarily pan
let lastDraw = null; // last source pixel during a stroke {x,y} let lastDraw = null; // last source pixel during a stroke {x,y}
let panStart = null; // {x,y,panX,panY} at pan start let panStart = null; // {cx,cy,panX,panY} at pan start
let shapeStart = null; // {x,y} where a shape drag began
let shapeSnapshot = null; // ImageData captured at shape drag start
// Selection / move / clipboard
let selection = null; // {x,y,w,h} committed selection (source px)
let selDrag = null; // {mode:'new'|'move', sx,sy, ox,oy}
let floating = null; // {canvas,x,y,w,h} lifted/pasted pixels
let clipboard = null; // <canvas> of copied pixels
// Color palettes
let recentColors = [];
let customPalette = [];
// Reference overlay
let refImage = null;
let refOpacity = 0.5;
let refVisible = true;
// Undo/redo // Undo/redo
let history = []; let history = [];
@@ -86,6 +113,15 @@
<button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button> <button class="pe-tool" data-tool="eraser" title="Eraser (E)">⌫</button>
<button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button> <button class="pe-tool" data-tool="picker" title="Color picker (I)">⊙</button>
<button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button> <button class="pe-tool" data-tool="fill" title="Fill bucket (F)">▣</button>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="line" title="Line (L)"></button>
<button class="pe-tool" data-tool="rect" title="Rectangle (R)">▭</button>
<button class="pe-tool" data-tool="ellipse" title="Ellipse (O)">◯</button>
<label class="pe-check" title="Fill shapes"><input type="checkbox" id="pe-fill"> FILL</label>
</div>
<div class="pe-tool-group">
<button class="pe-tool" data-tool="select" title="Select & move (M)">⬚</button>
<button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button> <button class="pe-tool" data-tool="pan" title="Pan (hold Space)">✥</button>
</div> </div>
<div class="pe-tool-group"> <div class="pe-tool-group">
@@ -114,11 +150,31 @@
<div class="pe-swatch-large" id="pe-current-swatch"></div> <div class="pe-swatch-large" id="pe-current-swatch"></div>
<input type="color" id="pe-color-input" value="#5c94fc"> <input type="color" id="pe-color-input" value="#5c94fc">
</div> </div>
<div class="pe-opacity-group">
<span class="pe-tool-label">OPACITY <span id="pe-opacity-val">100%</span></span>
<input type="range" id="pe-opacity" min="10" max="100" value="100">
</div>
<div class="pe-palette" id="pe-palette"></div> <div class="pe-palette" id="pe-palette"></div>
<button class="pe-mini-btn" id="pe-add-swatch" title="Save current color to your custom palette">+ SAVE COLOR</button>
<div class="pe-subhead" id="pe-custom-head">CUSTOM</div>
<div class="pe-palette" id="pe-custom-palette"></div>
<div class="pe-subhead">RECENT</div>
<div class="pe-palette" id="pe-recent-palette"></div>
<div class="pe-subhead">REFERENCE</div>
<div class="pe-ref-controls">
<div class="pe-ref-row">
<label class="pe-mini-btn pe-ref-load">LOAD
<input type="file" id="pe-ref-file" accept="image/*" hidden>
</label>
<button class="pe-mini-btn" id="pe-ref-clear">CLEAR</button>
</div>
<label class="pe-check"><input type="checkbox" id="pe-ref-show" checked> SHOW</label>
<input type="range" id="pe-ref-opacity" min="5" max="100" value="50" title="Reference opacity">
</div>
<div class="pe-readout"> <div class="pe-readout">
<div>POS <span id="pe-pos">--, --</span></div> <div>POS <span id="pe-pos">--, --</span></div>
<div>ZOOM <span id="pe-zoom-val">100%</span></div> <div>ZOOM <span id="pe-zoom-val">100%</span></div>
<div>SIZE 500×333</div> <div>SEL <span id="pe-sel">none</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -130,15 +186,24 @@
els.title = root.querySelector('#pe-title'); els.title = root.querySelector('#pe-title');
els.wrap = root.querySelector('#pe-canvas-wrap'); els.wrap = root.querySelector('#pe-canvas-wrap');
els.palette = root.querySelector('#pe-palette'); els.palette = root.querySelector('#pe-palette');
els.customPalette = root.querySelector('#pe-custom-palette');
els.recentPalette = root.querySelector('#pe-recent-palette');
els.currentSwatch = root.querySelector('#pe-current-swatch'); els.currentSwatch = root.querySelector('#pe-current-swatch');
els.colorInput = root.querySelector('#pe-color-input'); els.colorInput = root.querySelector('#pe-color-input');
els.pos = root.querySelector('#pe-pos'); els.pos = root.querySelector('#pe-pos');
els.zoomVal = root.querySelector('#pe-zoom-val'); els.zoomVal = root.querySelector('#pe-zoom-val');
els.selVal = root.querySelector('#pe-sel');
els.brush = root.querySelector('#pe-brush'); els.brush = root.querySelector('#pe-brush');
els.brushVal = root.querySelector('#pe-brush-val'); els.brushVal = root.querySelector('#pe-brush-val');
els.fill = root.querySelector('#pe-fill');
els.opacity = root.querySelector('#pe-opacity');
els.opacityVal = root.querySelector('#pe-opacity-val');
els.grid = root.querySelector('#pe-grid'); els.grid = root.querySelector('#pe-grid');
els.undo = root.querySelector('#pe-undo'); els.undo = root.querySelector('#pe-undo');
els.redo = root.querySelector('#pe-redo'); els.redo = root.querySelector('#pe-redo');
els.refFile = root.querySelector('#pe-ref-file');
els.refShow = root.querySelector('#pe-ref-show');
els.refOpacity = root.querySelector('#pe-ref-opacity');
viewCanvas = root.querySelector('#pe-view'); viewCanvas = root.querySelector('#pe-view');
viewCtx = viewCanvas.getContext('2d'); viewCtx = viewCanvas.getContext('2d');
@@ -149,32 +214,49 @@
srcCanvas.height = SPRITE_H; srcCanvas.height = SPRITE_H;
srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true }); srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
buildPalette(); buildPalette(els.palette, PALETTE, false);
wireEvents(); wireEvents();
} }
function buildPalette() { // Build a row of color swatches. `removable` enables right-click removal
els.palette.innerHTML = ''; // (used by the custom palette).
PALETTE.forEach(c => { function buildPalette(container, colors, removable) {
container.innerHTML = '';
colors.forEach(c => {
const sw = document.createElement('button'); const sw = document.createElement('button');
sw.className = 'pe-swatch'; sw.className = 'pe-swatch';
sw.style.background = c; sw.style.background = c;
sw.title = c; sw.title = removable ? `${c} (right-click to remove)` : c;
sw.addEventListener('click', () => setColor(c)); sw.addEventListener('click', () => setColor(c));
els.palette.appendChild(sw); if (removable) {
sw.addEventListener('contextmenu', (e) => {
e.preventDefault();
customPalette = customPalette.filter(x => x !== c);
saveCustomPalette();
buildCustomPalette();
}); });
} }
container.appendChild(sw);
});
}
function buildCustomPalette() {
buildPalette(els.customPalette, customPalette, true);
root.querySelector('#pe-custom-head').style.opacity = customPalette.length ? '1' : '0.5';
}
function buildRecentPalette() {
buildPalette(els.recentPalette, recentColors, false);
}
// =================================================================== // ===================================================================
// Event wiring // Event wiring
// =================================================================== // ===================================================================
function wireEvents() { function wireEvents() {
// Tool buttons
root.querySelectorAll('.pe-tool').forEach(btn => { root.querySelectorAll('.pe-tool').forEach(btn => {
btn.addEventListener('click', () => setTool(btn.dataset.tool)); btn.addEventListener('click', () => setTool(btn.dataset.tool));
}); });
// Header actions
root.querySelector('#pe-close').addEventListener('click', () => { root.querySelector('#pe-close').addEventListener('click', () => {
if (confirmDiscard()) close(); if (confirmDiscard()) close();
}); });
@@ -182,24 +264,39 @@
root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true)); root.querySelector('#pe-load').addEventListener('click', () => loadExisting(true));
root.querySelector('#pe-clear').addEventListener('click', clearCanvas); root.querySelector('#pe-clear').addEventListener('click', clearCanvas);
// Zoom controls
root.querySelector('#pe-zoom-in').addEventListener('click', () => zoomAt(1.25, centerPoint())); root.querySelector('#pe-zoom-in').addEventListener('click', () => zoomAt(1.25, centerPoint()));
root.querySelector('#pe-zoom-out').addEventListener('click', () => zoomAt(0.8, centerPoint())); root.querySelector('#pe-zoom-out').addEventListener('click', () => zoomAt(0.8, centerPoint()));
root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen); root.querySelector('#pe-zoom-fit').addEventListener('click', fitToScreen);
// Undo/redo
els.undo.addEventListener('click', undo); els.undo.addEventListener('click', undo);
els.redo.addEventListener('click', redo); els.redo.addEventListener('click', redo);
// Grid + brush
els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); }); els.grid.addEventListener('change', () => { showGrid = els.grid.checked; render(); });
els.brush.addEventListener('input', () => { els.brush.addEventListener('input', () => {
brushSize = parseInt(els.brush.value, 10); brushSize = parseInt(els.brush.value, 10);
els.brushVal.textContent = brushSize; els.brushVal.textContent = brushSize;
}); });
els.fill.addEventListener('change', () => { shapeFill = els.fill.checked; });
els.opacity.addEventListener('input', () => {
brushOpacity = parseInt(els.opacity.value, 10) / 100;
els.opacityVal.textContent = els.opacity.value + '%';
});
// Color input
els.colorInput.addEventListener('input', () => setColor(els.colorInput.value)); els.colorInput.addEventListener('input', () => setColor(els.colorInput.value));
root.querySelector('#pe-add-swatch').addEventListener('click', addCurrentColorToPalette);
// Reference overlay
els.refFile.addEventListener('change', (e) => {
const f = e.target.files[0];
if (f) loadRefFromFile(f);
e.target.value = '';
});
root.querySelector('#pe-ref-clear').addEventListener('click', clearRef);
els.refShow.addEventListener('change', () => { refVisible = els.refShow.checked; render(); });
els.refOpacity.addEventListener('input', () => {
refOpacity = parseInt(els.refOpacity.value, 10) / 100;
render();
});
// Canvas pointer events // Canvas pointer events
viewCanvas.addEventListener('pointerdown', onPointerDown); viewCanvas.addEventListener('pointerdown', onPointerDown);
@@ -209,11 +306,9 @@
viewCanvas.addEventListener('wheel', onWheel, { passive: false }); viewCanvas.addEventListener('wheel', onWheel, { passive: false });
viewCanvas.addEventListener('contextmenu', e => e.preventDefault()); viewCanvas.addEventListener('contextmenu', e => e.preventDefault());
// Keyboard shortcuts (only active while editor is open)
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp); document.addEventListener('keyup', onKeyUp);
// Keep the view sized to its container
window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } }); window.addEventListener('resize', () => { if (isOpen()) { resizeView(); render(); } });
} }
@@ -227,16 +322,28 @@
saveCallback = opts.onSave || null; saveCallback = opts.onSave || null;
els.title.textContent = 'PIXEL EDITOR — ' + type; els.title.textContent = 'PIXEL EDITOR — ' + type;
// Reset state // Reset transient state
zoom = 1; panX = 0; panY = 0; zoom = 1; panX = 0; panY = 0;
history = []; historyIndex = -1; history = []; historyIndex = -1;
selection = null; selDrag = null; floating = null;
drawing = drawingShape = false; shapeSnapshot = null;
refImage = null; refVisible = true;
recentColors = [];
brushOpacity = 1;
shapeFill = false;
els.opacity.value = 100; els.opacityVal.textContent = '100%';
els.fill.checked = false;
els.refShow.checked = true;
loadCustomPalette();
buildCustomPalette();
buildRecentPalette();
setTool('pencil'); setTool('pencil');
updateSelReadout();
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
root.classList.remove('hidden'); root.classList.remove('hidden');
document.body.classList.add('pe-open'); document.body.classList.add('pe-open');
// Load existing sprite (if any), then size + draw.
loadExisting(false, () => { loadExisting(false, () => {
resizeView(); resizeView();
fitToScreen(); fitToScreen();
@@ -255,8 +362,7 @@
} }
function confirmDiscard() { function confirmDiscard() {
// Only warn if there is edit history beyond the baseline. if (historyIndex > 0 || floating) {
if (historyIndex > 0) {
return window.confirm('Discard unsaved changes and close the editor?'); return window.confirm('Discard unsaved changes and close the editor?');
} }
return true; return true;
@@ -270,7 +376,6 @@
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
// Draw centered/stretched into the 500x333 buffer.
srcCtx.imageSmoothingEnabled = false; srcCtx.imageSmoothingEnabled = false;
srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H); srcCtx.drawImage(img, 0, 0, SPRITE_W, SPRITE_H);
if (recordHistory) { pushHistory(); toast('Loaded current sprite'); } if (recordHistory) { pushHistory(); toast('Loaded current sprite'); }
@@ -278,7 +383,6 @@
if (done) done(); if (done) done();
}; };
img.onerror = () => { img.onerror = () => {
// No existing sprite — start from a blank transparent canvas.
if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); } if (recordHistory) { pushHistory(); toast('No existing sprite — blank canvas', true); }
render(); render();
if (done) done(); if (done) done();
@@ -287,6 +391,7 @@
} }
function clearCanvas() { function clearCanvas() {
floating = null; selection = null; updateSelReadout();
srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H); srcCtx.clearRect(0, 0, SPRITE_W, SPRITE_H);
pushHistory(); pushHistory();
render(); render();
@@ -296,6 +401,10 @@
// Tool / color selection // Tool / color selection
// =================================================================== // ===================================================================
function setTool(t) { function setTool(t) {
if (t !== tool) {
stampFloating(); // commit any lifted pixels before switching
if (t !== 'select') { selection = null; updateSelReadout(); }
}
tool = t; tool = t;
root.querySelectorAll('.pe-tool').forEach(b => { root.querySelectorAll('.pe-tool').forEach(b => {
b.classList.toggle('active', b.dataset.tool === t); b.classList.toggle('active', b.dataset.tool === t);
@@ -311,13 +420,69 @@
} }
} }
function recordColor(c) {
c = c.toLowerCase();
recentColors = recentColors.filter(x => x !== c);
recentColors.unshift(c);
if (recentColors.length > RECENT_LIMIT) recentColors.length = RECENT_LIMIT;
buildRecentPalette();
}
function addCurrentColorToPalette() {
const c = color.toLowerCase();
if (!customPalette.includes(c)) {
customPalette.push(c);
saveCustomPalette();
buildCustomPalette();
toast('Color saved to palette');
}
}
function loadCustomPalette() {
try { customPalette = JSON.parse(localStorage.getItem(CUSTOM_KEY) || '[]'); }
catch (e) { customPalette = []; }
}
function saveCustomPalette() {
try { localStorage.setItem(CUSTOM_KEY, JSON.stringify(customPalette)); } catch (e) { /* ignore */ }
}
function updateCursor() { function updateCursor() {
let cur = 'crosshair'; let cur = 'crosshair';
if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab'; if (tool === 'pan' || spaceDown) cur = panning ? 'grabbing' : 'grab';
else if (tool === 'picker') cur = 'cell'; else if (tool === 'picker') cur = 'cell';
else if (tool === 'select') cur = 'crosshair';
viewCanvas.style.cursor = cur; viewCanvas.style.cursor = cur;
} }
function updateSelReadout() {
if (floating) els.selVal.textContent = `${floating.w}×${floating.h} (moving)`;
else if (selection) els.selVal.textContent = `${selection.w}×${selection.h}`;
else els.selVal.textContent = 'none';
}
// ===================================================================
// Reference overlay
// ===================================================================
function loadRefFromFile(file) {
const url = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
refImage = img;
refVisible = true;
els.refShow.checked = true;
render();
URL.revokeObjectURL(url);
toast('Reference loaded');
};
img.onerror = () => { toast('Could not load reference image', true); URL.revokeObjectURL(url); };
img.src = url;
}
function clearRef() {
refImage = null;
render();
}
// =================================================================== // ===================================================================
// Coordinate mapping // Coordinate mapping
// =================================================================== // ===================================================================
@@ -336,6 +501,9 @@
return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 }; return { cx: viewCanvas.width / 2, cy: viewCanvas.height / 2 };
} }
function isShapeTool(t) { return SHAPE_TOOLS.indexOf(t) !== -1; }
function pointInRect(p, r) { return r && p.x >= r.x && p.x < r.x + r.w && p.y >= r.y && p.y < r.y + r.h; }
// =================================================================== // ===================================================================
// Pointer handlers // Pointer handlers
// =================================================================== // ===================================================================
@@ -352,14 +520,26 @@
return; return;
} }
if (tool === 'picker') { if (tool === 'picker') { pickColor(p.x, p.y); return; }
pickColor(p.x, p.y);
return; if (tool === 'select') { onSelectDown(p); return; }
}
// All remaining tools draw onto the canvas — commit any floating first.
stampFloating();
if (tool === 'fill') { if (tool === 'fill') {
floodFill(p.x, p.y); floodFill(p.x, p.y);
pushHistory(); pushHistory();
recordColor(color);
render();
return;
}
if (isShapeTool(tool)) {
shapeStart = { x: p.x, y: p.y };
shapeSnapshot = srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H);
drawingShape = true;
rasterShape(tool, p.x, p.y, p.x, p.y);
render(); render();
return; return;
} }
@@ -372,13 +552,30 @@
render(); render();
} }
function onSelectDown(p) {
if (floating) {
if (pointInRect(p, floating)) {
selDrag = { mode: 'move', sx: p.x, sy: p.y, ox: floating.x, oy: floating.y };
return;
}
stampFloating(); // clicked outside → drop it in place
}
if (selection && pointInRect(p, selection)) {
liftSelection(); // begin moving the committed selection
selDrag = { mode: 'move', sx: p.x, sy: p.y, ox: floating.x, oy: floating.y };
return;
}
// Begin a new selection rectangle.
selDrag = { mode: 'new', sx: p.x, sy: p.y };
selection = { x: p.x, y: p.y, w: 1, h: 1 };
updateSelReadout();
render();
}
function onPointerMove(e) { function onPointerMove(e) {
const p = eventToSource(e); const p = eventToSource(e);
if (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H) { els.pos.textContent = (p.x >= 0 && p.x < SPRITE_W && p.y >= 0 && p.y < SPRITE_H)
els.pos.textContent = `${p.x}, ${p.y}`; ? `${p.x}, ${p.y}` : '--, --';
} else {
els.pos.textContent = '--, --';
}
if (panning && panStart) { if (panning && panStart) {
panX = panStart.panX + (e.clientX - panStart.cx); panX = panStart.panX + (e.clientX - panStart.cx);
@@ -387,8 +584,31 @@
return; return;
} }
if (selDrag) {
if (selDrag.mode === 'new') {
const x0 = clamp(selDrag.sx, 0, SPRITE_W - 1), y0 = clamp(selDrag.sy, 0, SPRITE_H - 1);
const x1 = clamp(p.x, 0, SPRITE_W - 1), y1 = clamp(p.y, 0, SPRITE_H - 1);
selection = {
x: Math.min(x0, x1), y: Math.min(y0, y1),
w: Math.abs(x1 - x0) + 1, h: Math.abs(y1 - y0) + 1
};
} else if (floating) {
floating.x = selDrag.ox + (p.x - selDrag.sx);
floating.y = selDrag.oy + (p.y - selDrag.sy);
}
updateSelReadout();
render();
return;
}
if (drawingShape) {
srcCtx.putImageData(shapeSnapshot, 0, 0);
rasterShape(tool, shapeStart.x, shapeStart.y, p.x, p.y);
render();
return;
}
if (drawing && (tool === 'pencil' || tool === 'eraser')) { if (drawing && (tool === 'pencil' || tool === 'eraser')) {
// Interpolate from the last point so fast strokes have no gaps.
if (lastDraw) lineApply(lastDraw.x, lastDraw.y, p.x, p.y); if (lastDraw) lineApply(lastDraw.x, lastDraw.y, p.x, p.y);
else applyBrush(p.x, p.y); else applyBrush(p.x, p.y);
lastDraw = { x: p.x, y: p.y }; lastDraw = { x: p.x, y: p.y };
@@ -397,10 +617,25 @@
} }
function onPointerUp() { function onPointerUp() {
if (selDrag) {
if (selDrag.mode === 'new' && selection && (selection.w < 1 || selection.h < 1)) {
selection = null;
}
selDrag = null;
updateSelReadout();
render();
}
if (drawingShape) {
drawingShape = false;
shapeSnapshot = null;
pushHistory();
recordColor(color);
}
if (drawing) { if (drawing) {
drawing = false; drawing = false;
lastDraw = null; lastDraw = null;
pushHistory(); // one undo step per completed stroke pushHistory();
if (tool !== 'eraser') recordColor(color);
} }
if (panning) { if (panning) {
panning = false; panning = false;
@@ -412,8 +647,7 @@
function onWheel(e) { function onWheel(e) {
e.preventDefault(); e.preventDefault();
const rect = viewCanvas.getBoundingClientRect(); const rect = viewCanvas.getBoundingClientRect();
const focus = { cx: e.clientX - rect.left, cy: e.clientY - rect.top }; zoomAt(e.deltaY < 0 ? 1.15 : 0.87, { cx: e.clientX - rect.left, cy: e.clientY - rect.top });
zoomAt(e.deltaY < 0 ? 1.15 : 0.87, focus);
} }
// =================================================================== // ===================================================================
@@ -425,12 +659,14 @@
if (tool === 'eraser') { if (tool === 'eraser') {
srcCtx.clearRect(x0, y0, brushSize, brushSize); srcCtx.clearRect(x0, y0, brushSize, brushSize);
} else { } else {
srcCtx.globalAlpha = brushOpacity;
srcCtx.fillStyle = color; srcCtx.fillStyle = color;
srcCtx.fillRect(x0, y0, brushSize, brushSize); srcCtx.fillRect(x0, y0, brushSize, brushSize);
srcCtx.globalAlpha = 1;
} }
} }
// Bresenham line between two source pixels, stamping the brush along it. // Bresenham line, stamping the brush along it.
function lineApply(x0, y0, x1, y1) { function lineApply(x0, y0, x1, y1) {
const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0); const dx = Math.abs(x1 - x0), dy = Math.abs(y1 - y0);
const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1; const sx = x0 < x1 ? 1 : -1, sy = y0 < y1 ? 1 : -1;
@@ -444,6 +680,66 @@
} }
} }
// ----- Shape rasterizers (commit into srcCtx) -----
function rasterShape(t, x0, y0, x1, y1) {
if (t === 'line') lineApply(x0, y0, x1, y1);
else if (t === 'rect') rasterRect(x0, y0, x1, y1, shapeFill);
else if (t === 'ellipse') rasterEllipse(x0, y0, x1, y1, shapeFill);
}
function rasterRect(x0, y0, x1, y1, fill) {
const left = Math.min(x0, x1), right = Math.max(x0, x1);
const top = Math.min(y0, y1), bottom = Math.max(y0, y1);
if (fill) {
srcCtx.globalAlpha = brushOpacity;
srcCtx.fillStyle = color;
srcCtx.fillRect(left, top, right - left + 1, bottom - top + 1);
srcCtx.globalAlpha = 1;
} else {
lineApply(left, top, right, top);
lineApply(left, bottom, right, bottom);
lineApply(left, top, left, bottom);
lineApply(right, top, right, bottom);
}
}
function rasterEllipse(x0, y0, x1, y1, fill) {
const left = Math.min(x0, x1), right = Math.max(x0, x1);
const top = Math.min(y0, y1), bottom = Math.max(y0, y1);
const a = (right - left) / 2, b = (bottom - top) / 2;
const cx = (left + right) / 2, cy = (top + bottom) / 2;
if (a < 0.5 || b < 0.5) { lineApply(left, top, right, bottom); return; }
if (fill) {
srcCtx.globalAlpha = brushOpacity;
srcCtx.fillStyle = color;
for (let y = top; y <= bottom; y++) {
const ny = (y - cy) / b;
if (Math.abs(ny) > 1) continue;
const dx = a * Math.sqrt(1 - ny * ny);
const xl = Math.round(cx - dx), xr = Math.round(cx + dx);
srcCtx.fillRect(xl, y, xr - xl + 1, 1);
}
srcCtx.globalAlpha = 1;
} else {
// Scan rows then columns so the 1px outline has no gaps.
for (let y = top; y <= bottom; y++) {
const ny = (y - cy) / b;
if (Math.abs(ny) > 1) continue;
const dx = a * Math.sqrt(1 - ny * ny);
applyBrush(Math.round(cx - dx), y);
applyBrush(Math.round(cx + dx), y);
}
for (let x = left; x <= right; x++) {
const nx = (x - cx) / a;
if (Math.abs(nx) > 1) continue;
const dy = b * Math.sqrt(1 - nx * nx);
applyBrush(x, Math.round(cy - dy));
applyBrush(x, Math.round(cy + dy));
}
}
}
function pickColor(x, y) { function pickColor(x, y) {
if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return; if (x < 0 || x >= SPRITE_W || y < 0 || y >= SPRITE_H) return;
const d = srcCtx.getImageData(x, y, 1, 1).data; const d = srcCtx.getImageData(x, y, 1, 1).data;
@@ -462,7 +758,6 @@
const fill = hexToRgb(color); const fill = hexToRgb(color);
const fillA = 255; const fillA = 255;
// No-op if the target already equals the fill color.
if (target[0] === fill.r && target[1] === fill.g && if (target[0] === fill.r && target[1] === fill.g &&
target[2] === fill.b && target[3] === fillA) return; target[2] === fill.b && target[3] === fillA) return;
@@ -482,13 +777,104 @@
srcCtx.putImageData(img, 0, 0); srcCtx.putImageData(img, 0, 0);
} }
// ===================================================================
// Selection / move / clipboard
// ===================================================================
function clampSelection() {
if (!selection) return;
const x = clamp(selection.x, 0, SPRITE_W - 1);
const y = clamp(selection.y, 0, SPRITE_H - 1);
selection = {
x, y,
w: clamp(selection.w, 1, SPRITE_W - x),
h: clamp(selection.h, 1, SPRITE_H - y),
};
}
// Lift the selected pixels off the canvas into a floating layer.
function liftSelection() {
clampSelection();
const { x, y, w, h } = selection;
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(srcCanvas, x, y, w, h, 0, 0, w, h);
srcCtx.clearRect(x, y, w, h);
floating = { canvas: c, x, y, w, h };
selection = null;
updateSelReadout();
}
// Composite the floating layer back onto the canvas and record history.
function stampFloating() {
if (!floating) return;
srcCtx.drawImage(floating.canvas, floating.x, floating.y);
floating = null;
updateSelReadout();
pushHistory();
render();
}
function copySelection() {
if (!selection) { toast('Nothing selected', true); return; }
clampSelection();
const { x, y, w, h } = selection;
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(srcCanvas, x, y, w, h, 0, 0, w, h);
clipboard = c;
toast(`Copied ${w}×${h}`);
}
function cutSelection() {
if (!selection) { toast('Nothing selected', true); return; }
copySelection();
const { x, y, w, h } = selection;
srcCtx.clearRect(x, y, w, h);
selection = null;
updateSelReadout();
pushHistory();
render();
}
function pasteClipboard() {
if (!clipboard) { toast('Clipboard empty', true); return; }
stampFloating();
const c = document.createElement('canvas');
c.width = clipboard.width; c.height = clipboard.height;
c.getContext('2d').drawImage(clipboard, 0, 0);
const x = selection ? selection.x : 0;
const y = selection ? selection.y : 0;
floating = { canvas: c, x, y, w: c.width, h: c.height };
setTool('select');
updateSelReadout();
render();
toast('Pasted — drag to position, Enter to place');
}
function deleteSelection() {
if (floating) { floating = null; updateSelReadout(); pushHistory(); render(); return; }
if (!selection) return;
clampSelection();
const { x, y, w, h } = selection;
srcCtx.clearRect(x, y, w, h);
pushHistory();
render();
}
function nudgeFloating(dx, dy) {
if (!floating && selection) liftSelection();
if (floating) {
floating.x += dx; floating.y += dy;
render();
}
}
// =================================================================== // ===================================================================
// Zoom / pan // Zoom / pan
// =================================================================== // ===================================================================
function zoomAt(factor, focus) { function zoomAt(factor, focus) {
const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM); const newZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
if (newZoom === zoom) return; if (newZoom === zoom) return;
// Keep the source pixel under the focus point stationary.
const sxBefore = (focus.cx - panX) / zoom; const sxBefore = (focus.cx - panX) / zoom;
const syBefore = (focus.cy - panY) / zoom; const syBefore = (focus.cy - panY) / zoom;
zoom = newZoom; zoom = newZoom;
@@ -521,13 +907,27 @@
const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom; const dw = SPRITE_W * zoom, dh = SPRITE_H * zoom;
// Transparency checkerboard behind the artboard.
drawCheckerboard(panX, panY, dw, dh); drawCheckerboard(panX, panY, dw, dh);
// The sprite itself, nearest-neighbour scaled. // Reference overlay sits under the sprite so you can trace over it.
if (refImage && refVisible) {
viewCtx.globalAlpha = refOpacity;
viewCtx.imageSmoothingEnabled = false;
viewCtx.drawImage(refImage, panX, panY, dw, dh);
viewCtx.globalAlpha = 1;
}
viewCtx.imageSmoothingEnabled = false; viewCtx.imageSmoothingEnabled = false;
viewCtx.drawImage(srcCanvas, panX, panY, dw, dh); viewCtx.drawImage(srcCanvas, panX, panY, dw, dh);
// Floating (moved/pasted) pixels render above the sprite.
if (floating) {
viewCtx.imageSmoothingEnabled = false;
viewCtx.drawImage(floating.canvas,
panX + floating.x * zoom, panY + floating.y * zoom,
floating.w * zoom, floating.h * zoom);
}
// Artboard border. // Artboard border.
viewCtx.strokeStyle = '#5c94fc'; viewCtx.strokeStyle = '#5c94fc';
viewCtx.lineWidth = 1; viewCtx.lineWidth = 1;
@@ -535,12 +935,23 @@
if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh); if (showGrid && zoom >= GRID_MIN_ZOOM) drawGrid(dw, dh);
if (selection) drawMarquee(selection, '#fcd444');
if (floating) drawMarquee(floating, '#54fc54');
els.zoomVal.textContent = Math.round(zoom * 100) + '%'; els.zoomVal.textContent = Math.round(zoom * 100) + '%';
} }
function drawMarquee(r, stroke) {
viewCtx.save();
viewCtx.setLineDash([4, 3]);
viewCtx.strokeStyle = stroke;
viewCtx.lineWidth = 1;
viewCtx.strokeRect(panX + r.x * zoom + 0.5, panY + r.y * zoom + 0.5, r.w * zoom, r.h * zoom);
viewCtx.restore();
}
function drawCheckerboard(ox, oy, dw, dh) { function drawCheckerboard(ox, oy, dw, dh) {
const tile = 8; const tile = 8;
// Clip to artboard so the checker stays inside the sprite bounds.
viewCtx.save(); viewCtx.save();
viewCtx.beginPath(); viewCtx.beginPath();
viewCtx.rect(ox, oy, dw, dh); viewCtx.rect(ox, oy, dw, dh);
@@ -578,35 +989,39 @@
// Undo / redo // Undo / redo
// =================================================================== // ===================================================================
function pushHistory() { function pushHistory() {
// Drop any redo tail.
if (historyIndex < history.length - 1) { if (historyIndex < history.length - 1) {
history = history.slice(0, historyIndex + 1); history = history.slice(0, historyIndex + 1);
} }
history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)); history.push(srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H));
// Cap memory use.
if (history.length > HISTORY_LIMIT) history.shift(); if (history.length > HISTORY_LIMIT) history.shift();
historyIndex = history.length - 1; historyIndex = history.length - 1;
updateHistoryButtons(); updateHistoryButtons();
} }
function undo() { function restore(index) {
if (historyIndex <= 0) return; // Pending floating/selection are invalidated by an undo/redo.
historyIndex--; floating = null; selection = null; selDrag = null;
srcCtx.putImageData(history[historyIndex], 0, 0); updateSelReadout();
srcCtx.putImageData(history[index], 0, 0);
render(); render();
updateHistoryButtons(); updateHistoryButtons();
} }
function undo() {
stampFloating();
if (historyIndex <= 0) return;
historyIndex--;
restore(historyIndex);
}
function redo() { function redo() {
if (historyIndex >= history.length - 1) return; if (historyIndex >= history.length - 1) return;
historyIndex++; historyIndex++;
srcCtx.putImageData(history[historyIndex], 0, 0); restore(historyIndex);
render();
updateHistoryButtons();
} }
function updateHistoryButtons() { function updateHistoryButtons() {
els.undo.disabled = historyIndex <= 0; els.undo.disabled = historyIndex <= 0 && !floating;
els.redo.disabled = historyIndex >= history.length - 1; els.redo.disabled = historyIndex >= history.length - 1;
} }
@@ -615,6 +1030,7 @@
// =================================================================== // ===================================================================
function save() { function save() {
if (!spriteType) return; if (!spriteType) return;
stampFloating(); // bake any floating pixels before exporting
const saveBtn = root.querySelector('#pe-save'); const saveBtn = root.querySelector('#pe-save');
saveBtn.disabled = true; saveBtn.disabled = true;
saveBtn.textContent = 'SAVING…'; saveBtn.textContent = 'SAVING…';
@@ -627,12 +1043,9 @@
const fd = new FormData(); const fd = new FormData();
fd.append('file', blob, `${spriteType}.png`); fd.append('file', blob, `${spriteType}.png`);
try { try {
const res = await fetch(`/api/admin/sprites/${spriteType}`, { const res = await fetch(`/api/admin/sprites/${spriteType}`, { method: 'POST', body: fd });
method: 'POST', body: fd,
});
if (res.ok) { if (res.ok) {
toast(`${spriteType} sprite saved`); toast(`${spriteType} sprite saved`);
// Treat the saved state as the new baseline.
history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)]; history = [srcCtx.getImageData(0, 0, SPRITE_W, SPRITE_H)];
historyIndex = 0; historyIndex = 0;
updateHistoryButtons(); updateHistoryButtons();
@@ -653,28 +1066,47 @@
// =================================================================== // ===================================================================
function onKeyDown(e) { function onKeyDown(e) {
if (!isOpen()) return; if (!isOpen()) return;
// Don't hijack typing in inputs. // Don't hijack typing in text/number inputs.
if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') { if (e.target.tagName === 'INPUT' && e.target.type !== 'checkbox' && e.target.type !== 'range') {
if (e.key === 'Escape') e.target.blur(); if (e.key === 'Escape') e.target.blur();
return; return;
} }
const ctrl = e.ctrlKey || e.metaKey; const ctrl = e.ctrlKey || e.metaKey;
if (ctrl && e.key.toLowerCase() === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; } if (ctrl) {
if (ctrl && (e.key.toLowerCase() === 'y' || (e.key.toLowerCase() === 'z' && e.shiftKey))) { const k = e.key.toLowerCase();
e.preventDefault(); redo(); return; if (k === 'z' && !e.shiftKey) { e.preventDefault(); undo(); return; }
if (k === 'y' || (k === 'z' && e.shiftKey)) { e.preventDefault(); redo(); return; }
if (k === 'c') { e.preventDefault(); copySelection(); return; }
if (k === 'x') { e.preventDefault(); cutSelection(); return; }
if (k === 'v') { e.preventDefault(); pasteClipboard(); return; }
return;
} }
switch (e.key) { switch (e.key) {
case 'Escape': if (confirmDiscard()) close(); break; case 'Escape':
if (floating) { stampFloating(); }
else if (selection) { selection = null; updateSelReadout(); render(); }
else if (confirmDiscard()) close();
break;
case 'Enter': if (floating) stampFloating(); break;
case 'Delete': case 'Backspace': e.preventDefault(); deleteSelection(); break;
case 'b': case 'B': setTool('pencil'); break; case 'b': case 'B': setTool('pencil'); break;
case 'e': case 'E': setTool('eraser'); break; case 'e': case 'E': setTool('eraser'); break;
case 'i': case 'I': setTool('picker'); break; case 'i': case 'I': setTool('picker'); break;
case 'f': case 'F': setTool('fill'); break; case 'f': case 'F': setTool('fill'); break;
case 'l': case 'L': setTool('line'); break;
case 'r': case 'R': setTool('rect'); break;
case 'o': case 'O': setTool('ellipse'); break;
case 'm': case 'M': setTool('select'); break;
case 'g': case 'G': els.grid.checked = !els.grid.checked; showGrid = els.grid.checked; render(); break; case 'g': case 'G': els.grid.checked = !els.grid.checked; showGrid = els.grid.checked; render(); break;
case '+': case '=': zoomAt(1.25, centerPoint()); break; case '+': case '=': zoomAt(1.25, centerPoint()); break;
case '-': case '_': zoomAt(0.8, centerPoint()); break; case '-': case '_': zoomAt(0.8, centerPoint()); break;
case '0': fitToScreen(); break; case '0': fitToScreen(); break;
case 'ArrowLeft': if (selOrFloat()) { e.preventDefault(); nudgeFloating(-1, 0); } break;
case 'ArrowRight': if (selOrFloat()) { e.preventDefault(); nudgeFloating(1, 0); } break;
case 'ArrowUp': if (selOrFloat()) { e.preventDefault(); nudgeFloating(0, -1); } break;
case 'ArrowDown': if (selOrFloat()) { e.preventDefault(); nudgeFloating(0, 1); } break;
case ' ': case ' ':
if (!spaceDown) { spaceDown = true; updateCursor(); } if (!spaceDown) { spaceDown = true; updateCursor(); }
e.preventDefault(); e.preventDefault();
@@ -682,6 +1114,8 @@
} }
} }
function selOrFloat() { return tool === 'select' && (selection || floating); }
function onKeyUp(e) { function onKeyUp(e) {
if (!isOpen()) return; if (!isOpen()) return;
if (e.key === ' ') { spaceDown = false; updateCursor(); } if (e.key === ' ') { spaceDown = false; updateCursor(); }
@@ -703,10 +1137,9 @@
return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join(''); return '#' + [r, g, b].map(v => v.toString(16).padStart(2, '0')).join('');
} }
// Reuse the admin toast if present, else fall back to a console message.
function toast(msg, isError) { function toast(msg, isError) {
const el = document.getElementById('toast'); const el = document.getElementById('toast');
if (!el) { return; } if (!el) return;
el.textContent = msg; el.textContent = msg;
el.classList.remove('hidden', 'error'); el.classList.remove('hidden', 'error');
if (isError) el.classList.add('error'); if (isError) el.classList.add('error');
+4 -2
View File
@@ -6,9 +6,11 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
- PYTHONUNBUFFERED=1 - PYTHONUNBUFFERED=1
- ADSBIT_CONFIG=/app/data/config.json
volumes: volumes:
# Persist config (copy config.json.example to config.json before first run) # Config persists here; auto-seeded from config.json.example on first run
- ./config.json:/app/config.json # (a directory mount, so a fresh `docker compose up` just works).
- ./data:/app/data
# Persist custom sprite uploads # Persist custom sprite uploads
- ./images:/app/images - ./images:/app/images
# Persist custom theme backgrounds # Persist custom theme backgrounds
+16
View File
@@ -0,0 +1,16 @@
#!/bin/sh
# Seed a config file on first run so `docker compose up` works on a fresh
# clone with no manual steps. The config lives on a mounted data volume so it
# persists across container rebuilds.
set -e
: "${ADSBIT_CONFIG:=/app/config.json}"
CONFIG_DIR=$(dirname "$ADSBIT_CONFIG")
mkdir -p "$CONFIG_DIR"
if [ ! -f "$ADSBIT_CONFIG" ]; then
echo "[entrypoint] No config at $ADSBIT_CONFIG — seeding from config.json.example"
cp /app/config.json.example "$ADSBIT_CONFIG"
fi
exec python3 server.py
Binary file not shown.

After

Width:  |  Height:  |  Size: 304 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 857 KiB

+42 -4
View File
@@ -17,8 +17,16 @@ from aiohttp import web, ClientSession, ClientTimeout
import netifaces import netifaces
import bcrypt import bcrypt
VERSION = "1.1.2"
WEB_DIR = Path(__file__).parent WEB_DIR = Path(__file__).parent
CONFIG_FILE = WEB_DIR / "config.json" # Config path is overridable (handy for containers persisting config on a
# named volume); defaults to config.json next to this script for bare-metal.
CONFIG_FILE = Path(os.environ.get("ADSBIT_CONFIG") or (WEB_DIR / "config.json"))
# Smallest subnet prefix (largest network) we'll auto-scan: /20 = 4094 hosts.
# Anything bigger (e.g. a docker /16) is skipped to keep scans fast and safe.
MIN_SCAN_PREFIX = 20
# Track server start time # Track server start time
SERVER_START_TIME = time.time() SERVER_START_TIME = time.time()
@@ -412,6 +420,15 @@ async def scan_for_receivers():
if ip and netmask and not ip.startswith('127.'): if ip and netmask and not ip.startswith('127.'):
try: try:
network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False) network = ipaddress.IPv4Network(f"{ip}/{netmask}", strict=False)
# Skip subnets too large to scan quickly (e.g. a docker
# bridge /16 = 65k hosts). Without this, AUTO discovery
# hammers the network and stalls startup on any host with
# Docker or a large/16. Use MANUAL or a custom subnet for
# receivers that live on such networks.
if network.prefixlen < MIN_SCAN_PREFIX:
print(f"Skipping {network} on {iface}: too large "
f"({network.num_addresses} hosts) for auto-scan")
continue
found.extend(await _scan_subnet(network, port)) found.extend(await _scan_subnet(network, port))
except ValueError as e: except ValueError as e:
print(f"Invalid network {ip}/{netmask}: {e}") print(f"Invalid network {ip}/{netmask}: {e}")
@@ -549,6 +566,7 @@ async def handle_receiver_location(request):
async def handle_config(request): async def handle_config(request):
"""Return client-relevant configuration (public, no secrets).""" """Return client-relevant configuration (public, no secrets)."""
return web.json_response({ return web.json_response({
"version": VERSION,
"theme": config.get("theme", "desert"), "theme": config.get("theme", "desert"),
"location": config["location"], "location": config["location"],
"receivers": receivers, "receivers": receivers,
@@ -981,9 +999,10 @@ async def handle_admin_scan_receivers(request):
return web.json_response( return web.json_response(
{"error": f"Invalid subnet: {subnet}"}, status=400) {"error": f"Invalid subnet: {subnet}"}, status=400)
if network.prefixlen < 20: if network.prefixlen < MIN_SCAN_PREFIX:
return web.json_response( return web.json_response(
{"error": "Subnet too large (max /20 = 4096 hosts)"}, status=400) {"error": f"Subnet too large (max /{MIN_SCAN_PREFIX} = "
f"{2 ** (32 - MIN_SCAN_PREFIX) - 2} hosts)"}, status=400)
port = config['receiver_port'] port = config['receiver_port']
found = await _scan_subnet(network, port) found = await _scan_subnet(network, port)
@@ -1169,6 +1188,17 @@ async def handle_http(request):
return web.Response(status=404, text="Not Found") return web.Response(status=404, text="Not Found")
async def handle_health(request):
"""GET /health - liveness/readiness probe (no auth, no secrets)."""
return web.json_response({
"status": "ok",
"version": VERSION,
"receivers": len(receivers),
"flights": len(flights),
"uptime_seconds": int(time.time() - SERVER_START_TIME),
})
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Application setup # Application setup
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -1181,6 +1211,7 @@ async def start_http_server():
app.router.add_get('/ws', websocket_handler) app.router.add_get('/ws', websocket_handler)
# Public API # Public API
app.router.add_get('/health', handle_health)
app.router.add_get('/api/receiver-location', handle_receiver_location) app.router.add_get('/api/receiver-location', handle_receiver_location)
app.router.add_get('/api/config', handle_config) app.router.add_get('/api/config', handle_config)
@@ -1231,7 +1262,7 @@ async def main():
"""Main entry point""" """Main entry point"""
global receivers, receiver_tasks global receivers, receiver_tasks
print("ADS-Bit Server Starting...") print(f"ADS-Bit Server v{VERSION} Starting...")
load_config() load_config()
@@ -1279,4 +1310,11 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
# Make SIGTERM (docker stop, systemctl stop) raise KeyboardInterrupt like
# SIGINT does, so shutdown is clean and quiet instead of a stack trace.
import signal
signal.signal(signal.SIGTERM, signal.default_int_handler)
try:
asyncio.run(main()) asyncio.run(main())
except (KeyboardInterrupt, SystemExit):
print("ADS-Bit server shutting down.")